26 Commits

Author SHA1 Message Date
Hein 25d40ad515 chore(release): bump version to 0.0.7
CI / Build (push) Successful in 20s
Release / Release (push) Successful in 36s
Release / Debian packages (push) Successful in 33s
Release / VSCode Extension (push) Successful in 35s
Release / AUR package (push) Successful in 55s
Release / RPM package (push) Successful in 56s
Release / Windows installer (push) Successful in 59s
Release / DataGrip Plugin (push) Successful in 4m29s
CI / Test (push) Successful in 33s
Release / Test (push) Successful in 31s
2026-07-17 14:29:45 +02:00
Hein bac966c2ac feat(format): add runtime semantic-equality safety gate
CI / Test (push) Successful in 31s
CI / Build (push) Successful in 23s
2026-07-17 14:29:24 +02:00
Hein 4abc89700f chore(release): bump version to 0.0.6
Release / AUR package (push) Successful in 53s
Release / DataGrip Plugin (push) Successful in 4m17s
Release / Release (push) Successful in 1m57s
Release / VSCode Extension (push) Successful in 56s
Release / Test (push) Successful in 1m50s
CI / Test (push) Successful in 1m51s
CI / Build (push) Successful in 21s
Release / RPM package (push) Successful in 39s
Release / Debian packages (push) Successful in 47s
Release / Windows installer (push) Successful in 47s
2026-07-17 13:21:20 +02:00
Hein ad52f21cc2 feat(datagrip): migrate from LSP4IJ to native CLI integration
CI / Test (push) Successful in 26s
CI / Build (push) Successful in 22s
2026-07-17 13:20:49 +02:00
Hein 60602d1de7 chore(release): bump version to 0.0.5
CI / Test (push) Successful in 25s
CI / Build (push) Successful in 20s
Release / Test (push) Failing after 1m51s
Release / Release (push) Has been skipped
Release / Debian packages (push) Has been skipped
Release / RPM package (push) Has been skipped
Release / Windows installer (push) Has been skipped
Release / AUR package (push) Has been skipped
Release / VSCode Extension (push) Has been skipped
Release / DataGrip Plugin (push) Has been skipped
2026-07-17 09:40:51 +02:00
warkanum 8d19258aa0 Merge pull request 'Fix PL/pgSQL formatting indentation' (#2) from issue-1-formatting-indenting into main
CI / Test (push) Successful in 26s
CI / Build (push) Successful in 29s
Reviewed-on: #2
2026-07-16 19:38:55 +00:00
Hein 5cba6beeb1 fix: align PLpgSQL formatting clauses
CI / Test (pull_request) Successful in 43s
CI / Build (pull_request) Successful in 1m2s
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
warkanum 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
warkanum 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
warkanum 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
warkanum 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
warkanum 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
warkanum 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
warkanum 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
warkanum 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
47 changed files with 6065 additions and 399 deletions
+103 -40
View File
@@ -55,6 +55,14 @@ jobs:
-ldflags "-X main.version=${VERSION}" \
-o "$NAME" ./cmd/pgtidy
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
- name: Create release and upload binaries
@@ -64,12 +72,11 @@ jobs:
API="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases"
PREV_TAG=$(git tag --sort=-version:refname | { grep -v "^${TAG}$" || true; } | head -1)
if [ -n "$PREV_TAG" ]; then
RANGE="${PREV_TAG}..${TAG}"
if [ -n "$PREV_TAG" ] && git rev-parse --verify "${PREV_TAG}" >/dev/null 2>&1; then
NOTES=$(git log "${PREV_TAG}..${TAG}" --pretty=format:"- %s" --no-merges)
else
RANGE="HEAD~20..HEAD"
NOTES=$(git log --pretty=format:"- %s" --no-merges)
fi
NOTES=$(git log "$RANGE" --pretty=format:"- %s" --no-merges)
BODY="## What's changed"$'\n'"${NOTES}"
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" \
-H "Authorization: token ${GITHUB_TOKEN}" \
-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"
UPLOAD_URL=$(echo "$RELEASE" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4 | sed 's/{[^}]*}//')
@@ -86,7 +93,7 @@ jobs:
exit 1
fi
for f in pgtidy-*; do
for f in pgtidy-* pgtidy_*.tar.gz; do
echo "Uploading $f..."
curl -sf -X POST "${UPLOAD_URL}?name=${f}" \
-H "Authorization: token ${GITHUB_TOKEN}" \
@@ -152,47 +159,36 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
fetch-depth: 0
go-version-file: go.mod
- name: Build RPM
run: |
set -euo pipefail
VERSION="${{ github.event.inputs.tag || github.ref_name }}"
PKGVER="${VERSION#v}"
GO_VER="$(awk '/^go / { print $2; exit }' go.mod)"
git archive --format=tar.gz --prefix=pgtidy-${PKGVER}/ HEAD \
> pgtidy-${PKGVER}.tar.gz
sudo apt-get update -y
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
CID=$(docker create \
-e GO_VER="${GO_VER}" \
-e PKGVER="${PKGVER}" \
-w /build \
rockylinux:9 \
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
")
mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
cp pgtidy ~/rpmbuild/SOURCES/pgtidy
cp LICENSE ~/rpmbuild/SOURCES/LICENSE
cp linux/centos/pgtidy.spec ~/rpmbuild/SPECS/
rpmbuild --nodeps -bb ~/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
docker cp "$CID:/root/rpmbuild/RPMS/." rpm-out/
trap - EXIT; cleanup
find ~/rpmbuild/RPMS/ -name '*.rpm' -exec cp {} rpm-out/ \;
- name: Upload to release
run: |
@@ -212,6 +208,52 @@ jobs:
env:
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:
name: AUR package
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 clone ssh://aur@aur.archlinux.org/pgtidy-bin.git aur-repo
if [ -f aur-repo/PKGBUILD ]; then
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
PKGREL=$((CURRENT_PKGREL + 1))
@@ -270,8 +317,8 @@ jobs:
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_ARM64=$(curl -fsSL "https://git.warky.dev/wdevs/pgtidy/releases/download/v${PKGVER}/pgtidy-linux-arm64" | 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_${PKGVER}_linux_arm64.tar.gz" | sha256sum | cut -d' ' -f1)
sed -e "s/^pkgver=.*/pkgver=${PKGVER}/" \
-e "s/^pkgrel=.*/pkgrel=${PKGREL}/" \
@@ -312,14 +359,24 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
- uses: pnpm/action-setup@v4
with:
version: latest
- name: Set version from tag
working-directory: editors/vscode
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
npm pkg set version="${TAG#v}"
- name: Install and package
working-directory: editors/vscode
run: |
npm ci
npm run compile
npm run package
pnpm install --frozen-lockfile
pnpm run compile
pnpm run package
- name: Upload to release
run: |
@@ -351,6 +408,12 @@ jobs:
distribution: temurin
java-version: '21'
- name: Set version from tag
working-directory: editors/datagrip
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
sed -i "s/^pluginVersion=.*/pluginVersion=${TAG#v}/" gradle.properties
- name: Build plugin
working-directory: editors/datagrip
run: ./gradlew buildPlugin
+8
View File
@@ -44,6 +44,10 @@ jobs:
cache: npm
cache-dependency-path: editors/vscode/package-lock.json
- name: Set version from tag
working-directory: editors/vscode
run: npm pkg set version="${GITHUB_REF_NAME#v}"
- name: Install and package
working-directory: editors/vscode
run: |
@@ -73,6 +77,10 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Set version from tag
working-directory: editors/datagrip
run: sed -i "s/^pluginVersion=.*/pluginVersion=${GITHUB_REF_NAME#v}/" gradle.properties
- name: Build plugin
working-directory: editors/datagrip
run: ./gradlew buildPlugin
+4
View File
@@ -0,0 +1,4 @@
version: "2"
linters:
default: standard
+12 -3
View File
@@ -36,7 +36,7 @@ pkg/pgast/ — go-pgquery wrapper: SQL → real PG AST (lint, v2)
pkg/lint/ — rule engine + rule packs (v2)
pkg/lsp/ — LSP server (v3)
editors/vscode/ — VSCode extension (v3)
editors/datagrip/ — LSP4IJ integration (v4)
editors/datagrip/ — native JetBrains plugin, shells out to the CLI (v4)
testdata/corpus/ — real-world .pgsql procedures used as the safety/idempotence harness
```
@@ -59,6 +59,14 @@ testdata/corpus/ — real-world .pgsql procedures used as the safety/idempoten
3. **Idempotence**: `fmt(fmt(x)) == fmt(x)`.
4. **Graceful degradation**: any span the parser cannot handle is passed through verbatim
rather than corrupted.
5. **Runtime safety gate**: every frontend (CLI `fmt`, LSP `textDocument/formatting` and
`rangeFormatting`) calls `format.SemanticallyEqual(src, out)` before writing or returning
formatted output. It re-lexes both sides and compares non-trivia token streams
(case-insensitive for identifiers/keywords, exact otherwise, recursing into dollar-quoted
bodies). If it ever returns false, the formatter has a bug — the caller must refuse to
write/emit the result and keep the original source, never guess or best-effort it. This is
not just a test assertion (`pkg/format/format_test.go`); it is enforced at runtime so a
formatter bug can never silently drop or alter code.
## Commands
@@ -111,8 +119,9 @@ go fmt ./... # Format Go code
Per-platform VSIX (`win32/linux/darwin × x64/arm64`) built in CI matrix.
### V4 — DataGrip
- `editors/datagrip`: integrate via LSP4IJ (free, works across JetBrains editions incl.
DataGrip). No core changes expected.
- `editors/datagrip`: native JetBrains plugin (no LSP4IJ dependency) that shells out to the
`pgtidy` binary directly. Formatting and version info run `pgtidy fmt`/`version`; lint
diagnostics and quick-fixes run via a native `ExternalAnnotator` calling `pgtidy lint --json`.
## Verification
- **Formatter:** `go test ./...` runs golden-file tests + corpus harness asserting idempotence
+2 -1
View File
@@ -20,9 +20,10 @@ vet:
fmt:
go fmt ./...
## lint: vet + format check
## lint: vet + format check + golangci-lint
lint: vet
test -z "$$(gofmt -l .)" || (echo "gofmt needed:"; gofmt -l .; exit 1)
golangci-lint run ./...
## clean: remove build artifacts
clean:
+8 -8
View File
@@ -11,7 +11,7 @@ import (
func cmdConfig(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
for _, a := range args {
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
}
}
@@ -21,14 +21,14 @@ func cmdConfig(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
}
st, err := config.Load(wd)
if err != nil {
fmt.Fprintf(stderr, "pgtidy: %v\n", err)
_, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err)
return 2
}
fmt.Fprintf(stdout, "indent: %q\n", st.Indent)
fmt.Fprintf(stdout, "newline: %q\n", st.Newline)
fmt.Fprintf(stdout, "keyword_case: %s\n", st.KeywordCase)
fmt.Fprintf(stdout, "ident_case: %s\n", st.IdentCase)
fmt.Fprintf(stdout, "type_case: %s\n", st.TypeCase)
fmt.Fprintf(stdout, "commas: %s\n", st.Commas)
_, _ = fmt.Fprintf(stdout, "indent: %q\n", st.Indent)
_, _ = fmt.Fprintf(stdout, "newline: %q\n", st.Newline)
_, _ = fmt.Fprintf(stdout, "keyword_case: %s\n", st.KeywordCase)
_, _ = fmt.Fprintf(stdout, "ident_case: %s\n", st.IdentCase)
_, _ = fmt.Fprintf(stdout, "type_case: %s\n", st.TypeCase)
_, _ = fmt.Fprintf(stdout, "commas: %s\n", st.Commas)
return 0
}
+19 -10
View File
@@ -37,7 +37,7 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 0
default:
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
}
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)
if err != nil {
fmt.Fprintf(stderr, "%v\n", err)
_, _ = fmt.Fprintf(stderr, "%v\n", err)
return 2
}
@@ -59,19 +59,23 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
if len(files) == 0 {
src, err := io.ReadAll(stdin)
if err != nil {
fmt.Fprintf(stderr, "pgtidy: reading stdin: %v\n", err)
_, _ = fmt.Fprintf(stderr, "pgtidy: reading stdin: %v\n", err)
return 2
}
out := format.File(parser.Parse(string(src)), st)
if !format.SemanticallyEqual(string(src), out) {
_, _ = fmt.Fprintln(stderr, "pgtidy: refusing to format stdin: formatter safety check failed (output would change code content)")
return 2
}
switch {
case check:
if out != string(src) {
return 1
}
case diff:
io.WriteString(stdout, unifiedDiff(string(src), out, "stdin"))
_, _ = io.WriteString(stdout, unifiedDiff(string(src), out, "stdin"))
default:
io.WriteString(stdout, out)
_, _ = io.WriteString(stdout, out)
}
return 0
}
@@ -81,11 +85,16 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
for _, path := range files {
src, err := os.ReadFile(path)
if err != nil {
fmt.Fprintf(stderr, "pgtidy: %v\n", err)
_, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err)
exit = 2
continue
}
out := format.File(parser.Parse(string(src)), st)
if !format.SemanticallyEqual(string(src), out) {
_, _ = fmt.Fprintf(stderr, "pgtidy: refusing to format %s: formatter safety check failed (output would change code content)\n", path)
exit = 2
continue
}
changed := out != string(src)
if changed {
anyDiff = true
@@ -94,22 +103,22 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
case write:
if changed {
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
}
}
case list:
if changed {
fmt.Fprintln(stdout, path)
_, _ = fmt.Fprintln(stdout, path)
}
case diff:
if changed {
io.WriteString(stdout, unifiedDiff(string(src), out, path))
_, _ = io.WriteString(stdout, unifiedDiff(string(src), out, path))
}
case check:
// handled after loop via anyDiff
default:
io.WriteString(stdout, out)
_, _ = io.WriteString(stdout, out)
}
}
if check && anyDiff && exit == 0 {
+37 -15
View File
@@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"fmt"
"io"
"os"
@@ -20,6 +21,7 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
var (
only []string // --only=ID,ID rule filter
fix bool
jsonOutput bool
files []string
)
for _, a := range args {
@@ -29,6 +31,8 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 0
case a == "--fix":
fix = true
case a == "--json":
jsonOutput = true
case strings.HasPrefix(a, "--only="):
ids := strings.Split(strings.TrimPrefix(a, "--only="), ",")
for _, id := range ids {
@@ -37,7 +41,7 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
}
}
case len(a) > 1 && a[0] == '-':
fmt.Fprintf(stderr, "pgtidy lint: unknown flag %q\n", a)
_, _ = fmt.Fprintf(stderr, "pgtidy lint: unknown flag %q\n", a)
return 2
default:
files = append(files, a)
@@ -66,7 +70,13 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return diags, nil
}
printDiags := func(diags []diagnostics.Diagnostic) {
var jsonDiags []diagnostics.Diagnostic
reportDiags := func(diags []diagnostics.Diagnostic) {
if jsonOutput {
jsonDiags = append(jsonDiags, diags...)
return
}
for _, d := range diags {
loc := d.File
if d.Line > 0 {
@@ -75,7 +85,7 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
if loc == "" {
loc = "stdin"
}
fmt.Fprintf(stdout, "%s: [%s] %s: %s\n", loc, d.RuleID, d.Severity, d.Message)
_, _ = fmt.Fprintf(stdout, "%s: [%s] %s: %s\n", loc, d.RuleID, d.Severity, d.Message)
}
}
@@ -84,22 +94,22 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
if len(files) == 0 {
src, err := io.ReadAll(stdin)
if err != nil {
fmt.Fprintf(stderr, "pgtidy: reading stdin: %v\n", err)
_, _ = fmt.Fprintf(stderr, "pgtidy: reading stdin: %v\n", err)
return 2
}
diags, err := check(string(src), "")
if err != nil {
fmt.Fprintf(stderr, "pgtidy: %v\n", err)
_, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err)
return 2
}
if fix {
if fix && !jsonOutput {
fixed := lint.ApplyFixes(string(src), diags)
if fixed != string(src) {
io.WriteString(stdout, fixed)
_, _ = io.WriteString(stdout, fixed)
return 0
}
}
printDiags(diags)
reportDiags(diags)
if len(diags) > 0 {
found = true
}
@@ -107,36 +117,47 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
for _, path := range files {
src, err := os.ReadFile(path)
if err != nil {
fmt.Fprintf(stderr, "pgtidy: %v\n", err)
_, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err)
return 2
}
diags, err := check(string(src), path)
if err != nil {
fmt.Fprintf(stderr, "pgtidy: %v\n", err)
_, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err)
return 2
}
if fix {
if fix && !jsonOutput {
fixed := lint.ApplyFixes(string(src), diags)
if fixed != string(src) {
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
}
// Re-check to report any remaining unfixed diagnostics.
diags, err = check(fixed, path)
if err != nil {
fmt.Fprintf(stderr, "pgtidy: %v\n", err)
_, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err)
return 2
}
}
}
printDiags(diags)
reportDiags(diags)
if len(diags) > 0 {
found = true
}
}
}
if jsonOutput {
if jsonDiags == nil {
jsonDiags = []diagnostics.Diagnostic{}
}
enc := json.NewEncoder(stdout)
if err := enc.Encode(jsonDiags); err != nil {
_, _ = fmt.Fprintf(stderr, "pgtidy: encoding json: %v\n", err)
return 2
}
}
if found {
return 1
}
@@ -144,12 +165,13 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
}
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.
Flags:
--fix Apply autofixes for fixable rules (MIG001, MIG003) and rewrite files
--json Emit findings as a JSON array instead of text (ignores --fix)
--only=ID,... comma-separated rule IDs to enable (default: all rules)
-h, --help show this help
+2 -2
View File
@@ -12,7 +12,7 @@ import (
func cmdLsp(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
for _, a := range args {
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
}
}
@@ -21,7 +21,7 @@ func cmdLsp(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
wd = "."
}
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 0
+3 -3
View File
@@ -29,20 +29,20 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
case "config":
return cmdConfig(args[1:], stdin, stdout, stderr)
case "version", "--version", "-v":
fmt.Fprintf(stdout, "pgtidy %s\n", version)
_, _ = fmt.Fprintf(stdout, "pgtidy %s\n", version)
return 0
case "help", "-h", "--help":
usage(stdout)
return 0
default:
fmt.Fprintf(stderr, "pgtidy: unknown command %q\n", args[0])
_, _ = fmt.Fprintf(stderr, "pgtidy: unknown command %q\n", args[0])
usage(stderr)
return 2
}
}
func usage(w io.Writer) {
fmt.Fprint(w, `pgtidy — PostgreSQL formatter and linter
_, _ = fmt.Fprint(w, `pgtidy — PostgreSQL formatter and linter
Usage:
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).
# Any field you omit keeps its default.
# Indentation string for one level (two spaces).
indent: " "
# --- Core ---
# Line terminator written by the formatter.
newline: "\n"
indent: " " # One indentation level (two spaces).
newline: "\n" # Line terminator emitted by the formatter.
# Casing for SQL keywords (SELECT, FROM, WHERE, …).
# --- Casing ---
# upper | lower | preserve
keyword_case: upper
# Casing for unquoted identifiers (column names, variable names, …).
# upper | lower | preserve
ident_case: lower
keyword_case: upper # SQL keywords (SELECT, FROM, WHERE, …)
ident_case: lower # Unquoted identifiers (column/variable names)
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, …).
# upper | lower | preserve
type_case: lower
# --- Query layout ---
# Comma placement in multi-line parameter / column lists.
# leading → comma at the start of the continuation line (,col)
# trailing → comma at the end of the preceding line (col,)
commas: leading
commas: leading # leading → ,col | trailing → col,
align_columns: false # Pad SELECT list items so values align vertically
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 `(`.
- 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
### 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
discovery walking up to `.pgtidy.yaml`; CI-friendly exit codes.
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
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: 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
- `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.
+7 -17
View File
@@ -1,37 +1,27 @@
# PgTidy — DataGrip / JetBrains Plugin
Formats and lints SQL files in DataGrip (and any JetBrains IDE) via the LSP4IJ plugin.
Formats SQL files in DataGrip (and any JetBrains IDE) by shelling out to the `pgtidy` binary.
## Requirements
- `pgtidy` binary on `PATH` — download from [releases](https://git.warky.dev/wdevs/PgTidy/releases) or build with `go install git.warky.dev/wdevs/pgtidy/cmd/pgtidy@latest`
- [LSP4IJ](https://plugins.jetbrains.com/plugin/23257-lsp4ij) plugin installed (free, by Red Hat)
- DataGrip 2024.3+ (or any JetBrains IDE 2024.3+)
## Install the plugin
### Option A — Install from disk (`.zip`)
1. Build: `./gradlew buildPlugin` (output in `build/distributions/`)
2. In DataGrip: **Settings → Plugins → ⚙ → Install Plugin from Disk…** → select the `.zip`
3. Restart the IDE
### Option B — Install LSP4IJ and configure manually (no plugin build needed)
1. Install **LSP4IJ** from the marketplace (**Settings → Plugins → Marketplace → search "LSP4IJ"**)
2. Go to **Settings → Language Servers → + (Add)**
3. Fill in:
- **Name:** `PgTidy`
- **Command:** `pgtidy lsp`
4. Under **Mappings**, add file patterns: `*.sql`, `*.pgsql`
5. Click **OK** and restart the IDE
## Features
- **Formatting** — `Code → Reformat Code` (`Ctrl+Alt+L`) formats the current SQL file
- **Diagnostics** — lint findings shown as inspections (MIG001MIG003, COR001003, NAM001003)
- **Formatting** — `Format with PgTidy` (`Ctrl+Alt+Shift+P` or right-click in the editor) formats the current SQL file via `pgtidy fmt`
- **Version check** — `Tools → Show PgTidy Version` and the status bar widget show the installed `pgtidy` version
- **Diagnostics** — lint findings (MIG001MIG003, COR001003, NAM001003) are shown inline as editor annotations, via a native `ExternalAnnotator` that runs `pgtidy lint --json`
- **Quick fixes** — intention actions for MIG001 (add `CONCURRENTLY`) and MIG003 (add `NOT VALID`)
## Troubleshooting
**Server not starting:** Check **View → Tool Windows → LSP4IJ Consoles** for stderr output. Most common cause: `pgtidy` not found on `PATH` — update the command to the full path, e.g. `/usr/local/bin/pgtidy lsp`.
**"Cannot start pgtidy" error:** the `pgtidy` binary isn't on `PATH` for the IDE process. Install it or restart the IDE after adding it to `PATH`.
**No diagnostics showing:** diagnostics only run on files with a `.sql` or `.pgsql` extension; check `pgtidy lint --json` runs cleanly on the file from a terminal with the same `PATH` as the IDE.
-1
View File
@@ -23,7 +23,6 @@ dependencies {
val platformType = providers.gradleProperty("platformType")
create(platformType, platformVersion)
bundledPlugin("com.intellij.database")
plugin("com.redhat.devtools.lsp4ij:${providers.gradleProperty("lsp4ijVersion").get()}")
}
}
-1
View File
@@ -5,4 +5,3 @@ pluginSinceBuild=243
# DataGrip 2024.3
platformVersion=2024.3
platformType=DB
lsp4ijVersion=0.20.1
@@ -0,0 +1,18 @@
package com.pgtidy.datagrip
data class PgTidyFix(
val offset: Int,
val end: Int,
val new: String,
val title: String,
)
data class PgTidyDiagnostic(
val ruleId: String,
val severity: String,
val message: String,
val file: String,
val line: Int,
val col: Int,
val fix: PgTidyFix?,
)
@@ -0,0 +1,50 @@
package com.pgtidy.datagrip
import com.intellij.lang.annotation.AnnotationHolder
import com.intellij.lang.annotation.ExternalAnnotator
import com.intellij.lang.annotation.HighlightSeverity
import com.intellij.openapi.editor.Document
import com.intellij.psi.PsiFile
class PgTidyExternalAnnotator : ExternalAnnotator<String, List<PgTidyDiagnostic>>() {
override fun collectInformation(file: PsiFile): String? {
val ext = file.virtualFile?.extension?.lowercase()
if (ext != "sql" && ext != "pgsql") return null
return file.viewProvider.document?.text
}
override fun doAnnotate(source: String): List<PgTidyDiagnostic> = PgTidyLintRunner.run(source)
override fun apply(file: PsiFile, diagnostics: List<PgTidyDiagnostic>, holder: AnnotationHolder) {
val document = file.viewProvider.document ?: return
for (d in diagnostics) {
if (d.line <= 0 || d.line > document.lineCount) continue
val range = lineTailRange(document, d.line, d.col) ?: continue
val severity = when (d.severity) {
"error" -> HighlightSeverity.ERROR
"warning" -> HighlightSeverity.WARNING
else -> HighlightSeverity.WEAK_WARNING
}
val builder = holder.newAnnotation(severity, "[${d.ruleId}] ${d.message}").range(range)
if (d.fix != null) {
builder.withFix(PgTidyQuickFix(d.fix))
}
builder.create()
}
}
/** Byte column [col] (1-based) to end-of-line, converted to a document TextRange. */
private fun lineTailRange(document: Document, line1Based: Int, col1Based: Int): com.intellij.openapi.util.TextRange? {
val lineIdx = line1Based - 1
val lineStart = document.getLineStartOffset(lineIdx)
val lineEnd = document.getLineEndOffset(lineIdx)
val lineText = document.getText(com.intellij.openapi.util.TextRange(lineStart, lineEnd))
val charCol = PgTidyOffsets.byteOffsetToCharIndex(lineText, col1Based - 1)
val start = lineStart + charCol
if (start >= lineEnd) return null
return com.intellij.openapi.util.TextRange(start, lineEnd)
}
}
@@ -0,0 +1,32 @@
package com.pgtidy.datagrip
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
object PgTidyLintRunner {
private val gson = Gson()
private val listType = TypeToken.getParameterized(List::class.java, PgTidyDiagnostic::class.java).type
/** Runs `pgtidy lint --json` over [source]. Returns an empty list if pgtidy is missing or output is unparseable. */
fun run(source: String): List<PgTidyDiagnostic> {
val proc = try {
ProcessBuilder("pgtidy", "lint", "--json")
.redirectErrorStream(false)
.start()
} catch (ex: Exception) {
return emptyList()
}
proc.outputStream.bufferedWriter().use { it.write(source) }
val output = proc.inputStream.bufferedReader().readText()
proc.errorStream.bufferedReader().readText()
proc.waitFor()
if (output.isBlank()) return emptyList()
return try {
gson.fromJson<List<PgTidyDiagnostic>>(output, listType) ?: emptyList()
} catch (ex: Exception) {
emptyList()
}
}
}
@@ -0,0 +1,28 @@
package com.pgtidy.datagrip
/**
* pgtidy reports positions as UTF-8 byte offsets (Go strings are byte slices);
* IntelliJ documents index text as UTF-16 chars. Converts one to the other.
*/
object PgTidyOffsets {
fun byteOffsetToCharIndex(text: String, byteOffset: Int): Int {
var bytes = 0
var i = 0
while (i < text.length) {
val cp = text.codePointAt(i)
val charCount = Character.charCount(cp)
val byteLen = utf8Length(cp)
if (bytes + byteLen > byteOffset) return i
bytes += byteLen
i += charCount
}
return text.length
}
private fun utf8Length(codePoint: Int): Int = when {
codePoint <= 0x7F -> 1
codePoint <= 0x7FF -> 2
codePoint <= 0xFFFF -> 3
else -> 4
}
}
@@ -0,0 +1,25 @@
package com.pgtidy.datagrip
import com.intellij.codeInsight.intention.IntentionAction
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiFile
import com.intellij.util.IncorrectOperationException
class PgTidyQuickFix(private val fix: PgTidyFix) : IntentionAction {
override fun getText(): String = fix.title
override fun getFamilyName(): String = "PgTidy"
override fun startInWriteAction(): Boolean = true
override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?): Boolean = editor != null
@Throws(IncorrectOperationException::class)
override fun invoke(project: Project, editor: Editor?, file: PsiFile?) {
val document = editor?.document ?: return
val text = document.text
val start = PgTidyOffsets.byteOffsetToCharIndex(text, fix.offset)
val end = PgTidyOffsets.byteOffsetToCharIndex(text, fix.end)
document.replaceString(start, end, fix.new)
}
}
@@ -1,9 +0,0 @@
package com.pgtidy.datagrip
import com.intellij.openapi.project.Project
import com.redhat.devtools.lsp4ij.server.ProcessStreamConnectionProvider
class PgTidyServerConnection(project: Project) : ProcessStreamConnectionProvider(
listOf("pgtidy", "lsp"),
project.basePath ?: System.getProperty("user.home"),
)
@@ -1,10 +0,0 @@
package com.pgtidy.datagrip
import com.intellij.openapi.project.Project
import com.redhat.devtools.lsp4ij.LanguageServerFactory
import com.redhat.devtools.lsp4ij.server.StreamConnectionProvider
class PgTidyServerFactory : LanguageServerFactory {
override fun createConnectionProvider(project: Project): StreamConnectionProvider =
PgTidyServerConnection(project)
}
@@ -0,0 +1,42 @@
package com.pgtidy.datagrip
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.progress.ProgressIndicator
import com.intellij.openapi.progress.Task
import com.intellij.openapi.ui.Messages
class PgTidyShowVersionAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
val project = e.project
object : Task.Backgroundable(project, "PgTidy: checking version…", false) {
override fun run(indicator: ProgressIndicator) {
val proc = try {
ProcessBuilder("pgtidy", "version")
.redirectErrorStream(false)
.start()
} catch (ex: Exception) {
ApplicationManager.getApplication().invokeLater {
Messages.showErrorDialog(project, "Cannot start pgtidy: ${ex.message}", "PgTidy")
}
return
}
val output = proc.inputStream.bufferedReader().readText()
val stderr = proc.errorStream.bufferedReader().readText()
val exit = proc.waitFor()
ApplicationManager.getApplication().invokeLater {
if (exit != 0) {
Messages.showErrorDialog(project, stderr.ifBlank { "pgtidy exited with code $exit" }, "PgTidy")
return@invokeLater
}
Messages.showInfoMessage(project, output.trim(), "PgTidy Version")
}
}
}.queue()
}
}
@@ -0,0 +1,72 @@
package com.pgtidy.datagrip
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.StatusBar
import com.intellij.openapi.wm.StatusBarWidget
import com.intellij.openapi.wm.StatusBarWidgetFactory
import com.intellij.util.Consumer
import java.awt.event.MouseEvent
private const val WIDGET_ID = "com.pgtidy.StatusBarWidget"
class PgTidyStatusBarWidget(private val project: Project) : StatusBarWidget, StatusBarWidget.TextPresentation {
private var statusBar: StatusBar? = null
private var text: String = "pgtidy: …"
override fun ID(): String = WIDGET_ID
override fun install(statusBar: StatusBar) {
this.statusBar = statusBar
refresh()
}
override fun dispose() {
statusBar = null
}
override fun getPresentation(): StatusBarWidget.WidgetPresentation = this
override fun getText(): String = text
override fun getAlignment(): Float = java.awt.Component.CENTER_ALIGNMENT
override fun getTooltipText(): String = "PgTidy version — click to refresh"
override fun getClickConsumer(): Consumer<MouseEvent> = Consumer { refresh() }
fun refresh() {
ApplicationManager.getApplication().executeOnPooledThread {
text = try {
val proc = ProcessBuilder("pgtidy", "version")
.redirectErrorStream(true)
.start()
val output = proc.inputStream.bufferedReader().readText().trim()
val exit = proc.waitFor()
if (exit == 0 && output.isNotBlank()) output else "pgtidy: not found"
} catch (ex: Exception) {
"pgtidy: not found"
}
ApplicationManager.getApplication().invokeLater {
statusBar?.updateWidget(WIDGET_ID)
}
}
}
}
class PgTidyStatusBarWidgetFactory : StatusBarWidgetFactory {
override fun getId(): String = WIDGET_ID
override fun getDisplayName(): String = "PgTidy Version"
override fun isAvailable(project: Project): Boolean = true
override fun createWidget(project: Project): StatusBarWidget = PgTidyStatusBarWidget(project)
override fun disposeWidget(widget: StatusBarWidget) {
widget.dispose()
}
override fun canBeEnabledOn(statusBar: StatusBar): Boolean = true
}
@@ -5,13 +5,11 @@
<vendor url="https://git.warky.dev/wdevs/PgTidy">Warky Devs</vendor>
<description><![CDATA[
PostgreSQL formatter and linter powered by <a href="https://github.com/hein/pgtidy">pgtidy</a>.<br/>
Requires the <code>pgtidy</code> binary on PATH and the
<a href="https://plugins.jetbrains.com/plugin/23257-lsp4ij">LSP4IJ</a> plugin.
Requires the <code>pgtidy</code> binary on PATH.
]]></description>
<depends>com.intellij.modules.platform</depends>
<depends>com.intellij.database</depends>
<depends>com.redhat.devtools.lsp4ij</depends>
<actions>
<action id="com.pgtidy.FormatDocument"
@@ -21,17 +19,19 @@
<add-to-group group-id="EditorPopupMenu" anchor="first"/>
<keyboard-shortcut keymap="$default" first-keystroke="ctrl alt shift P"/>
</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>
<extensions defaultExtensionNs="com.redhat.devtools.lsp4ij">
<server id="com.pgtidy.lsp"
name="PgTidy"
factoryClass="com.pgtidy.datagrip.PgTidyServerFactory">
<description>PostgreSQL formatter and linter (pgtidy lsp)</description>
</server>
<fileNamePatternMapping patterns="*.sql;*.pgsql"
serverId="com.pgtidy.lsp"
languageId="SQL"/>
<extensions defaultExtensionNs="com.intellij">
<statusBarWidgetFactory id="com.pgtidy.StatusBarWidget"
implementation="com.pgtidy.datagrip.PgTidyStatusBarWidgetFactory"
order="last"/>
<externalAnnotator language="" implementationClass="com.pgtidy.datagrip.PgTidyExternalAnnotator"/>
</extensions>
</idea-plugin>
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=pgtidy-bin
pkgver=0.0.1
pkgver=0.0.7
pkgrel=1
pkgdesc="PostgreSQL SQL formatter and linter"
arch=('x86_64' 'aarch64')
+9 -18
View File
@@ -1,13 +1,13 @@
Name: pgtidy
Version: 0.0.1
Version: 0.0.7
Release: 1%{?dist}
Summary: PostgreSQL SQL formatter and linter
License: MIT
URL: https://git.warky.dev/wdevs/pgtidy
Source0: %{name}-%{version}.tar.gz
BuildRequires: golang >= 1.26
Source0: pgtidy
Source1: LICENSE
ExclusiveArch: x86_64
%global debug_package %{nil}
%define _debugsource_packages 0
@@ -17,24 +17,15 @@ BuildRequires: golang >= 1.26
PgTidy formats and lints PostgreSQL SQL, enforcing a consistent style
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 -Dm755 %{name} %{buildroot}%{_bindir}/%{name}
install -Dm644 LICENSE %{buildroot}%{_licensedir}/%{name}/LICENSE
install -Dm755 %{SOURCE0} %{buildroot}%{_bindir}/%{name}
mkdir -p %{buildroot}%{_datadir}/licenses/%{name}
install -m644 %{SOURCE1} %{buildroot}%{_datadir}/licenses/%{name}/LICENSE
%files
%license LICENSE
%{_bindir}/%{name}
%{_datadir}/licenses/%{name}/LICENSE
%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
+247 -26
View File
@@ -18,7 +18,6 @@ type Case string
const (
CaseUpper Case = "upper"
CaseLower Case = "lower"
// CasePreserve leaves the token text unchanged.
CasePreserve Case = "preserve"
)
@@ -26,28 +25,79 @@ const (
type CommaStyle string
const (
// CommaLeading puts the comma at the start of the continuation line
// (",col"), the house style.
CommaLeading CommaStyle = "leading"
// CommaTrailing puts the comma at the end of the preceding line ("col,").
CommaTrailing CommaStyle = "trailing"
)
// WrapMode controls whether a construct wraps to multiple lines.
type WrapMode string
const (
WrapAlways WrapMode = "always"
WrapWhenLong WrapMode = "when_long"
WrapNever WrapMode = "never"
)
// Placement controls where a token or block is placed relative to surrounding content.
type Placement string
const (
PlacementSameLine Placement = "same_line"
PlacementNewLine Placement = "new_line"
)
// Style is the formatter configuration.
type Style struct {
// Indent is one indentation level (default two spaces).
// --- Core ---
Indent string
// Newline is the line terminator emitted by the formatter.
Newline string
// KeywordCase controls SQL keyword casing (types excluded — see TypeCase).
// --- Casing ---
KeywordCase Case
// IdentCase controls unquoted identifier casing (quoted identifiers are
// never touched).
IdentCase Case
// TypeCase controls built-in type-name casing.
TypeCase Case
// Commas controls list separator placement.
AliasCase Case // token immediately following AS in SELECT/FROM
BuiltinCase Case // built-in function names (COALESCE, MAX, NOW, …)
CustomTypeCase Case // user-defined / domain types not in the built-in set
// --- Query layout ---
Commas CommaStyle
AlignColumns bool // pad SELECT list so values align
AlignLineComments bool // align trailing -- comments in a block
SelectAlignAs bool // pad between expression and AS in SELECT list
SetAlignEqual bool // align = in UPDATE SET list
IndentJoin bool // extra indentation for JOIN … ON lines
JoinIndentSize int // extra indent levels for JOINs (default 1)
WhereWrap WrapMode // always|when_long|never — each AND/OR on its own line
WhereAndOrIndent bool // AND/OR indented one level under WHERE
// --- Subqueries ---
SubqueryOpening Placement // opening ( placement: same_line|new_line
SubqueryContent Placement // content indentation: same_line|new_line
SubqueryClosing Placement // closing ) placement: same_line|new_line
SubquerySpaceBeforeParen bool // space before ( in subqueries
// --- INSERT ---
InsertCollapseValues bool // fold multiple VALUES rows onto fewer lines
// --- Routines ---
AlignParamTypes bool // pad param names so type column aligns
RoutineAsWrap bool // newline before AS $$
// --- PL/pgSQL body ---
PlpgsqlMaxBlankLines int // max consecutive blank lines in body
PlpgsqlDeclareAlignType bool // align type column in DECLARE block
PlpgsqlDeclareAlignEq bool // align := / = in DECLARE block
PlpgsqlIfThenNewline bool // THEN on its own line
PlpgsqlLoopCollapse bool // collapse empty loop bodies to one line
// --- Expressions ---
BinaryOpAlign bool // align =, <>, || etc. vertically in WHERE/expr lists
SpaceAfterCommaInCalls bool // space after , in function calls: func(a, b)
CaseWhenWrap bool // each WHEN … THEN on its own line
CaseEnd Placement // END placement: same_line|new_line
CaseCollapse bool // collapse short CASE to one line
RecordSpaceBeforeParen bool // space before ( in ROW(…) / record constructors
}
// Default returns the house-style configuration.
@@ -55,10 +105,46 @@ func Default() Style {
return Style{
Indent: " ",
Newline: "\n",
KeywordCase: CaseUpper,
IdentCase: CaseLower,
TypeCase: CaseLower,
AliasCase: CaseLower,
BuiltinCase: CaseLower,
CustomTypeCase: CaseLower,
Commas: CommaLeading,
AlignColumns: false,
AlignLineComments: false,
SelectAlignAs: false,
SetAlignEqual: false,
IndentJoin: false,
JoinIndentSize: 1,
WhereWrap: WrapAlways,
WhereAndOrIndent: true,
SubqueryOpening: PlacementSameLine,
SubqueryContent: PlacementNewLine,
SubqueryClosing: PlacementNewLine,
SubquerySpaceBeforeParen: false,
InsertCollapseValues: true,
AlignParamTypes: true,
RoutineAsWrap: true,
PlpgsqlMaxBlankLines: 1,
PlpgsqlDeclareAlignType: false,
PlpgsqlDeclareAlignEq: false,
PlpgsqlIfThenNewline: true,
PlpgsqlLoopCollapse: true,
BinaryOpAlign: false,
SpaceAfterCommaInCalls: false,
CaseWhenWrap: false,
CaseEnd: PlacementNewLine,
CaseCollapse: false,
RecordSpaceBeforeParen: false,
}
}
@@ -67,10 +153,46 @@ func Default() Style {
type yamlFile struct {
Indent *string `yaml:"indent"`
Newline *string `yaml:"newline"`
KeywordCase *string `yaml:"keyword_case"`
IdentCase *string `yaml:"ident_case"`
TypeCase *string `yaml:"type_case"`
AliasCase *string `yaml:"alias_case"`
BuiltinCase *string `yaml:"builtin_case"`
CustomTypeCase *string `yaml:"custom_type_case"`
Commas *string `yaml:"commas"`
AlignColumns *bool `yaml:"align_columns"`
AlignLineComments *bool `yaml:"align_line_comments"`
SelectAlignAs *bool `yaml:"select_align_as"`
SetAlignEqual *bool `yaml:"set_align_equal"`
IndentJoin *bool `yaml:"indent_join"`
JoinIndentSize *int `yaml:"join_indent_size"`
WhereWrap *string `yaml:"where_wrap"`
WhereAndOrIndent *bool `yaml:"where_and_or_indent"`
SubqueryOpening *string `yaml:"subquery_opening"`
SubqueryContent *string `yaml:"subquery_content"`
SubqueryClosing *string `yaml:"subquery_closing"`
SubquerySpaceBeforeParen *bool `yaml:"subquery_space_before_paren"`
InsertCollapseValues *bool `yaml:"insert_collapse_values"`
AlignParamTypes *bool `yaml:"align_param_types"`
RoutineAsWrap *bool `yaml:"routine_as_wrap"`
PlpgsqlMaxBlankLines *int `yaml:"plpgsql_max_blank_lines"`
PlpgsqlDeclareAlignType *bool `yaml:"plpgsql_declare_align_type"`
PlpgsqlDeclareAlignEq *bool `yaml:"plpgsql_declare_align_eq"`
PlpgsqlIfThenNewline *bool `yaml:"plpgsql_if_then_newline"`
PlpgsqlLoopCollapse *bool `yaml:"plpgsql_loop_collapse"`
BinaryOpAlign *bool `yaml:"binary_op_align"`
SpaceAfterCommaInCalls *bool `yaml:"space_after_comma_in_calls"`
CaseWhenWrap *bool `yaml:"case_when_wrap"`
CaseEnd *string `yaml:"case_end"`
CaseCollapse *bool `yaml:"case_collapse"`
RecordSpaceBeforeParen *bool `yaml:"record_space_before_paren"`
}
// Load discovers and parses the nearest .pgtidy.yaml by walking up from
@@ -100,27 +222,26 @@ func Load(startDir string) (Style, error) {
if yf.Newline != nil {
st.Newline = *yf.Newline
}
if yf.KeywordCase != nil {
c := Case(*yf.KeywordCase)
if err := validCase(c); err != nil {
return st, fmt.Errorf("pgtidy: %s: keyword_case: %w", path, err)
if err := loadCase(yf.KeywordCase, &st.KeywordCase, path, "keyword_case"); err != nil {
return st, err
}
st.KeywordCase = c
if err := loadCase(yf.IdentCase, &st.IdentCase, path, "ident_case"); err != nil {
return st, err
}
if yf.IdentCase != nil {
c := Case(*yf.IdentCase)
if err := validCase(c); err != nil {
return st, fmt.Errorf("pgtidy: %s: ident_case: %w", path, err)
if err := loadCase(yf.TypeCase, &st.TypeCase, path, "type_case"); err != nil {
return st, err
}
st.IdentCase = c
if err := loadCase(yf.AliasCase, &st.AliasCase, path, "alias_case"); err != nil {
return st, err
}
if yf.TypeCase != nil {
c := Case(*yf.TypeCase)
if err := validCase(c); err != nil {
return st, fmt.Errorf("pgtidy: %s: type_case: %w", path, err)
if err := loadCase(yf.BuiltinCase, &st.BuiltinCase, path, "builtin_case"); err != nil {
return st, err
}
st.TypeCase = c
if err := loadCase(yf.CustomTypeCase, &st.CustomTypeCase, path, "custom_type_case"); err != nil {
return st, err
}
if yf.Commas != nil {
cs := CommaStyle(*yf.Commas)
if cs != CommaLeading && cs != CommaTrailing {
@@ -129,9 +250,93 @@ func Load(startDir string) (Style, error) {
st.Commas = cs
}
loadBool(yf.AlignColumns, &st.AlignColumns)
loadBool(yf.AlignLineComments, &st.AlignLineComments)
loadBool(yf.SelectAlignAs, &st.SelectAlignAs)
loadBool(yf.SetAlignEqual, &st.SetAlignEqual)
loadBool(yf.IndentJoin, &st.IndentJoin)
if yf.JoinIndentSize != nil {
st.JoinIndentSize = *yf.JoinIndentSize
}
if yf.WhereWrap != nil {
wm := WrapMode(*yf.WhereWrap)
if err := validWrap(wm); err != nil {
return st, fmt.Errorf("pgtidy: %s: where_wrap: %w", path, err)
}
st.WhereWrap = wm
}
loadBool(yf.WhereAndOrIndent, &st.WhereAndOrIndent)
if yf.SubqueryOpening != nil {
pl := Placement(*yf.SubqueryOpening)
if err := validPlacement(pl); err != nil {
return st, fmt.Errorf("pgtidy: %s: subquery_opening: %w", path, err)
}
st.SubqueryOpening = pl
}
if yf.SubqueryContent != nil {
pl := Placement(*yf.SubqueryContent)
if err := validPlacement(pl); err != nil {
return st, fmt.Errorf("pgtidy: %s: subquery_content: %w", path, err)
}
st.SubqueryContent = pl
}
if yf.SubqueryClosing != nil {
pl := Placement(*yf.SubqueryClosing)
if err := validPlacement(pl); err != nil {
return st, fmt.Errorf("pgtidy: %s: subquery_closing: %w", path, err)
}
st.SubqueryClosing = pl
}
loadBool(yf.SubquerySpaceBeforeParen, &st.SubquerySpaceBeforeParen)
loadBool(yf.InsertCollapseValues, &st.InsertCollapseValues)
loadBool(yf.AlignParamTypes, &st.AlignParamTypes)
loadBool(yf.RoutineAsWrap, &st.RoutineAsWrap)
if yf.PlpgsqlMaxBlankLines != nil {
st.PlpgsqlMaxBlankLines = *yf.PlpgsqlMaxBlankLines
}
loadBool(yf.PlpgsqlDeclareAlignType, &st.PlpgsqlDeclareAlignType)
loadBool(yf.PlpgsqlDeclareAlignEq, &st.PlpgsqlDeclareAlignEq)
loadBool(yf.PlpgsqlIfThenNewline, &st.PlpgsqlIfThenNewline)
loadBool(yf.PlpgsqlLoopCollapse, &st.PlpgsqlLoopCollapse)
loadBool(yf.BinaryOpAlign, &st.BinaryOpAlign)
loadBool(yf.SpaceAfterCommaInCalls, &st.SpaceAfterCommaInCalls)
loadBool(yf.CaseWhenWrap, &st.CaseWhenWrap)
if yf.CaseEnd != nil {
pl := Placement(*yf.CaseEnd)
if err := validPlacement(pl); err != nil {
return st, fmt.Errorf("pgtidy: %s: case_end: %w", path, err)
}
st.CaseEnd = pl
}
loadBool(yf.CaseCollapse, &st.CaseCollapse)
loadBool(yf.RecordSpaceBeforeParen, &st.RecordSpaceBeforeParen)
return st, nil
}
func loadCase(src *string, dst *Case, path, key string) error {
if src == nil {
return nil
}
c := Case(*src)
if err := validCase(c); err != nil {
return fmt.Errorf("pgtidy: %s: %s: %w", path, key, err)
}
*dst = c
return nil
}
func loadBool(src *bool, dst *bool) {
if src != nil {
*dst = *src
}
}
// findConfig walks parent directories from startDir looking for .pgtidy.yaml.
// Returns ("", nil) when no file is found before reaching the filesystem root.
func findConfig(startDir string) (string, error) {
@@ -159,3 +364,19 @@ func validCase(c Case) error {
}
return fmt.Errorf("must be \"upper\", \"lower\", or \"preserve\"")
}
func validWrap(w WrapMode) error {
switch w {
case WrapAlways, WrapWhenLong, WrapNever:
return nil
}
return fmt.Errorf("must be \"always\", \"when_long\", or \"never\"")
}
func validPlacement(p Placement) error {
switch p {
case PlacementSameLine, PlacementNewLine:
return nil
}
return fmt.Errorf("must be \"same_line\" or \"new_line\"")
}
+11 -11
View File
@@ -15,27 +15,27 @@ const (
// TextFix is a byte-range replacement that can be applied to the source SQL.
// Replace src[Offset:End] with New. An insertion has Offset == End.
type TextFix struct {
Offset int // byte offset in source (inclusive)
End int // byte offset in source (exclusive)
New string // replacement text
Title string // short description shown in editor UI
Offset int `json:"offset"` // byte offset in source (inclusive)
End int `json:"end"` // byte offset in source (exclusive)
New string `json:"new"` // replacement text
Title string `json:"title"` // short description shown in editor UI
}
// Diagnostic is a single lint finding.
type Diagnostic struct {
// RuleID is the stable identifier for the rule that produced this finding
// (e.g. "MIG001").
RuleID string
RuleID string `json:"ruleId"`
// Severity is the urgency level.
Severity Severity
Severity Severity `json:"severity"`
// Message is a human-readable description of the finding.
Message string
Message string `json:"message"`
// File is the path to the source file, or "" for stdin.
File string
File string `json:"file"`
// Line is the 1-based line number of the finding.
Line int
Line int `json:"line"`
// Col is the 1-based column number of the finding.
Col int
Col int `json:"col"`
// Fix is non-nil when an autofix is available for this diagnostic.
Fix *TextFix
Fix *TextFix `json:"fix,omitempty"`
}
+443 -59
View File
@@ -92,10 +92,11 @@ func formatBodyInner(inner string, st config.Style) string {
var b strings.Builder
// 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++ {
t := sig[i]
for _, tr := range t.Lead {
b.WriteString(tr.Text)
b.WriteString(strings.ReplaceAll(tr.Text, "\r\n", nl))
}
if i == declareIdx {
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
indent := st.Indent
depth := 0
var decls [][]cst.Tok
var cur []cst.Tok
var preComments []string
var preCommentSets [][]string
var curPreComments []string
emit := func() {
collect := func() {
if len(cur) == 0 {
return
}
for _, c := range preComments {
b.WriteString(indent)
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)
decls = append(decls, cur)
preCommentSets = append(preCommentSets, curPreComments)
cur = nil
curPreComments = nil
}
for _, t := range toks {
if len(cur) == 0 {
for _, tr := range t.Lead {
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 {
case lexer.LParen, lexer.LBracket:
depth++
@@ -181,14 +155,195 @@ func formatDeclareVars(b *strings.Builder, toks []cst.Tok, st config.Style) {
depth--
}
}
cur = append(cur, t)
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.
@@ -208,17 +363,23 @@ type bline struct {
// 3. After EXCEPTION the formatter switches to verbatim-indent mode (original
// leading whitespace is preserved) to avoid conflicts between styles that
// 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 {
nl := st.Newline
normalised := strings.ReplaceAll(text, "\r\n", "\n")
rawLines := strings.Split(normalised, "\n")
maxBlanks := st.PlpgsqlMaxBlankLines
if maxBlanks < 0 {
maxBlanks = 0
}
var (
result strings.Builder
stmt []bline
parenDepth int
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
pendingBlanks int
depthInc bool // increment blockDepth after next flush
@@ -228,7 +389,11 @@ func formatBodyStatements(text string, st config.Style) string {
if len(stmt) == 0 {
return
}
for i := 0; i < pendingBlanks; i++ {
blanks := pendingBlanks
if blanks > maxBlanks {
blanks = maxBlanks
}
for i := 0; i < blanks; i++ {
result.WriteString(nl)
}
pendingBlanks = 0
@@ -259,8 +424,6 @@ func formatBodyStatements(text string, st config.Style) string {
}
effectiveDepth = blockDepth
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 {
effectiveDepth = blockDepth - 1
}
@@ -271,13 +434,16 @@ func formatBodyStatements(text string, st config.Style) string {
baseIndent := strings.Repeat(st.Indent, effectiveDepth)
for i, ll := range stmt {
if i == 0 || ll.indent == "" {
result.WriteString(baseIndent)
} else {
result.WriteString(ll.indent)
// plpgsql_if_then_newline: when false, THEN stays on the same line as
// the condition. When true (default) it's already on its own logical line.
stmtLines := stmt
if !st.PlpgsqlIfThenNewline && fw == "if" {
stmtLines = joinThenToCondition(stmt)
}
result.WriteString(ll.text)
formattedLines := formatBodyStmtLines(stmtLines, baseIndent, st)
for _, line := range formattedLines {
result.WriteString(line)
result.WriteString(nl)
}
@@ -301,9 +467,31 @@ func formatBodyStatements(text string, st config.Style) string {
fw := lowerASCII(firstBodyKeyword(stripped))
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]
// 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 {
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})
}
// Scan tokens to track paren depth and detect flush triggers.
var lastD0Kw string
for _, tok := range lexer.Lex(stripped) {
if tok.IsTrivia() || tok.Kind == lexer.EOF {
@@ -327,27 +514,53 @@ func formatBodyStatements(text string, st config.Style) string {
}
case lexer.Semicolon:
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()
}
}
if parenDepth == 0 && tok.Kind == lexer.Ident {
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 {
switch lastD0Kw {
case "then", "loop", "begin":
// ELSIF/ELSEIF headers end with THEN but must NOT increment depth
// (blockDepth is already at the right level for the body).
case "then":
// A THEN ending a CASE…WHEN branch is not a PL/pgSQL block
// 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))
if fw0 != "elsif" && fw0 != "elseif" {
depthInc = true
}
flush()
case "else", "exception":
if caseDepth > 0 {
break
}
flush()
}
}
@@ -357,6 +570,177 @@ func formatBodyStatements(text string, st config.Style) 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
// (lowercased), or "" if the first significant token is not an identifier.
func firstBodyKeyword(s string) string {
+268 -19
View File
@@ -10,18 +10,15 @@ import (
// isDMLStart reports whether toks begins with a DML statement keyword.
func isDMLStart(toks []cst.Tok) bool {
for _, t := range toks {
if t.Tok.Kind == lexer.Ident {
switch lowerASCII(t.Tok.Text) {
if len(toks) == 0 || toks[0].Tok.Kind != lexer.Ident {
return false
}
switch lowerASCII(toks[0].Tok.Text) {
case "select", "insert", "update", "delete", "with":
return true
}
return false
}
return false
}
return false
}
// dmlSeg is one major clause of a DML statement.
type dmlSeg struct {
@@ -179,9 +176,16 @@ func dmlSegText(seg dmlSeg, st config.Style) string {
}
switch kw {
case "select", "set", "returning":
case "select", "returning":
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":
return formatWithBody(kwText, seg.body, st)
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
// at depth-0 commas and formatting the subquery inside each AS (...) block.
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) {
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()
}
@@ -401,11 +507,9 @@ func dmlSplitCommas(toks []cst.Tok) [][]cst.Tok {
return items
}
// dmlColList formats kwText followed by a comma-separated body.
// One item: kept on the same line as the keyword.
// Multiple items: each on its own line with the configured comma style.
func dmlColList(kwText string, items [][]cst.Tok, st config.Style) string {
// Filter out spurious empty items (e.g. trailing comma in source).
// dmlColListSelect formats a SELECT / RETURNING column list with optional
// align_columns and select_align_as settings.
func dmlColListSelect(kwText string, items [][]cst.Tok, st config.Style) string {
var kept [][]cst.Tok
for _, item := range items {
if len(item) > 0 {
@@ -426,14 +530,23 @@ func dmlColList(kwText string, items [][]cst.Tok, st config.Style) string {
return kwText + " " + body
}
// Multiple items: one per line.
first := st.Indent + " " // aligns item text one column past the comma
// Render each item text.
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 + ","
var b strings.Builder
b.WriteString(kwText)
for i, item := range items {
for i, text := range texts {
b.WriteString(nl)
text := dmlInline(item, st)
if i == 0 || st.Commas != config.CommaLeading {
b.WriteString(first)
b.WriteString(text)
@@ -447,3 +560,139 @@ func dmlColList(kwText string, items [][]cst.Tok, st config.Style) 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) {
// Verify the corpus (which contains only CREATE FUNCTION) is not affected
// by the new DML formatting path.
+205 -16
View File
@@ -53,9 +53,12 @@ func (p *printer) writeItem(n cst.Node) {
case *cst.CreateFunction:
p.writeCreateFunction(v)
case *cst.Raw:
if isDMLStart(v.Toks) {
switch {
case isDMLStart(v.Toks):
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))
}
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) {
// Safety: if the header carries comments we cannot confidently relocate,
// 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) {
p.b.WriteString(verbatimSpan(cst.Tokens(cf)))
p.b.WriteString(verbatimSpanFormatBody(cst.Tokens(cf), cf.Body, p.st))
return
}
@@ -80,11 +136,22 @@ func (p *printer) writeCreateFunction(cf *cst.CreateFunction) {
}
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
cont := p.st.Indent
for i, param := range cf.Params {
for i, text := range paramTexts {
param := cf.Params[i]
p.nl()
text := p.inline(param.Toks)
if i == 0 || p.st.Commas != config.CommaLeading {
p.b.WriteString(first)
p.b.WriteString(text)
@@ -96,6 +163,16 @@ func (p *printer) writeCreateFunction(cf *cst.CreateFunction) {
p.b.WriteString(",")
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.b.WriteString(")")
@@ -105,7 +182,12 @@ func (p *printer) writeCreateFunction(cf *cst.CreateFunction) {
p.b.WriteString(p.inline(clause))
}
if cf.As != nil {
// 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}}))
}
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) {
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()
}
@@ -147,7 +234,8 @@ func (p *printer) inline(toks []cst.Tok) string {
func (p *printer) leadingComments(lead cst.Trivia) {
for _, tr := range lead {
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()
}
}
@@ -157,7 +245,7 @@ func (p *printer) trailingComments(lead cst.Trivia) {
cs := commentsOf(lead)
for _, c := range cs {
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 {
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 {
return t.Text // only unquoted words are re-cased
return t.Text
}
low := lowerASCII(t.Text)
switch {
@@ -220,6 +314,10 @@ func caseText(t lexer.Token, st config.Style) string {
return applyCase(t.Text, st.TypeCase)
case isKeyword(low):
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:
return applyCase(t.Text, st.IdentCase)
}
@@ -239,18 +337,25 @@ func applyCase(s string, c config.Case) string {
// --- helpers ---
// headerHasComments reports whether the function header carries comment trivia
// the formatter cannot confidently relocate. The first token's leading trivia
// is excluded: that is the statement's leading comment, which File() emits
// separately. The body token's own text is excluded too (it is emitted
// verbatim), but a comment in front of the body is caught.
// the formatter cannot confidently relocate. Param separator (Sep) comments
// are excluded those are trailing inline comments on param lines that the
// formatter emits explicitly after each param text. The first token's leading
// trivia and the body token are also excluded.
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)
for i, t := range all {
if i == 0 {
if i == 0 || t.Tok.Kind == lexer.Semicolon {
continue
}
if t.Tok.Kind == lexer.Semicolon {
continue
if sepOffsets[t.Tok.Off] {
continue // Sep comments handled separately
}
if hasComment(t) {
return true
@@ -302,6 +407,27 @@ func verbatimSpan(toks []cst.Tok) 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
// (two or more newlines), indicating the author wanted statements separated.
func hasBlankLine(lead cst.Trivia) bool {
@@ -331,3 +457,66 @@ func lowerASCII(s string) string {
}
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 -45
View File
@@ -7,7 +7,6 @@ import (
"testing"
"git.warky.dev/wdevs/pgtidy/pkg/config"
"git.warky.dev/wdevs/pgtidy/pkg/lexer"
"git.warky.dev/wdevs/pgtidy/pkg/parser"
)
@@ -71,6 +70,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) {
dir := filepath.Join("..", "..", "testdata", "corpus")
entries, err := os.ReadDir(dir)
@@ -79,7 +174,7 @@ func TestCorpusIdempotentAndSafe(t *testing.T) {
}
var seen int
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
}
seen++
@@ -103,48 +198,7 @@ func TestCorpusIdempotentAndSafe(t *testing.T) {
t.Logf("formatted %d corpus files (idempotent + semantically equal)", seen)
}
// semanticallyEqual compares the non-trivia token streams of two sources,
// treating unquoted identifiers/keywords case-insensitively and everything
// else (strings, numbers, operators, punctuation) exactly. Dollar-quoted body
// tokens are compared recursively so body whitespace normalization does not
// trigger a false failure.
// semanticallyEqual is a test-local alias for the exported safety check.
func semanticallyEqual(a, b string) bool {
ta := significant(a)
tb := significant(b)
if len(ta) != len(tb) {
return false
}
for i := range ta {
if ta[i].Kind != tb[i].Kind {
return false
}
switch ta[i].Kind {
case lexer.Ident:
if !strings.EqualFold(ta[i].Text, tb[i].Text) {
return false
}
case lexer.DollarString:
_, innerA, _, okA := splitDollarQuote(ta[i].Text)
_, innerB, _, okB := splitDollarQuote(tb[i].Text)
if okA != okB || (okA && !semanticallyEqual(innerA, innerB)) {
return false
}
default:
if ta[i].Text != tb[i].Text {
return false
}
}
}
return true
}
func significant(src string) []lexer.Token {
var out []lexer.Token
for _, t := range lexer.Lex(src) {
if t.Kind == lexer.EOF || t.IsTrivia() {
continue
}
out = append(out, t)
}
return out
return SemanticallyEqual(a, b)
}
+18
View File
@@ -51,5 +51,23 @@ func words(s string) map[string]bool {
return m
}
// builtinFunctions are built-in function names controlled by BuiltinCase.
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] }
+61
View File
@@ -0,0 +1,61 @@
package format
import (
"strings"
"git.warky.dev/wdevs/pgtidy/pkg/lexer"
)
// SemanticallyEqual reports whether a and b have the same non-trivia token
// stream, i.e. formatting may only ever change whitespace/comment trivia and
// layout — it must never add, remove, or alter a token of actual code.
// Unquoted identifiers/keywords compare case-insensitively (casing is a
// style choice); everything else (strings, numbers, operators, punctuation)
// must match exactly. Dollar-quoted body tokens are compared recursively so
// that independent body reformatting doesn't trigger a false failure.
//
// The CLI and LSP must call this before ever writing or emitting formatted
// output: if it returns false, the formatter has a bug and the original
// source must be kept, never the (corrupting) formatted output.
func SemanticallyEqual(a, b string) bool {
ta := significantTokens(a)
tb := significantTokens(b)
if len(ta) != len(tb) {
return false
}
for i := range ta {
if ta[i].Kind != tb[i].Kind {
return false
}
switch ta[i].Kind {
case lexer.Ident:
if !strings.EqualFold(ta[i].Text, tb[i].Text) {
return false
}
case lexer.DollarString:
_, innerA, _, okA := splitDollarQuote(ta[i].Text)
_, innerB, _, okB := splitDollarQuote(tb[i].Text)
if okA != okB || (okA && !SemanticallyEqual(innerA, innerB)) {
return false
}
default:
if ta[i].Text != tb[i].Text {
return false
}
}
}
return true
}
// significantTokens lexes src and returns its tokens excluding EOF and trivia
// (whitespace/comments).
func significantTokens(src string) []lexer.Token {
var out []lexer.Token
for _, t := range lexer.Lex(src) {
if t.Kind == lexer.EOF || t.IsTrivia() {
continue
}
out = append(out, t)
}
return out
}
+14 -7
View File
@@ -89,12 +89,12 @@ func (s *server) handle(raw []byte) bool {
return true
case "textDocument/didOpen":
var p didOpenParams
json.Unmarshal(req.Params, &p)
_ = json.Unmarshal(req.Params, &p)
s.docs[p.TextDocument.URI] = p.TextDocument.Text
s.pushDiagnostics(p.TextDocument.URI, p.TextDocument.Text)
case "textDocument/didChange":
var p didChangeParams
json.Unmarshal(req.Params, &p)
_ = json.Unmarshal(req.Params, &p)
if len(p.ContentChanges) > 0 {
text := p.ContentChanges[len(p.ContentChanges)-1].Text
s.docs[p.TextDocument.URI] = text
@@ -104,7 +104,7 @@ func (s *server) handle(raw []byte) bool {
var p struct {
TextDocument textDocID `json:"textDocument"`
}
json.Unmarshal(req.Params, &p)
_ = json.Unmarshal(req.Params, &p)
delete(s.docs, p.TextDocument.URI)
delete(s.fixes, p.TextDocument.URI)
s.notify("textDocument/publishDiagnostics", publishDiagnosticsParams{
@@ -113,7 +113,7 @@ func (s *server) handle(raw []byte) bool {
})
case "textDocument/formatting":
var p formattingParams
json.Unmarshal(req.Params, &p)
_ = json.Unmarshal(req.Params, &p)
text, ok := s.docs[p.TextDocument.URI]
if !ok {
s.reply(req.ID, []textEdit{})
@@ -124,10 +124,14 @@ func (s *server) handle(raw []byte) bool {
s.reply(req.ID, []textEdit{})
return false
}
if !format.SemanticallyEqual(text, formatted) {
s.reply(req.ID, []textEdit{})
return false
}
s.reply(req.ID, []textEdit{fullReplace(text, formatted)})
case "textDocument/rangeFormatting":
var p rangeFormattingParams
json.Unmarshal(req.Params, &p)
_ = json.Unmarshal(req.Params, &p)
text, ok := s.docs[p.TextDocument.URI]
if !ok {
s.reply(req.ID, []textEdit{})
@@ -136,7 +140,7 @@ func (s *server) handle(raw []byte) bool {
s.reply(req.ID, s.rangeFormat(text, p.Range))
case "textDocument/codeAction":
var p codeActionParams
json.Unmarshal(req.Params, &p)
_ = json.Unmarshal(req.Params, &p)
s.handleCodeAction(req.ID, p)
case "$/cancelRequest": // ignore
default:
@@ -224,6 +228,9 @@ func (s *server) rangeFormat(text string, r lspRange) []textEdit {
if formatted == text {
return nil
}
if !format.SemanticallyEqual(text, formatted) {
return nil
}
// Split both versions into lines, keeping the trailing newline attached to
// each element so that joining them reconstructs the original string.
@@ -360,7 +367,7 @@ func (s *server) send(v interface{}) {
if err != nil {
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
}
+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])
var n int
fmt.Sscanf(lenStr, "%d", &n)
_, _ = fmt.Sscanf(lenStr, "%d", &n)
sep := bytes.Index(data, []byte("\r\n\r\n"))
if sep < 0 || len(data) < sep+4+n {
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]
buf.Next(sep + 4 + n)
var result map[string]interface{}
json.Unmarshal(body, &result)
_ = json.Unmarshal(body, &result)
return result
}
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") {
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
}
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] == '*':
i += 2
for i+1 < n && !(sql[i] == '*' && sql[i+1] == '/') {
for i+1 < n && (sql[i] != '*' || sql[i+1] != '/') {
i++
}
if i+1 < n {
-2
View File
@@ -134,8 +134,6 @@ BEGIN
where u.rid_hub = m_rid_hub;
end if;
select jsonb_build_object('token', m_session ->>'token'
, 'session', m_session ->>'session'
, '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