- 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)
430 lines
10 KiB
Go
430 lines
10 KiB
Go
// 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"`
|
|
}
|