feat: add LSP server, VSCode + DataGrip extensions, release infra, autofix
- 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:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 |
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -43,6 +47,8 @@ func usage(w io.Writer) {
|
|||||||
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 config Print effective configuration
|
||||||
|
pgtidy lsp Start LSP server (stdio, for editors)
|
||||||
pgtidy version Print version
|
pgtidy version Print version
|
||||||
pgtidy help Show this help
|
pgtidy help Show this help
|
||||||
|
|
||||||
|
|||||||
+23
-11
@@ -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).
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
*.zip
|
||||||
@@ -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 (MIG001–MIG003, COR001–003, NAM001–003)
|
||||||
|
- **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`.
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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 |
@@ -0,0 +1,4 @@
|
|||||||
|
out/
|
||||||
|
node_modules/
|
||||||
|
*.vsix
|
||||||
|
assets/
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.vscode/**
|
||||||
|
src/**
|
||||||
|
node_modules/**
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
tsconfig.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
pnpm-workspace.yaml
|
||||||
|
**/*.ts
|
||||||
|
**/*.map
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../LICENSE
|
||||||
@@ -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 (MIG001–MIG003, COR001–003, NAM001–003)
|
||||||
|
- **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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1589
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
allowBuilds:
|
||||||
|
'@vscode/vsce-sign': true
|
||||||
|
keytar: true
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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,4 +1,4 @@
|
|||||||
module github.com/hein/pgtidy
|
module git.warky.dev/wdevs/pgtidy
|
||||||
|
|
||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 *.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user