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,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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user