feat(lsp): hover, documentSymbol, willSaveWaitUntil, token-wide diagnostic ranges
This commit is contained in:
+163
-7
@@ -2,7 +2,9 @@
|
||||
//
|
||||
// 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/formatting, rangeFormatting, willSaveWaitUntil — formatting via pkg/format
|
||||
// - textDocument/hover — rule ID + message of the diagnostic under the cursor
|
||||
// - textDocument/documentSymbol — CREATE FUNCTION / PROCEDURE outline
|
||||
// - textDocument/publishDiagnostics — lint findings via pkg/lint, sent on
|
||||
// every didOpen/didChange notification
|
||||
package lsp
|
||||
@@ -17,8 +19,10 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/config"
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/cst"
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/diagnostics"
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/format"
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/lexer"
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/lint"
|
||||
"git.warky.dev/wdevs/pgtidy/pkg/parser"
|
||||
)
|
||||
@@ -30,6 +34,7 @@ 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),
|
||||
diags: make(map[string][]lspDiagnostic),
|
||||
fixes: make(map[string][]diagnostics.Diagnostic),
|
||||
cfg: cfg,
|
||||
w: w,
|
||||
@@ -39,6 +44,7 @@ func Serve(ctx context.Context, r io.Reader, w io.Writer, startDir string) error
|
||||
|
||||
type server struct {
|
||||
docs map[string]string // URI → current text
|
||||
diags map[string][]lspDiagnostic // URI → last published diagnostics
|
||||
fixes map[string][]diagnostics.Diagnostic // URI → diagnostics that have fixes
|
||||
cfg config.Style
|
||||
w io.Writer
|
||||
@@ -76,10 +82,12 @@ func (s *server) handle(raw []byte) bool {
|
||||
case "initialize":
|
||||
s.reply(req.ID, initResult{
|
||||
Capabilities: serverCaps{
|
||||
TextDocumentSync: 1, // full sync
|
||||
TextDocumentSync: syncOptions{OpenClose: true, Change: 1, WillSaveWaitUntil: true}, // full sync
|
||||
DocumentFormattingProvider: true,
|
||||
DocumentRangeFormattingProvider: true,
|
||||
CodeActionProvider: true,
|
||||
HoverProvider: true,
|
||||
DocumentSymbolProvider: true,
|
||||
},
|
||||
})
|
||||
case "initialized": // no-op notification
|
||||
@@ -111,6 +119,18 @@ func (s *server) handle(raw []byte) bool {
|
||||
URI: p.TextDocument.URI,
|
||||
Diagnostics: []lspDiagnostic{},
|
||||
})
|
||||
case "textDocument/willSaveWaitUntil":
|
||||
var p formattingParams // only textDocument is used
|
||||
_ = json.Unmarshal(req.Params, &p)
|
||||
s.reply(req.ID, s.fullFormatEdits(p.TextDocument.URI))
|
||||
case "textDocument/hover":
|
||||
var p positionParams
|
||||
_ = json.Unmarshal(req.Params, &p)
|
||||
s.reply(req.ID, s.hover(p.TextDocument.URI, p.Position))
|
||||
case "textDocument/documentSymbol":
|
||||
var p formattingParams
|
||||
_ = json.Unmarshal(req.Params, &p)
|
||||
s.reply(req.ID, s.documentSymbols(p.TextDocument.URI))
|
||||
case "textDocument/formatting":
|
||||
var p formattingParams
|
||||
_ = json.Unmarshal(req.Params, &p)
|
||||
@@ -167,7 +187,7 @@ func (s *server) pushDiagnostics(uri, text string) {
|
||||
col--
|
||||
}
|
||||
lspD := lspDiagnostic{
|
||||
Range: lspRange{Start: position{line, col}, End: position{line, col + 1}},
|
||||
Range: lspRange{Start: position{line, col}, End: position{line, col + diagSpan(text, line, col)}},
|
||||
Severity: severityCode(d.Severity),
|
||||
Code: d.RuleID,
|
||||
Source: "pgtidy",
|
||||
@@ -179,6 +199,7 @@ func (s *server) pushDiagnostics(uri, text string) {
|
||||
}
|
||||
}
|
||||
s.fixes[uri] = fixable
|
||||
s.diags[uri] = out
|
||||
s.notify("textDocument/publishDiagnostics", publishDiagnosticsParams{
|
||||
URI: uri,
|
||||
Diagnostics: out,
|
||||
@@ -432,10 +453,18 @@ type initResult struct {
|
||||
}
|
||||
|
||||
type serverCaps struct {
|
||||
TextDocumentSync int `json:"textDocumentSync"`
|
||||
DocumentFormattingProvider bool `json:"documentFormattingProvider"`
|
||||
DocumentRangeFormattingProvider bool `json:"documentRangeFormattingProvider"`
|
||||
CodeActionProvider bool `json:"codeActionProvider"`
|
||||
TextDocumentSync syncOptions `json:"textDocumentSync"`
|
||||
DocumentFormattingProvider bool `json:"documentFormattingProvider"`
|
||||
DocumentRangeFormattingProvider bool `json:"documentRangeFormattingProvider"`
|
||||
CodeActionProvider bool `json:"codeActionProvider"`
|
||||
HoverProvider bool `json:"hoverProvider"`
|
||||
DocumentSymbolProvider bool `json:"documentSymbolProvider"`
|
||||
}
|
||||
|
||||
type syncOptions struct {
|
||||
OpenClose bool `json:"openClose"`
|
||||
Change int `json:"change"` // 1 = full
|
||||
WillSaveWaitUntil bool `json:"willSaveWaitUntil"`
|
||||
}
|
||||
|
||||
type textDocItem struct {
|
||||
@@ -509,3 +538,130 @@ type codeAction struct {
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Edit *workspaceEdit `json:"edit,omitempty"`
|
||||
}
|
||||
|
||||
type positionParams struct {
|
||||
TextDocument textDocID `json:"textDocument"`
|
||||
Position position `json:"position"`
|
||||
}
|
||||
|
||||
type hoverResult struct {
|
||||
Contents markupContent `json:"contents"`
|
||||
Range lspRange `json:"range"`
|
||||
}
|
||||
|
||||
type markupContent struct {
|
||||
Kind string `json:"kind"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type documentSymbol struct {
|
||||
Name string `json:"name"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Kind int `json:"kind"`
|
||||
Range lspRange `json:"range"`
|
||||
SelectionRange lspRange `json:"selectionRange"`
|
||||
}
|
||||
|
||||
const symbolKindFunction = 12
|
||||
|
||||
// fullFormatEdits returns the whole-document formatting edit for uri, or an
|
||||
// empty list when the document is unknown, already formatted, or the result
|
||||
// fails the safety gate.
|
||||
func (s *server) fullFormatEdits(uri string) []textEdit {
|
||||
text, ok := s.docs[uri]
|
||||
if !ok {
|
||||
return []textEdit{}
|
||||
}
|
||||
formatted := format.File(parser.Parse(text), s.cfg)
|
||||
if formatted == text || format.VerifySafe(text, formatted, s.cfg) != nil {
|
||||
return []textEdit{}
|
||||
}
|
||||
return []textEdit{fullReplace(text, formatted)}
|
||||
}
|
||||
|
||||
// diagSpan returns the width in characters of the token starting at the given
|
||||
// (0-based) line/col, so a diagnostic highlights the offending token rather
|
||||
// than a single caret. Falls back to 1.
|
||||
func diagSpan(text string, line, col uint32) uint32 {
|
||||
off := positionToOffset(text, position{line, col})
|
||||
for _, t := range lexer.Lex(text) {
|
||||
if t.Off == off && !t.IsTrivia() && t.Kind != lexer.EOF && !strings.Contains(t.Text, "\n") && len(t.Text) > 0 {
|
||||
return uint32(len(t.Text))
|
||||
}
|
||||
if t.Off > off {
|
||||
break
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// positionToOffset converts an LSP position to a byte offset in text.
|
||||
func positionToOffset(text string, p position) int {
|
||||
off := 0
|
||||
for line := uint32(0); line < p.Line; line++ {
|
||||
i := strings.IndexByte(text[off:], '\n')
|
||||
if i < 0 {
|
||||
return len(text)
|
||||
}
|
||||
off += i + 1
|
||||
}
|
||||
off += int(p.Character)
|
||||
if off > len(text) {
|
||||
off = len(text)
|
||||
}
|
||||
return off
|
||||
}
|
||||
|
||||
// hover describes the diagnostic under the cursor, if any.
|
||||
func (s *server) hover(uri string, p position) *hoverResult {
|
||||
for _, d := range s.diags[uri] {
|
||||
if !rangesOverlap(d.Range.Start, d.Range.End, p, p) || p == d.Range.End {
|
||||
continue
|
||||
}
|
||||
return &hoverResult{
|
||||
Contents: markupContent{Kind: "markdown", Value: "**" + d.Code + "**\n\n" + d.Message},
|
||||
Range: d.Range,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// documentSymbols lists the CREATE FUNCTION / PROCEDURE statements in the document.
|
||||
func (s *server) documentSymbols(uri string) []documentSymbol {
|
||||
text, ok := s.docs[uri]
|
||||
if !ok {
|
||||
return []documentSymbol{}
|
||||
}
|
||||
out := []documentSymbol{}
|
||||
for _, item := range parser.Parse(text).Items {
|
||||
cf, ok := item.(*cst.CreateFunction)
|
||||
if !ok || len(cf.Name) == 0 {
|
||||
continue
|
||||
}
|
||||
all := cst.Tokens(cf)
|
||||
last := all[len(all)-1].Tok
|
||||
nameFirst, nameLast := cf.Name[0].Tok, cf.Name[len(cf.Name)-1].Tok
|
||||
var name strings.Builder
|
||||
for _, t := range cf.Name {
|
||||
name.WriteString(t.Tok.Text)
|
||||
}
|
||||
detail := "function"
|
||||
if cf.IsProcedure() {
|
||||
detail = "procedure"
|
||||
}
|
||||
out = append(out, documentSymbol{
|
||||
Name: name.String(),
|
||||
Detail: detail,
|
||||
Kind: symbolKindFunction,
|
||||
Range: lspRange{
|
||||
Start: offsetToPosition(text, all[0].Tok.Off),
|
||||
End: offsetToPosition(text, last.Off+len(last.Text)),
|
||||
},
|
||||
SelectionRange: lspRange{
|
||||
Start: offsetToPosition(text, nameFirst.Off),
|
||||
End: offsetToPosition(text, nameLast.Off+len(nameLast.Text)),
|
||||
},
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user