feat: add LSP server, VSCode + DataGrip extensions, release infra, autofix
CI / Test (push) Failing after 47s
CI / Build snapshot (push) Has been skipped

- pkg/lsp: JSON-RPC 2.0 LSP server (formatting, diagnostics, codeAction quick-fixes)
- cmd/pgtidy: lsp and config subcommands
- pkg/diagnostics: TextFix struct for byte-range autofixes
- pkg/lint: MIG001/MIG003 autofixes, ApplyFixes helper, --fix flag on lint command
- editors/vscode: TypeScript extension with LanguageClient, showVersion/showConfig/formatDocument commands, logo
- editors/datagrip: Gradle JetBrains plugin via LSP4IJ, pluginIcon
- .goreleaser.yaml, .github/workflows: CI + release pipeline
- Makefile: snapshot, release, vscode-compile, vscode-package targets
- go.mod + all imports: module path updated to git.warky.dev/wdevs/pgtidy
- assets: logo files (256px, 128px, 1024px, ico)
This commit is contained in:
2026-06-28 12:48:28 +02:00
parent 7fb76bae3d
commit e88d32f281
49 changed files with 3228 additions and 49 deletions
+62
View File
@@ -0,0 +1,62 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
- name: Format check
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "Files need gofmt:"
echo "$unformatted"
exit 1
fi
build-snapshot:
name: Build snapshot
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --snapshot --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist-snapshot
path: dist/
retention-days: 7
+53
View File
@@ -0,0 +1,53 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- uses: goreleaser/goreleaser-action@v6
with:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
vscode-package:
name: VSCode Extension
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: editors/vscode/package-lock.json
- name: Install and package
working-directory: editors/vscode
run: |
npm ci
npm run compile
npm run package
- uses: actions/upload-artifact@v4
with:
name: pgtidy-vscode
path: editors/vscode/*.vsix
+54
View File
@@ -0,0 +1,54 @@
project_name: pgtidy
before:
hooks:
- go mod tidy
- go vet ./...
builds:
- id: pgtidy
main: ./cmd/pgtidy
binary: pgtidy
ldflags:
- -s -w -X main.version={{ .Version }}
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
archives:
- id: pgtidy
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
files:
- LICENSE
- README.md
checksum:
name_template: "checksums.txt"
changelog:
sort: asc
use: github
filters:
exclude:
- "^docs:"
- "^test:"
- "^chore:"
- "^ci:"
release:
github:
owner: hein
name: pgtidy
draft: true
+18 -1
View File
@@ -2,7 +2,7 @@ APP := pgtidy
CMD := ./cmd/pgtidy CMD := ./cmd/pgtidy
DIST := dist DIST := dist
.PHONY: build test lint vet fmt clean .PHONY: build test lint vet fmt clean release snapshot vscode-compile vscode-package
## build: compile binary for the current platform ## build: compile binary for the current platform
build: build:
@@ -27,3 +27,20 @@ lint: vet
## clean: remove build artifacts ## clean: remove build artifacts
clean: clean:
rm -rf $(DIST) rm -rf $(DIST)
## snapshot: build multi-platform binaries without publishing (requires goreleaser)
snapshot:
goreleaser release --snapshot --clean
## release: tag and publish a GitHub release (requires GITHUB_TOKEN + goreleaser)
release:
goreleaser release --clean
## vscode-compile: compile the VSCode extension TypeScript
vscode-compile:
cp -r assets editors/vscode/assets
cd editors/vscode && pnpm install && pnpm run build
## vscode-package: build the .vsix package
vscode-package: vscode-compile
cd editors/vscode && pnpm run package
Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+34
View File
@@ -0,0 +1,34 @@
package main
import (
"fmt"
"io"
"os"
"git.warky.dev/wdevs/pgtidy/pkg/config"
)
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")
return 0
}
}
wd, err := os.Getwd()
if err != nil {
wd = "."
}
st, err := config.Load(wd)
if err != nil {
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)
return 0
}
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"io" "io"
"os" "os"
"github.com/hein/pgtidy/pkg/config" "git.warky.dev/wdevs/pgtidy/pkg/config"
"github.com/hein/pgtidy/pkg/format" "git.warky.dev/wdevs/pgtidy/pkg/format"
"github.com/hein/pgtidy/pkg/parser" "git.warky.dev/wdevs/pgtidy/pkg/parser"
) )
// cmdFmt implements `pgtidy fmt`. It follows the gofmt model: with no flags it // cmdFmt implements `pgtidy fmt`. It follows the gofmt model: with no flags it
+28 -2
View File
@@ -6,8 +6,8 @@ import (
"os" "os"
"strings" "strings"
"github.com/hein/pgtidy/pkg/diagnostics" "git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
"github.com/hein/pgtidy/pkg/lint" "git.warky.dev/wdevs/pgtidy/pkg/lint"
) )
// cmdLint implements `pgtidy lint`. Reads one or more SQL files (or stdin) and // cmdLint implements `pgtidy lint`. Reads one or more SQL files (or stdin) and
@@ -19,6 +19,7 @@ import (
func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int { func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
var ( var (
only []string // --only=ID,ID rule filter only []string // --only=ID,ID rule filter
fix bool
files []string files []string
) )
for _, a := range args { for _, a := range args {
@@ -26,6 +27,8 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
case a == "-h" || a == "--help": case a == "-h" || a == "--help":
lintUsage(stdout) lintUsage(stdout)
return 0 return 0
case a == "--fix":
fix = true
case strings.HasPrefix(a, "--only="): case strings.HasPrefix(a, "--only="):
ids := strings.Split(strings.TrimPrefix(a, "--only="), ",") ids := strings.Split(strings.TrimPrefix(a, "--only="), ",")
for _, id := range ids { for _, id := range ids {
@@ -89,6 +92,13 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "pgtidy: %v\n", err) fmt.Fprintf(stderr, "pgtidy: %v\n", err)
return 2 return 2
} }
if fix {
fixed := lint.ApplyFixes(string(src), diags)
if fixed != string(src) {
io.WriteString(stdout, fixed)
return 0
}
}
printDiags(diags) printDiags(diags)
if len(diags) > 0 { if len(diags) > 0 {
found = true found = true
@@ -105,6 +115,21 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "pgtidy: %v\n", err) fmt.Fprintf(stderr, "pgtidy: %v\n", err)
return 2 return 2
} }
if fix {
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)
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)
return 2
}
}
}
printDiags(diags) printDiags(diags)
if len(diags) > 0 { if len(diags) > 0 {
found = true found = true
@@ -124,6 +149,7 @@ func lintUsage(w io.Writer) {
Read SQL from files (or stdin) and report lint findings. Read SQL from files (or stdin) and report lint findings.
Flags: Flags:
--fix Apply autofixes for fixable rules (MIG001, MIG003) and rewrite files
--only=ID,... comma-separated rule IDs to enable (default: all rules) --only=ID,... comma-separated rule IDs to enable (default: all rules)
-h, --help show this help -h, --help show this help
+28
View File
@@ -0,0 +1,28 @@
package main
import (
"context"
"fmt"
"io"
"os"
"git.warky.dev/wdevs/pgtidy/pkg/lsp"
)
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)")
return 0
}
}
wd, err := os.Getwd()
if err != nil {
wd = "."
}
if err := lsp.Serve(context.Background(), stdin, stdout, wd); err != nil {
fmt.Fprintf(stderr, "pgtidy lsp: %v\n", err)
return 1
}
return 0
}
+10 -4
View File
@@ -24,6 +24,10 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return cmdFmt(args[1:], stdin, stdout, stderr) return cmdFmt(args[1:], stdin, stdout, stderr)
case "lint": case "lint":
return cmdLint(args[1:], stdin, stdout, stderr) return cmdLint(args[1:], stdin, stdout, stderr)
case "lsp":
return cmdLsp(args[1:], stdin, stdout, stderr)
case "config":
return cmdConfig(args[1:], stdin, stdout, stderr)
case "version", "--version", "-v": case "version", "--version", "-v":
fmt.Fprintf(stdout, "pgtidy %s\n", version) fmt.Fprintf(stdout, "pgtidy %s\n", version)
return 0 return 0
@@ -41,10 +45,12 @@ func usage(w io.Writer) {
fmt.Fprint(w, `pgtidy — PostgreSQL formatter and linter fmt.Fprint(w, `pgtidy — PostgreSQL formatter and linter
Usage: Usage:
pgtidy fmt [flags] [files...] Format SQL/PL-pgSQL (stdin if no files) pgtidy fmt [flags] [files...] Format SQL/PL-pgSQL (stdin if no files)
pgtidy lint [flags] [files...] Lint SQL (stdin if no files) pgtidy lint [flags] [files...] Lint SQL (stdin if no files)
pgtidy version Print version pgtidy config Print effective configuration
pgtidy help Show this help pgtidy lsp Start LSP server (stdio, for editors)
pgtidy version Print version
pgtidy help Show this help
fmt flags: fmt flags:
-w, --write Rewrite files in place -w, --write Rewrite files in place
+23 -11
View File
@@ -9,7 +9,7 @@ Legend: ✅ done · 🚧 in progress · ⬜ not started
## V1 — Formatter + CLI (current milestone) ## V1 — Formatter + CLI (current milestone)
### ✅ Scaffold module + repo hygiene ### ✅ Scaffold module + repo hygiene
- `go.mod` (`module github.com/hein/pgtidy`, go 1.26). - `go.mod` (`module git.warky.dev/wdevs/pgtidy`, go 1.26).
- Replaced WkMailSync boilerplate: `AGENTS.md` (PgTidy architecture + invariants), - Replaced WkMailSync boilerplate: `AGENTS.md` (PgTidy architecture + invariants),
`CLAUDE.md`, `Makefile` (`build`/`test`/`vet`/`fmt`/`lint`/`clean`). `CLAUDE.md`, `Makefile` (`build`/`test`/`vet`/`fmt`/`lint`/`clean`).
- Directory layout created (`cmd/`, `pkg/...`, `testdata/`). - Directory layout created (`cmd/`, `pkg/...`, `testdata/`).
@@ -108,21 +108,33 @@ Legend: ✅ done · 🚧 in progress · ⬜ not started
- NAM001/2/3 table/column/function names not snake_case (quoted identifiers only) - NAM001/2/3 table/column/function names not snake_case (quoted identifiers only)
- `pgtidy lint [--only=ID,...] [files...]`; exits 1 on findings, 2 on error. - `pgtidy lint [--only=ID,...] [files...]`; exits 1 on findings, 2 on error.
- Fixture SQL in `testdata/lint/`; 6 tests covering violations + clean fixtures. - Fixture SQL in `testdata/lint/`; 6 tests covering violations + clean fixtures.
- _`--fix` for autofixable rules: future._ - `--fix` rewrites files in place applying autofixes; for stdin, prints fixed SQL to stdout.
- Autofixable: **MIG001** (insert `CONCURRENTLY` after `INDEX`) and **MIG003** (insert `NOT VALID` before `;`). MIG002, COR*, NAM* are intentionally not autofixable.
- `pkg/diagnostics.TextFix{Offset, End, New, Title}` — byte-range replacement attached to `Diagnostic.Fix`.
- `pkg/lint.ApplyFixes` — applies all fixes in reverse-offset order; overlapping fixes skipped.
- Fix helpers (`mig001Fix`, `mig003Fix`) handle pg_query's convention of `StmtLen` excluding the trailing `;`.
## V3 — LSP + VSCode (later) ## V3 — LSP + VSCode
- `pkg/lsp`: formatting + range formatting, publishDiagnostics, codeAction quick-fixes. - `pkg/lsp`: JSON-RPC 2.0 over stdio; `textDocument/formatting` (full document), `publishDiagnostics` on every open/change, `textDocument/codeAction` quick-fixes, lifecycle (initialize/shutdown/exit). No external deps.
- `editors/vscode`: TS extension (`vscode-languageclient`) launching bundled `pgtidy lsp`; - `cmd/pgtidy/lsp.go`: `pgtidy lsp` subcommand; config discovered from cwd.
per-platform VSIX matrix in CI (rust-analyzer model) + target-less fallback. - `editors/vscode/`: TS extension using `vscode-languageclient`; launches `pgtidy lsp` via stdio; `.pgsql` mapped to `sql` language; `pgtidy.path` / `pgtidy.enable` settings.
- _Range formatting: future._
## V4 — DataGrip (later) ## V4 — DataGrip
- `editors/datagrip`: integrate via free **LSP4IJ** plugin. - `editors/datagrip/`: Gradle-based JetBrains plugin targeting DataGrip 2024.3+ via LSP4IJ.
- `build.gradle.kts` / `settings.gradle.kts` / `gradle.properties` — IntelliJ Platform Gradle Plugin v2.
- `plugin.xml` — registers `PgTidyServerFactory` as an LSP4IJ `<server>` extension and maps `*.sql`/`*.pgsql` to it.
- `PgTidyServerFactory.kt` + `PgTidyServerConnection.kt` — launches `pgtidy lsp` via `ProcessStreamConnectionProvider`.
- Requires LSP4IJ plugin installed in the IDE; `pgtidy` binary on PATH.
--- ---
## Build / release (cross-cutting) ## Build / release (cross-cutting)
- ⬜ Add goreleaser for the multi-platform binary matrix (clean: no cgo). - `.goreleaser.yaml`: multi-platform matrix — linux/darwin × amd64/arm64 + windows/amd64; no CGO; ldflags version injection; draft GitHub release.
- `make_release.sh` retained from boilerplate (generic version tagging). - `Makefile` extended: `snapshot` (local multi-platform build), `release` (publish), `vscode-compile`, `vscode-package`.
- `.github/workflows/ci.yml`: test + vet + gofmt check + goreleaser snapshot on every push/PR.
- `.github/workflows/release.yml`: goreleaser publish + VSCode `.vsix` artifact on `v*` tag.
- `make_release.sh` retained from boilerplate.
## Core invariants (must always hold — tested) ## Core invariants (must always hold — tested)
1. ✅ Lossless lex: `emit(Lex(src)) == src` (corpus round-trip). 1. ✅ Lossless lex: `emit(Lex(src)) == src` (corpus round-trip).
+3
View File
@@ -0,0 +1,3 @@
.gradle/
build/
*.zip
+37
View File
@@ -0,0 +1,37 @@
# PgTidy — DataGrip / JetBrains Plugin
Formats and lints SQL files in DataGrip (and any JetBrains IDE) via the LSP4IJ plugin.
## 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)
- **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`.
+48
View File
@@ -0,0 +1,48 @@
plugins {
id("org.jetbrains.intellij.platform") version "2.3.0"
kotlin("jvm") version "2.0.0"
}
group = providers.gradleProperty("pluginGroup").get()
version = providers.gradleProperty("pluginVersion").get()
kotlin {
jvmToolchain(17)
}
repositories {
mavenCentral()
intellijPlatform {
defaultRepositories()
}
}
dependencies {
intellijPlatform {
val platformVersion = providers.gradleProperty("platformVersion")
val platformType = providers.gradleProperty("platformType")
create(platformType, platformVersion)
bundledPlugin("com.intellij.database")
plugin("com.redhat.devtools.lsp4ij:${providers.gradleProperty("lsp4ijVersion").get()}")
instrumentationTools()
}
}
intellijPlatform {
pluginConfiguration {
name = providers.gradleProperty("pluginName")
version = providers.gradleProperty("pluginVersion")
ideaVersion {
sinceBuild = providers.gradleProperty("pluginSinceBuild")
}
}
signing {
// Set CERTIFICATE_CHAIN, PRIVATE_KEY, PRIVATE_KEY_PASSWORD env vars for release signing.
certificateChain = providers.environmentVariable("CERTIFICATE_CHAIN")
privateKey = providers.environmentVariable("PRIVATE_KEY")
privateKeyPassword = providers.environmentVariable("PRIVATE_KEY_PASSWORD")
}
publishing {
token = providers.environmentVariable("PUBLISH_TOKEN")
}
}
+8
View File
@@ -0,0 +1,8 @@
pluginGroup=com.pgtidy
pluginName=PgTidy
pluginVersion=0.1.0
pluginSinceBuild=243
# DataGrip 2024.3
platformVersion=2024.3
platformType=DB
lsp4ijVersion=0.8.0
+5
View File
@@ -0,0 +1,5 @@
rootProject.name = "pgtidy-datagrip"
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
}
@@ -0,0 +1,9 @@
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"),
)
@@ -0,0 +1,10 @@
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,27 @@
<idea-plugin>
<id>com.pgtidy.datagrip</id>
<name>PgTidy</name>
<version>0.1.0</version>
<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.
]]></description>
<depends>com.intellij.modules.platform</depends>
<depends>com.intellij.database</depends>
<depends>com.redhat.devtools.lsp4ij</depends>
<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>
</idea-plugin>
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+4
View File
@@ -0,0 +1,4 @@
out/
node_modules/
*.vsix
assets/
+10
View File
@@ -0,0 +1,10 @@
.vscode/**
src/**
node_modules/**
.gitignore
.gitattributes
tsconfig.json
pnpm-lock.yaml
pnpm-workspace.yaml
**/*.ts
**/*.map
+1
View File
@@ -0,0 +1 @@
../../LICENSE
+75
View File
@@ -0,0 +1,75 @@
# PgTidy — VSCode Extension
Formats and lints PostgreSQL/PL-pgSQL files using the `pgtidy` language server.
## Requirements
- `pgtidy` binary on `PATH` (see [Build the binary](#build-the-binary) below)
- Go 1.21+ (to build from source)
- VSCode 1.85+
## Build the binary
**Option A — install directly with Go (recommended):**
```sh
go install git.warky.dev/wdevs/pgtidy/cmd/pgtidy@latest
```
The binary lands in `$(go env GOPATH)/bin/`. Make sure that directory is on your `PATH`.
**Option B — build from source and place manually:**
```sh
git clone https://git.warky.dev/wdevs/PgTidy
cd PgTidy
go build -o pgtidy ./cmd/pgtidy
# Linux / macOS — move to a directory on PATH
mv pgtidy /usr/local/bin/
# Windows — move to a directory on PATH, e.g.
# move pgtidy.exe C:\tools\
```
Verify the binary is reachable:
```sh
pgtidy version
```
If you place it somewhere not on `PATH`, set the full path in VSCode settings:
```json
"pgtidy.path": "/path/to/pgtidy"
```
## Install from `.vsix`
1. Build the package: `pnpm run build && pnpm run package`
2. In VSCode: **Extensions**`···` menu → **Install from VSIX…** → select `pgtidy-*.vsix`
Or via CLI:
```sh
code --install-extension pgtidy-0.1.0.vsix
```
## Settings
| Setting | Default | Description |
|---|---|---|
| `pgtidy.path` | `"pgtidy"` | Path to the binary if not on `PATH` |
| `pgtidy.enable` | `true` | Enable/disable the language server |
## Features
- **Format on save** — configure via VSCode's `editor.formatOnSave`
- **Diagnostics** — lint findings shown inline as you type (MIG001MIG003, COR001003, NAM001003)
- **Quick fixes** — lightbulb actions for MIG001 (add `CONCURRENTLY`) and MIG003 (add `NOT VALID`)
`.sql` and `.pgsql` files are both handled.
## Troubleshooting
**No formatting / diagnostics:** Open the Output panel → select **PgTidy** to see server errors. Most common cause: `pgtidy` not found — set `pgtidy.path` to the full binary path.
+88
View File
@@ -0,0 +1,88 @@
{
"name": "pgtidy",
"displayName": "PgTidy",
"description": "PostgreSQL formatter and linter powered by pgtidy",
"version": "0.1.0",
"publisher": "warky-devs",
"author": {
"name": "Hein Puth",
"email": "hein.puth@gmail.com"
},
"icon": "assets/logo_256.png",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://git.warky.dev/wdevs/PgTidy"
},
"engines": {
"vscode": "^1.85.0"
},
"categories": [
"Formatters",
"Linters"
],
"activationEvents": [
"onCommand:pgtidy.showVersion",
"onCommand:pgtidy.showConfig",
"onCommand:pgtidy.formatDocument"
],
"main": "./out/extension",
"contributes": {
"commands": [
{
"command": "pgtidy.showVersion",
"title": "Show Version",
"category": "PgTidy"
},
{
"command": "pgtidy.showConfig",
"title": "Show Config",
"category": "PgTidy"
},
{
"command": "pgtidy.formatDocument",
"title": "Format Document",
"category": "PgTidy"
}
],
"languages": [
{
"id": "sql",
"extensions": [
".pgsql"
]
}
],
"configuration": {
"title": "PgTidy",
"properties": {
"pgtidy.path": {
"type": "string",
"default": "pgtidy",
"description": "Path to the pgtidy binary. Defaults to 'pgtidy' (must be on PATH)."
},
"pgtidy.enable": {
"type": "boolean",
"default": true,
"description": "Enable the PgTidy language server."
}
}
}
},
"scripts": {
"build": "tsc -p ./",
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",
"package": "vsce package --no-dependencies",
"lint": "eslint src --ext ts"
},
"dependencies": {
"vscode-languageclient": "^9.0.1"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/vscode": "^1.85.0",
"@vscode/vsce": "^2.24.0",
"typescript": "^5.3.0"
}
}
+1589
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
'@vscode/vsce-sign': true
keytar: true
+105
View File
@@ -0,0 +1,105 @@
import * as cp from 'child_process';
import * as path from 'path';
import * as vscode from 'vscode';
import {
LanguageClient,
LanguageClientOptions,
ServerOptions,
TransportKind,
} from 'vscode-languageclient/node';
let client: LanguageClient | undefined;
let outputChannel: vscode.OutputChannel;
export function activate(context: vscode.ExtensionContext): void {
outputChannel = vscode.window.createOutputChannel('PgTidy');
context.subscriptions.push(outputChannel);
// Commands are always registered so they work even when the LSP is disabled.
context.subscriptions.push(
vscode.commands.registerCommand('pgtidy.showVersion', cmdShowVersion),
vscode.commands.registerCommand('pgtidy.showConfig', cmdShowConfig),
vscode.commands.registerCommand('pgtidy.formatDocument', cmdFormatDocument),
);
const cfg = vscode.workspace.getConfiguration('pgtidy');
if (!cfg.get<boolean>('enable', true)) {
return;
}
const bin = cfg.get<string>('path', 'pgtidy');
const serverOptions: ServerOptions = {
command: bin,
args: ['lsp'],
transport: TransportKind.stdio,
};
const clientOptions: LanguageClientOptions = {
documentSelector: [{ scheme: 'file', language: 'sql' }],
synchronize: {
fileEvents: vscode.workspace.createFileSystemWatcher('**/*.{sql,pgsql}'),
},
};
client = new LanguageClient('pgtidy', 'PgTidy', serverOptions, clientOptions);
context.subscriptions.push(client);
client.start();
}
export function deactivate(): Thenable<void> | undefined {
return client?.stop();
}
function binary(): string {
return vscode.workspace.getConfiguration('pgtidy').get<string>('path', 'pgtidy');
}
function contextDir(): string | undefined {
const folders = vscode.workspace.workspaceFolders;
if (folders && folders.length > 0) {
return folders[0].uri.fsPath;
}
const doc = vscode.window.activeTextEditor?.document;
if (doc && !doc.isUntitled) {
return path.dirname(doc.uri.fsPath);
}
return undefined;
}
function cmdShowVersion(): void {
cp.execFile(binary(), ['version'], (err, stdout, stderr) => {
if (err) {
vscode.window.showErrorMessage(`PgTidy: ${stderr.trim() || err.message}`);
return;
}
vscode.window.showInformationMessage(stdout.trim());
});
}
function cmdShowConfig(): void {
const cwd = contextDir();
cp.execFile(binary(), ['config'], { cwd }, (err, stdout, stderr) => {
if (err) {
vscode.window.showErrorMessage(`PgTidy: ${stderr.trim() || err.message}`);
return;
}
outputChannel.clear();
outputChannel.appendLine('PgTidy — effective configuration');
if (cwd) {
outputChannel.appendLine(`(resolved from: ${cwd})`);
}
outputChannel.appendLine('');
outputChannel.append(stdout);
outputChannel.show(true);
});
}
async function cmdFormatDocument(): Promise<void> {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showWarningMessage('PgTidy: no active editor');
return;
}
await vscode.commands.executeCommand('editor.action.formatDocument');
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2020",
"outDir": "out",
"lib": ["ES2020"],
"sourceMap": true,
"rootDir": "src",
"strict": true,
"skipLibCheck": true
},
"exclude": ["node_modules", ".vscode-test"]
}
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/hein/pgtidy module git.warky.dev/wdevs/pgtidy
go 1.26 go 1.26
+1 -1
View File
@@ -11,7 +11,7 @@ package cst
import ( import (
"strings" "strings"
"github.com/hein/pgtidy/pkg/lexer" "git.warky.dev/wdevs/pgtidy/pkg/lexer"
) )
// Trivia is a run of whitespace/comment tokens preceding a significant token. // Trivia is a run of whitespace/comment tokens preceding a significant token.
+11
View File
@@ -12,6 +12,15 @@ const (
SeverityHint Severity = "hint" SeverityHint Severity = "hint"
) )
// 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
}
// Diagnostic is a single lint finding. // Diagnostic is a single lint finding.
type Diagnostic struct { type Diagnostic struct {
// RuleID is the stable identifier for the rule that produced this finding // RuleID is the stable identifier for the rule that produced this finding
@@ -27,4 +36,6 @@ type Diagnostic struct {
Line int Line int
// Col is the 1-based column number of the finding. // Col is the 1-based column number of the finding.
Col int Col int
// Fix is non-nil when an autofix is available for this diagnostic.
Fix *TextFix
} }
+3 -3
View File
@@ -3,9 +3,9 @@ package format
import ( import (
"strings" "strings"
"github.com/hein/pgtidy/pkg/config" "git.warky.dev/wdevs/pgtidy/pkg/config"
"github.com/hein/pgtidy/pkg/cst" "git.warky.dev/wdevs/pgtidy/pkg/cst"
"github.com/hein/pgtidy/pkg/lexer" "git.warky.dev/wdevs/pgtidy/pkg/lexer"
) )
// sqlClauseKw: col-0 lines at paren-depth 0 starting with these keywords stay // sqlClauseKw: col-0 lines at paren-depth 0 starting with these keywords stay
+3 -3
View File
@@ -14,9 +14,9 @@ package format
import ( import (
"strings" "strings"
"github.com/hein/pgtidy/pkg/config" "git.warky.dev/wdevs/pgtidy/pkg/config"
"github.com/hein/pgtidy/pkg/cst" "git.warky.dev/wdevs/pgtidy/pkg/cst"
"github.com/hein/pgtidy/pkg/lexer" "git.warky.dev/wdevs/pgtidy/pkg/lexer"
) )
// File formats a parsed file with the given style. // File formats a parsed file with the given style.
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/hein/pgtidy/pkg/config" "git.warky.dev/wdevs/pgtidy/pkg/config"
"github.com/hein/pgtidy/pkg/lexer" "git.warky.dev/wdevs/pgtidy/pkg/lexer"
"github.com/hein/pgtidy/pkg/parser" "git.warky.dev/wdevs/pgtidy/pkg/parser"
) )
func format(src string) string { func format(src string) string {
+39
View File
@@ -0,0 +1,39 @@
package lint
import (
"sort"
"git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
)
// ApplyFixes applies all autofixes from diags to src and returns the result.
// Fixes are applied in reverse-offset order so earlier edits do not shift the
// byte positions of later ones. Overlapping fixes are skipped.
func ApplyFixes(src string, diags []diagnostics.Diagnostic) string {
type fix struct {
offset, end int
new string
}
var fixes []fix
for _, d := range diags {
if d.Fix != nil {
fixes = append(fixes, fix{d.Fix.Offset, d.Fix.End, d.Fix.New})
}
}
if len(fixes) == 0 {
return src
}
sort.Slice(fixes, func(i, j int) bool {
return fixes[i].offset > fixes[j].offset
})
b := []byte(src)
last := len(b) + 1 // sentinel: no fix applied yet
for _, f := range fixes {
if f.end > last {
continue // overlaps a previously applied fix; skip
}
last = f.offset
b = append(b[:f.offset], append([]byte(f.new), b[f.end:]...)...)
}
return string(b)
}
+104
View File
@@ -0,0 +1,104 @@
package lint_test
import (
"strings"
"testing"
"git.warky.dev/wdevs/pgtidy/pkg/lint"
)
func TestMIG001Fix(t *testing.T) {
src := "CREATE INDEX idx_orders_user ON orders(user_id);"
eng := lint.New()
diags, _ := eng.Check(src, "test.sql")
var hasFix bool
for _, d := range diags {
if d.RuleID == "MIG001" && d.Fix != nil {
hasFix = true
}
}
if !hasFix {
t.Fatal("MIG001 diagnostic missing Fix")
}
fixed := lint.ApplyFixes(src, diags)
if !strings.Contains(fixed, "CONCURRENTLY") {
t.Errorf("fix did not insert CONCURRENTLY; got: %s", fixed)
}
// Re-check: MIG001 should be gone
diags2, _ := eng.Check(fixed, "test.sql")
for _, d := range diags2 {
if d.RuleID == "MIG001" {
t.Errorf("MIG001 still fires after fix: %s", fixed)
}
}
}
func TestMIG003Fix(t *testing.T) {
cases := []struct {
name string
src string
}{
{
"FK constraint",
"ALTER TABLE orders ADD CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id);",
},
{
"CHECK constraint",
"ALTER TABLE orders ADD CONSTRAINT chk_positive CHECK (amount > 0);",
},
}
eng := lint.New()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
diags, _ := eng.Check(tc.src, "test.sql")
var hasFix bool
for _, d := range diags {
if d.RuleID == "MIG003" && d.Fix != nil {
hasFix = true
}
}
if !hasFix {
t.Fatal("MIG003 diagnostic missing Fix")
}
fixed := lint.ApplyFixes(tc.src, diags)
if !strings.Contains(fixed, "NOT VALID") {
t.Errorf("fix did not insert NOT VALID; got: %s", fixed)
}
// Re-check: MIG003 should be gone
diags2, _ := eng.Check(fixed, "test.sql")
for _, d := range diags2 {
if d.RuleID == "MIG003" {
t.Errorf("MIG003 still fires after fix: %s", fixed)
}
}
})
}
}
func TestApplyFixes_MultipleInOneFile(t *testing.T) {
src := `CREATE INDEX a ON t(x);
ALTER TABLE t ADD CONSTRAINT fk FOREIGN KEY (x) REFERENCES u(id);`
eng := lint.New()
diags, _ := eng.Check(src, "test.sql")
fixed := lint.ApplyFixes(src, diags)
if !strings.Contains(fixed, "CONCURRENTLY") {
t.Error("CONCURRENTLY missing after multi-fix")
}
if !strings.Contains(fixed, "NOT VALID") {
t.Error("NOT VALID missing after multi-fix")
}
}
func TestApplyFixes_NoFixes(t *testing.T) {
src := "SELECT 1;"
eng := lint.New()
diags, _ := eng.Check(src, "test.sql")
fixed := lint.ApplyFixes(src, diags)
if fixed != src {
t.Errorf("ApplyFixes changed unfixable source: %q", fixed)
}
}
+2 -2
View File
@@ -11,8 +11,8 @@ import (
pg_query "github.com/pganalyze/pg_query_go/v6" pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/hein/pgtidy/pkg/diagnostics" "git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
"github.com/hein/pgtidy/pkg/pgast" "git.warky.dev/wdevs/pgtidy/pkg/pgast"
) )
// Rule is implemented by each lint rule. // Rule is implemented by each lint rule.
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/hein/pgtidy/pkg/diagnostics" "git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
"github.com/hein/pgtidy/pkg/lint" "git.warky.dev/wdevs/pgtidy/pkg/lint"
) )
func fixtureDir() string { func fixtureDir() string {
+2 -2
View File
@@ -5,8 +5,8 @@ import (
pg_query "github.com/pganalyze/pg_query_go/v6" pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/hein/pgtidy/pkg/diagnostics" "git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
"github.com/hein/pgtidy/pkg/pgast" "git.warky.dev/wdevs/pgtidy/pkg/pgast"
) )
// COR001 — SELECT *. // COR001 — SELECT *.
+75 -6
View File
@@ -2,11 +2,12 @@ package lint
import ( import (
"fmt" "fmt"
"strings"
pg_query "github.com/pganalyze/pg_query_go/v6" pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/hein/pgtidy/pkg/diagnostics" "git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
"github.com/hein/pgtidy/pkg/pgast" "git.warky.dev/wdevs/pgtidy/pkg/pgast"
) )
// MIG001 — CREATE INDEX without CONCURRENT. // MIG001 — CREATE INDEX without CONCURRENT.
@@ -30,13 +31,17 @@ func (ruleMIG001) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Dia
continue continue
} }
line, col := pgast.LocationToLineCol(src, pgast.FirstTokenOffset(src, int(raw.StmtLocation))) line, col := pgast.LocationToLineCol(src, pgast.FirstTokenOffset(src, int(raw.StmtLocation)))
out = append(out, diagnostics.Diagnostic{ d := diagnostics.Diagnostic{
RuleID: "MIG001", RuleID: "MIG001",
Severity: diagnostics.SeverityWarning, Severity: diagnostics.SeverityWarning,
Message: fmt.Sprintf("CREATE INDEX on %q without CONCURRENT blocks writes; use CREATE INDEX CONCURRENTLY", relName(s.Relation)), Message: fmt.Sprintf("CREATE INDEX on %q without CONCURRENT blocks writes; use CREATE INDEX CONCURRENTLY", relName(s.Relation)),
Line: line, Line: line,
Col: col, Col: col,
}) }
if fix := mig001Fix(src, int(raw.StmtLocation), int(raw.StmtLen)); fix != nil {
d.Fix = fix
}
out = append(out, d)
} }
return out return out
} }
@@ -127,13 +132,17 @@ func (ruleMIG003) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Dia
kind = "CHECK" kind = "CHECK"
} }
line, col := pgast.LocationToLineCol(src, pgast.FirstTokenOffset(src, int(raw.StmtLocation))) line, col := pgast.LocationToLineCol(src, pgast.FirstTokenOffset(src, int(raw.StmtLocation)))
out = append(out, diagnostics.Diagnostic{ d := diagnostics.Diagnostic{
RuleID: "MIG003", RuleID: "MIG003",
Severity: diagnostics.SeverityWarning, Severity: diagnostics.SeverityWarning,
Message: fmt.Sprintf("ALTER TABLE %q ADD %s CONSTRAINT without NOT VALID validates all rows immediately; use NOT VALID + VALIDATE CONSTRAINT", relName(tbl.Relation), kind), Message: fmt.Sprintf("ALTER TABLE %q ADD %s CONSTRAINT without NOT VALID validates all rows immediately; use NOT VALID + VALIDATE CONSTRAINT", relName(tbl.Relation), kind),
Line: line, Line: line,
Col: col, Col: col,
}) }
if fix := mig003Fix(src, int(raw.StmtLocation), int(raw.StmtLen)); fix != nil {
d.Fix = fix
}
out = append(out, d)
} }
} }
return out return out
@@ -169,3 +178,63 @@ func relName(rv *pg_query.RangeVar) string {
} }
return rv.Relname return rv.Relname
} }
// stmtText returns the text of a statement given its start offset and length.
// When stmtLen is 0 (last statement in file) it extends to EOF.
func stmtText(src string, stmtOffset, stmtLen int) string {
end := stmtOffset + stmtLen
if stmtLen == 0 || end > len(src) {
end = len(src)
}
return src[stmtOffset:end]
}
// mig001Fix builds the TextFix for MIG001: insert CONCURRENTLY after INDEX.
func mig001Fix(src string, stmtOffset, stmtLen int) *diagnostics.TextFix {
text := stmtText(src, stmtOffset, stmtLen)
upper := strings.ToUpper(text)
pos := strings.Index(upper, "INDEX")
if pos < 0 {
return nil
}
insertAt := stmtOffset + pos + len("INDEX")
return &diagnostics.TextFix{
Offset: insertAt,
End: insertAt,
New: " CONCURRENTLY",
Title: "Add CONCURRENTLY",
}
}
// mig003Fix builds the TextFix for MIG003: insert NOT VALID before the trailing semicolon.
// pg_query's StmtLen excludes the ";", which sits at src[stmtOffset+stmtLen].
func mig003Fix(src string, stmtOffset, stmtLen int) *diagnostics.TextFix {
end := stmtOffset + stmtLen
semiAt := -1
if stmtLen > 0 && end < len(src) && src[end] == ';' {
semiAt = end
} else {
// Fallback for stmtLen=0 (last statement without terminator) or edge cases.
if stmtLen == 0 {
end = len(src)
}
idx := strings.LastIndex(src[stmtOffset:end], ";")
if idx >= 0 {
semiAt = stmtOffset + idx
}
}
if semiAt < 0 {
return nil
}
// Insert " NOT VALID" just before the ";", after any trailing whitespace.
insertAt := semiAt
for insertAt > stmtOffset && (src[insertAt-1] == ' ' || src[insertAt-1] == '\t' || src[insertAt-1] == '\n' || src[insertAt-1] == '\r') {
insertAt--
}
return &diagnostics.TextFix{
Offset: insertAt,
End: insertAt,
New: " NOT VALID",
Title: "Add NOT VALID",
}
}
+2 -2
View File
@@ -7,8 +7,8 @@ import (
pg_query "github.com/pganalyze/pg_query_go/v6" pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/hein/pgtidy/pkg/diagnostics" "git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
"github.com/hein/pgtidy/pkg/pgast" "git.warky.dev/wdevs/pgtidy/pkg/pgast"
) )
// reSnakeCase matches valid snake_case identifiers: lowercase letters, digits, // reSnakeCase matches valid snake_case identifiers: lowercase letters, digits,
+429
View File
@@ -0,0 +1,429 @@
// Package lsp implements a Language Server Protocol server for PgTidy.
//
// The server communicates over stdio using JSON-RPC 2.0 with Content-Length
// framing. It provides:
// - textDocument/formatting — full-document formatting via pkg/format
// - textDocument/publishDiagnostics — lint findings via pkg/lint, sent on
// every didOpen/didChange notification
package lsp
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"git.warky.dev/wdevs/pgtidy/pkg/config"
"git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
"git.warky.dev/wdevs/pgtidy/pkg/format"
"git.warky.dev/wdevs/pgtidy/pkg/lint"
"git.warky.dev/wdevs/pgtidy/pkg/parser"
)
// Serve runs the LSP server, reading JSON-RPC messages from r and writing to w.
// startDir is used for .pgtidy.yaml discovery. Returns when the client sends
// "exit" or r reaches EOF.
func Serve(ctx context.Context, r io.Reader, w io.Writer, startDir string) error {
cfg, _ := config.Load(startDir)
srv := &server{
docs: make(map[string]string),
fixes: make(map[string][]diagnostics.Diagnostic),
cfg: cfg,
w: w,
}
return srv.loop(ctx, r)
}
type server struct {
docs map[string]string // URI → current text
fixes map[string][]diagnostics.Diagnostic // URI → diagnostics that have fixes
cfg config.Style
w io.Writer
}
func (s *server) loop(ctx context.Context, r io.Reader) error {
br := bufio.NewReader(r)
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
raw, err := readMsg(br)
if err != nil {
if err == io.EOF {
return nil
}
return err
}
if s.handle(raw) {
return nil
}
}
}
// handle dispatches one JSON-RPC message. Returns true when the server should exit.
func (s *server) handle(raw []byte) bool {
var req rpcMsg
if err := json.Unmarshal(raw, &req); err != nil {
return false
}
switch req.Method {
case "initialize":
s.reply(req.ID, initResult{
Capabilities: serverCaps{
TextDocumentSync: 1, // full sync
DocumentFormattingProvider: true,
CodeActionProvider: true,
},
})
case "initialized": // no-op notification
case "shutdown":
s.reply(req.ID, nil)
case "exit":
return true
case "textDocument/didOpen":
var p didOpenParams
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)
if len(p.ContentChanges) > 0 {
text := p.ContentChanges[len(p.ContentChanges)-1].Text
s.docs[p.TextDocument.URI] = text
s.pushDiagnostics(p.TextDocument.URI, text)
}
case "textDocument/didClose":
var p struct {
TextDocument textDocID `json:"textDocument"`
}
json.Unmarshal(req.Params, &p)
delete(s.docs, p.TextDocument.URI)
delete(s.fixes, p.TextDocument.URI)
s.notify("textDocument/publishDiagnostics", publishDiagnosticsParams{
URI: p.TextDocument.URI,
Diagnostics: []lspDiagnostic{},
})
case "textDocument/formatting":
var p formattingParams
json.Unmarshal(req.Params, &p)
text, ok := s.docs[p.TextDocument.URI]
if !ok {
s.reply(req.ID, []textEdit{})
return false
}
formatted := format.File(parser.Parse(text), s.cfg)
if formatted == text {
s.reply(req.ID, []textEdit{})
return false
}
s.reply(req.ID, []textEdit{fullReplace(text, formatted)})
case "textDocument/codeAction":
var p codeActionParams
json.Unmarshal(req.Params, &p)
s.handleCodeAction(req.ID, p)
case "$/cancelRequest": // ignore
default:
if req.ID != nil {
s.replyErr(req.ID, -32601, "method not found: "+req.Method)
}
}
return false
}
func (s *server) pushDiagnostics(uri, text string) {
eng := lint.New()
diags, _ := eng.Check(text, uri)
var fixable []diagnostics.Diagnostic
out := make([]lspDiagnostic, 0, len(diags))
for _, d := range diags {
line := uint32(d.Line)
if line > 0 {
line--
}
col := uint32(d.Col)
if col > 0 {
col--
}
lspD := lspDiagnostic{
Range: lspRange{Start: position{line, col}, End: position{line, col + 1}},
Severity: severityCode(d.Severity),
Code: d.RuleID,
Source: "pgtidy",
Message: d.Message,
}
out = append(out, lspD)
if d.Fix != nil {
fixable = append(fixable, d)
}
}
s.fixes[uri] = fixable
s.notify("textDocument/publishDiagnostics", publishDiagnosticsParams{
URI: uri,
Diagnostics: out,
})
}
func (s *server) handleCodeAction(id json.RawMessage, p codeActionParams) {
uri := p.TextDocument.URI
text, ok := s.docs[uri]
if !ok {
s.reply(id, []codeAction{})
return
}
var actions []codeAction
for _, d := range s.fixes[uri] {
if d.Fix == nil {
continue
}
start := offsetToPosition(text, d.Fix.Offset)
end := offsetToPosition(text, d.Fix.End)
// Only include if the fix range overlaps the requested range.
if !rangesOverlap(start, end, p.Range.Start, p.Range.End) {
continue
}
actions = append(actions, codeAction{
Title: d.Fix.Title,
Kind: "quickfix",
Edit: &workspaceEdit{
Changes: map[string][]textEdit{
uri: {{
Range: lspRange{Start: start, End: end},
NewText: d.Fix.New,
}},
},
},
})
}
s.reply(id, actions)
}
// offsetToPosition converts a byte offset in text to an LSP position.
func offsetToPosition(text string, byteOff int) position {
if byteOff > len(text) {
byteOff = len(text)
}
line := uint32(0)
lastNL := -1
for i := 0; i < byteOff; i++ {
if text[i] == '\n' {
line++
lastNL = i
}
}
return position{line, uint32(byteOff - lastNL - 1)}
}
// rangesOverlap returns true when [s1,e1) and [s2,e2) share any point.
// For insertions (s==e) we check if the point falls within the other range.
func rangesOverlap(s1, e1, s2, e2 position) bool {
cmp := func(a, b position) int {
if a.Line != b.Line {
if a.Line < b.Line {
return -1
}
return 1
}
if a.Character < b.Character {
return -1
}
if a.Character > b.Character {
return 1
}
return 0
}
return cmp(s1, e2) <= 0 && cmp(s2, e1) <= 0
}
func severityCode(sev diagnostics.Severity) int {
switch sev {
case diagnostics.SeverityError:
return 1
case diagnostics.SeverityWarning:
return 2
case diagnostics.SeverityHint:
return 4
}
return 3 // info
}
// fullReplace builds a TextEdit that replaces the entire document.
func fullReplace(orig, formatted string) textEdit {
lines := strings.Split(orig, "\n")
lastLine := uint32(len(lines) - 1)
lastChar := uint32(len(lines[lastLine]))
return textEdit{
Range: lspRange{Start: position{0, 0}, End: position{lastLine, lastChar}},
NewText: formatted,
}
}
// --- JSON-RPC 2.0 transport ---
func readMsg(r *bufio.Reader) ([]byte, error) {
length := 0
for {
line, err := r.ReadString('\n')
if err != nil {
return nil, err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
if after, ok := strings.CutPrefix(line, "Content-Length: "); ok {
length, _ = strconv.Atoi(after)
}
}
if length == 0 {
return nil, fmt.Errorf("lsp: missing Content-Length")
}
buf := make([]byte, length)
_, err := io.ReadFull(r, buf)
return buf, err
}
func (s *server) send(v interface{}) {
data, err := json.Marshal(v)
if err != nil {
return
}
fmt.Fprintf(s.w, "Content-Length: %d\r\n\r\n", len(data))
s.w.Write(data) //nolint:errcheck
}
func (s *server) reply(id json.RawMessage, result interface{}) {
s.send(rpcResponse{JSONRPC: "2.0", ID: id, Result: result})
}
func (s *server) replyErr(id json.RawMessage, code int, msg string) {
s.send(rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}})
}
func (s *server) notify(method string, params interface{}) {
s.send(rpcNotification{JSONRPC: "2.0", Method: method, Params: params})
}
// --- JSON-RPC 2.0 wire types ---
type rpcMsg struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result interface{} `json:"result"`
Error *rpcError `json:"error,omitempty"`
}
type rpcNotification struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params interface{} `json:"params"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// --- LSP protocol types (minimal subset) ---
type position struct {
Line uint32 `json:"line"`
Character uint32 `json:"character"`
}
type lspRange struct {
Start position `json:"start"`
End position `json:"end"`
}
type textEdit struct {
Range lspRange `json:"range"`
NewText string `json:"newText"`
}
type initResult struct {
Capabilities serverCaps `json:"capabilities"`
}
type serverCaps struct {
TextDocumentSync int `json:"textDocumentSync"`
DocumentFormattingProvider bool `json:"documentFormattingProvider"`
CodeActionProvider bool `json:"codeActionProvider"`
}
type textDocItem struct {
URI string `json:"uri"`
LanguageID string `json:"languageId"`
Version int `json:"version"`
Text string `json:"text"`
}
type textDocID struct {
URI string `json:"uri"`
}
type contentChange struct {
Text string `json:"text"`
}
type didOpenParams struct {
TextDocument textDocItem `json:"textDocument"`
}
type didChangeParams struct {
TextDocument textDocID `json:"textDocument"`
ContentChanges []contentChange `json:"contentChanges"`
}
type formattingParams struct {
TextDocument textDocID `json:"textDocument"`
Options struct {
TabSize int `json:"tabSize"`
InsertSpaces bool `json:"insertSpaces"`
} `json:"options"`
}
type lspDiagnostic struct {
Range lspRange `json:"range"`
Severity int `json:"severity"`
Code string `json:"code,omitempty"`
Source string `json:"source,omitempty"`
Message string `json:"message"`
}
type publishDiagnosticsParams struct {
URI string `json:"uri"`
Diagnostics []lspDiagnostic `json:"diagnostics"`
}
type codeActionParams struct {
TextDocument textDocID `json:"textDocument"`
Range lspRange `json:"range"`
Context struct {
Diagnostics []lspDiagnostic `json:"diagnostics"`
} `json:"context"`
}
type workspaceEdit struct {
Changes map[string][]textEdit `json:"changes"`
}
type codeAction struct {
Title string `json:"title"`
Kind string `json:"kind,omitempty"`
Edit *workspaceEdit `json:"edit,omitempty"`
}
+200
View File
@@ -0,0 +1,200 @@
package lsp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
)
// rpc sends a JSON-RPC request frame and returns the raw body bytes.
func frame(id int, method string, params interface{}) []byte {
type req struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Method string `json:"method"`
Params interface{} `json:"params"`
}
body, _ := json.Marshal(req{JSONRPC: "2.0", ID: id, Method: method, Params: params})
return []byte(fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(body), body))
}
func notifFrame(method string, params interface{}) []byte {
type notif struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params interface{} `json:"params"`
}
body, _ := json.Marshal(notif{JSONRPC: "2.0", Method: method, Params: params})
return []byte(fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(body), body))
}
// readResp reads one JSON-RPC response from a *bytes.Buffer (blocking until available).
func readResp(t *testing.T, buf *bytes.Buffer) map[string]interface{} {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
data := buf.Bytes()
// Find Content-Length header
idx := bytes.Index(data, []byte("Content-Length: "))
if idx < 0 {
time.Sleep(5 * time.Millisecond)
continue
}
eol := bytes.Index(data[idx:], []byte("\r\n"))
if eol < 0 {
time.Sleep(5 * time.Millisecond)
continue
}
lenStr := string(data[idx+16 : idx+eol])
var n int
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)
continue
}
body := data[sep+4 : sep+4+n]
buf.Next(sep + 4 + n)
var result map[string]interface{}
json.Unmarshal(body, &result)
return result
}
t.Fatal("timeout waiting for response")
return nil
}
func runServer(t *testing.T, input []byte) *bytes.Buffer {
t.Helper()
out := &bytes.Buffer{}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
done := make(chan error, 1)
go func() {
done <- Serve(ctx, bytes.NewReader(input), out, t.TempDir())
cancel()
}()
// Give the server time to process
select {
case <-done:
case <-time.After(3 * time.Second):
}
return out
}
func TestInitialize(t *testing.T) {
input := append(frame(1, "initialize", map[string]interface{}{}),
notifFrame("initialized", map[string]interface{}{})...)
input = append(input, frame(2, "shutdown", nil)...)
input = append(input, notifFrame("exit", nil)...)
out := runServer(t, input)
resp := readResp(t, out)
if resp["id"].(float64) != 1 {
t.Fatalf("expected id=1, got %v", resp["id"])
}
caps := resp["result"].(map[string]interface{})["capabilities"].(map[string]interface{})
if caps["documentFormattingProvider"] != true {
t.Error("expected documentFormattingProvider=true")
}
}
func TestFormatting(t *testing.T) {
unformatted := "create function foo() returns void language plpgsql as $$ begin end $$;"
uri := "file:///test.sql"
var input []byte
input = append(input, frame(1, "initialize", map[string]interface{}{})...)
input = append(input, notifFrame("initialized", nil)...)
input = append(input, notifFrame("textDocument/didOpen", map[string]interface{}{
"textDocument": map[string]interface{}{
"uri": uri,
"languageId": "sql",
"version": 1,
"text": unformatted,
},
})...)
input = append(input, frame(2, "textDocument/formatting", map[string]interface{}{
"textDocument": map[string]interface{}{"uri": uri},
"options": map[string]interface{}{"tabSize": 2, "insertSpaces": true},
})...)
input = append(input, frame(3, "shutdown", nil)...)
input = append(input, notifFrame("exit", nil)...)
out := runServer(t, input)
// Skip initialize response and publishDiagnostics notification, find formatting response
var formattingResp map[string]interface{}
for i := 0; i < 10; i++ {
resp := readResp(t, out)
if resp == nil {
break
}
id, hasID := resp["id"]
if hasID && id.(float64) == 2 {
formattingResp = resp
break
}
}
if formattingResp == nil {
t.Fatal("did not receive formatting response")
}
result, ok := formattingResp["result"].([]interface{})
if !ok {
t.Fatalf("expected array result, got %T: %v", formattingResp["result"], formattingResp["result"])
}
if len(result) == 0 {
t.Fatal("expected at least one text edit")
}
edit := result[0].(map[string]interface{})
newText := edit["newText"].(string)
if !strings.Contains(newText, "CREATE FUNCTION") {
t.Errorf("formatted output missing CREATE FUNCTION keyword; got:\n%s", newText)
}
}
func TestDidClose_ClearsDiagnostics(t *testing.T) {
uri := "file:///test.sql"
// SQL with a lint violation (SELECT * triggers COR001)
sql := "SELECT * FROM users;"
var input []byte
input = append(input, frame(1, "initialize", map[string]interface{}{})...)
input = append(input, notifFrame("initialized", nil)...)
input = append(input, notifFrame("textDocument/didOpen", map[string]interface{}{
"textDocument": map[string]interface{}{
"uri": uri, "languageId": "sql", "version": 1, "text": sql,
},
})...)
input = append(input, notifFrame("textDocument/didClose", map[string]interface{}{
"textDocument": map[string]interface{}{"uri": uri},
})...)
input = append(input, frame(2, "shutdown", nil)...)
input = append(input, notifFrame("exit", nil)...)
out := runServer(t, input)
// Collect all publishDiagnostics notifications; the last one for this URI must be empty.
var lastDiags []interface{}
for i := 0; i < 20; i++ {
if out.Len() == 0 {
break
}
resp := readResp(t, out)
if resp == nil {
break
}
if resp["method"] == "textDocument/publishDiagnostics" {
p := resp["params"].(map[string]interface{})
if p["uri"] == uri {
lastDiags = p["diagnostics"].([]interface{})
}
}
}
if len(lastDiags) != 0 {
t.Errorf("expected empty diagnostics after didClose, got %d", len(lastDiags))
}
}
+2 -2
View File
@@ -8,8 +8,8 @@
package parser package parser
import ( import (
"github.com/hein/pgtidy/pkg/cst" "git.warky.dev/wdevs/pgtidy/pkg/cst"
"github.com/hein/pgtidy/pkg/lexer" "git.warky.dev/wdevs/pgtidy/pkg/lexer"
) )
// Parse lexes and parses src into a lossless cst.File. // Parse lexes and parses src into a lossless cst.File.
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/hein/pgtidy/pkg/cst" "git.warky.dev/wdevs/pgtidy/pkg/cst"
) )
func TestParseRoundTripSmall(t *testing.T) { func TestParseRoundTripSmall(t *testing.T) {