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
+1 -1
View File
@@ -11,7 +11,7 @@ package cst
import (
"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.
+11
View File
@@ -12,6 +12,15 @@ const (
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.
type Diagnostic struct {
// RuleID is the stable identifier for the rule that produced this finding
@@ -27,4 +36,6 @@ type Diagnostic struct {
Line int
// Col is the 1-based column number of the finding.
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 (
"strings"
"github.com/hein/pgtidy/pkg/config"
"github.com/hein/pgtidy/pkg/cst"
"github.com/hein/pgtidy/pkg/lexer"
"git.warky.dev/wdevs/pgtidy/pkg/config"
"git.warky.dev/wdevs/pgtidy/pkg/cst"
"git.warky.dev/wdevs/pgtidy/pkg/lexer"
)
// sqlClauseKw: col-0 lines at paren-depth 0 starting with these keywords stay
+3 -3
View File
@@ -14,9 +14,9 @@ package format
import (
"strings"
"github.com/hein/pgtidy/pkg/config"
"github.com/hein/pgtidy/pkg/cst"
"github.com/hein/pgtidy/pkg/lexer"
"git.warky.dev/wdevs/pgtidy/pkg/config"
"git.warky.dev/wdevs/pgtidy/pkg/cst"
"git.warky.dev/wdevs/pgtidy/pkg/lexer"
)
// File formats a parsed file with the given style.
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"strings"
"testing"
"github.com/hein/pgtidy/pkg/config"
"github.com/hein/pgtidy/pkg/lexer"
"github.com/hein/pgtidy/pkg/parser"
"git.warky.dev/wdevs/pgtidy/pkg/config"
"git.warky.dev/wdevs/pgtidy/pkg/lexer"
"git.warky.dev/wdevs/pgtidy/pkg/parser"
)
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"
"github.com/hein/pgtidy/pkg/diagnostics"
"github.com/hein/pgtidy/pkg/pgast"
"git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
"git.warky.dev/wdevs/pgtidy/pkg/pgast"
)
// Rule is implemented by each lint rule.
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"path/filepath"
"testing"
"github.com/hein/pgtidy/pkg/diagnostics"
"github.com/hein/pgtidy/pkg/lint"
"git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
"git.warky.dev/wdevs/pgtidy/pkg/lint"
)
func fixtureDir() string {
+2 -2
View File
@@ -5,8 +5,8 @@ import (
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/hein/pgtidy/pkg/diagnostics"
"github.com/hein/pgtidy/pkg/pgast"
"git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
"git.warky.dev/wdevs/pgtidy/pkg/pgast"
)
// COR001 — SELECT *.
+75 -6
View File
@@ -2,11 +2,12 @@ package lint
import (
"fmt"
"strings"
pg_query "github.com/pganalyze/pg_query_go/v6"
"github.com/hein/pgtidy/pkg/diagnostics"
"github.com/hein/pgtidy/pkg/pgast"
"git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
"git.warky.dev/wdevs/pgtidy/pkg/pgast"
)
// MIG001 — CREATE INDEX without CONCURRENT.
@@ -30,13 +31,17 @@ func (ruleMIG001) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Dia
continue
}
line, col := pgast.LocationToLineCol(src, pgast.FirstTokenOffset(src, int(raw.StmtLocation)))
out = append(out, diagnostics.Diagnostic{
d := diagnostics.Diagnostic{
RuleID: "MIG001",
Severity: diagnostics.SeverityWarning,
Message: fmt.Sprintf("CREATE INDEX on %q without CONCURRENT blocks writes; use CREATE INDEX CONCURRENTLY", relName(s.Relation)),
Line: line,
Col: col,
})
}
if fix := mig001Fix(src, int(raw.StmtLocation), int(raw.StmtLen)); fix != nil {
d.Fix = fix
}
out = append(out, d)
}
return out
}
@@ -127,13 +132,17 @@ func (ruleMIG003) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Dia
kind = "CHECK"
}
line, col := pgast.LocationToLineCol(src, pgast.FirstTokenOffset(src, int(raw.StmtLocation)))
out = append(out, diagnostics.Diagnostic{
d := diagnostics.Diagnostic{
RuleID: "MIG003",
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),
Line: line,
Col: col,
})
}
if fix := mig003Fix(src, int(raw.StmtLocation), int(raw.StmtLen)); fix != nil {
d.Fix = fix
}
out = append(out, d)
}
}
return out
@@ -169,3 +178,63 @@ func relName(rv *pg_query.RangeVar) string {
}
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"
"github.com/hein/pgtidy/pkg/diagnostics"
"github.com/hein/pgtidy/pkg/pgast"
"git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
"git.warky.dev/wdevs/pgtidy/pkg/pgast"
)
// 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
import (
"github.com/hein/pgtidy/pkg/cst"
"github.com/hein/pgtidy/pkg/lexer"
"git.warky.dev/wdevs/pgtidy/pkg/cst"
"git.warky.dev/wdevs/pgtidy/pkg/lexer"
)
// Parse lexes and parses src into a lossless cst.File.
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
"github.com/hein/pgtidy/pkg/cst"
"git.warky.dev/wdevs/pgtidy/pkg/cst"
)
func TestParseRoundTripSmall(t *testing.T) {