feat(lsp): add textDocument/rangeFormatting support
Advertise DocumentRangeFormattingProvider in server capabilities and implement rangeFormat: formats the complete document, then returns an edit covering only the minimal changed-line region that overlaps the client's selection. Also includes gofmt alignment fixes across lint and format packages.
This commit is contained in:
+6
-6
@@ -215,13 +215,13 @@ func formatBodyStatements(text string, st config.Style) string {
|
|||||||
rawLines := strings.Split(normalised, "\n")
|
rawLines := strings.Split(normalised, "\n")
|
||||||
|
|
||||||
var (
|
var (
|
||||||
result strings.Builder
|
result strings.Builder
|
||||||
stmt []bline
|
stmt []bline
|
||||||
parenDepth int
|
parenDepth int
|
||||||
blockDepth int // 0=col-0 (BEGIN/END/EXCEPTION), 1=body, 2=nested…
|
blockDepth int // 0=col-0 (BEGIN/END/EXCEPTION), 1=body, 2=nested…
|
||||||
inException bool
|
inException bool
|
||||||
pendingBlanks int
|
pendingBlanks int
|
||||||
depthInc bool // increment blockDepth after next flush
|
depthInc bool // increment blockDepth after next flush
|
||||||
)
|
)
|
||||||
|
|
||||||
flush := func() {
|
flush := func() {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
// break callers silently. Enumerate the columns you need explicitly.
|
// break callers silently. Enumerate the columns you need explicitly.
|
||||||
type ruleCOR001 struct{}
|
type ruleCOR001 struct{}
|
||||||
|
|
||||||
func (ruleCOR001) ID() string { return "COR001" }
|
func (ruleCOR001) ID() string { return "COR001" }
|
||||||
func (ruleCOR001) Severity() diagnostics.Severity { return diagnostics.SeverityHint }
|
func (ruleCOR001) Severity() diagnostics.Severity { return diagnostics.SeverityHint }
|
||||||
|
|
||||||
func (ruleCOR001) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
func (ruleCOR001) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
||||||
@@ -71,7 +71,7 @@ func checkSelectStmt(s *pg_query.SelectStmt, src string, out *[]diagnostics.Diag
|
|||||||
// An UPDATE with no WHERE clause modifies every row in the table.
|
// An UPDATE with no WHERE clause modifies every row in the table.
|
||||||
type ruleCOR002 struct{}
|
type ruleCOR002 struct{}
|
||||||
|
|
||||||
func (ruleCOR002) ID() string { return "COR002" }
|
func (ruleCOR002) ID() string { return "COR002" }
|
||||||
func (ruleCOR002) Severity() diagnostics.Severity { return diagnostics.SeverityWarning }
|
func (ruleCOR002) Severity() diagnostics.Severity { return diagnostics.SeverityWarning }
|
||||||
|
|
||||||
func (ruleCOR002) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
func (ruleCOR002) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
||||||
@@ -101,7 +101,7 @@ func (ruleCOR002) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Dia
|
|||||||
// Use TRUNCATE if you intend a full wipe; it is faster and explicit.
|
// Use TRUNCATE if you intend a full wipe; it is faster and explicit.
|
||||||
type ruleCOR003 struct{}
|
type ruleCOR003 struct{}
|
||||||
|
|
||||||
func (ruleCOR003) ID() string { return "COR003" }
|
func (ruleCOR003) ID() string { return "COR003" }
|
||||||
func (ruleCOR003) Severity() diagnostics.Severity { return diagnostics.SeverityWarning }
|
func (ruleCOR003) Severity() diagnostics.Severity { return diagnostics.SeverityWarning }
|
||||||
|
|
||||||
func (ruleCOR003) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
func (ruleCOR003) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
// CREATE INDEX CONCURRENTLY instead.
|
// CREATE INDEX CONCURRENTLY instead.
|
||||||
type ruleMIG001 struct{}
|
type ruleMIG001 struct{}
|
||||||
|
|
||||||
func (ruleMIG001) ID() string { return "MIG001" }
|
func (ruleMIG001) ID() string { return "MIG001" }
|
||||||
func (ruleMIG001) Severity() diagnostics.Severity { return diagnostics.SeverityWarning }
|
func (ruleMIG001) Severity() diagnostics.Severity { return diagnostics.SeverityWarning }
|
||||||
|
|
||||||
func (ruleMIG001) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
func (ruleMIG001) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
||||||
@@ -52,7 +52,7 @@ func (ruleMIG001) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Dia
|
|||||||
// supplied. Use a nullable column first, backfill, then add the constraint.
|
// supplied. Use a nullable column first, backfill, then add the constraint.
|
||||||
type ruleMIG002 struct{}
|
type ruleMIG002 struct{}
|
||||||
|
|
||||||
func (ruleMIG002) ID() string { return "MIG002" }
|
func (ruleMIG002) ID() string { return "MIG002" }
|
||||||
func (ruleMIG002) Severity() diagnostics.Severity { return diagnostics.SeverityWarning }
|
func (ruleMIG002) Severity() diagnostics.Severity { return diagnostics.SeverityWarning }
|
||||||
|
|
||||||
func (ruleMIG002) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
func (ruleMIG002) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
||||||
@@ -97,7 +97,7 @@ func (ruleMIG002) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Dia
|
|||||||
// VALIDATE CONSTRAINT to spread the lock window.
|
// VALIDATE CONSTRAINT to spread the lock window.
|
||||||
type ruleMIG003 struct{}
|
type ruleMIG003 struct{}
|
||||||
|
|
||||||
func (ruleMIG003) ID() string { return "MIG003" }
|
func (ruleMIG003) ID() string { return "MIG003" }
|
||||||
func (ruleMIG003) Severity() diagnostics.Severity { return diagnostics.SeverityWarning }
|
func (ruleMIG003) Severity() diagnostics.Severity { return diagnostics.SeverityWarning }
|
||||||
|
|
||||||
func (ruleMIG003) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
func (ruleMIG003) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func isSnakeCase(s string) bool {
|
|||||||
// NAM001 — table names must be snake_case.
|
// NAM001 — table names must be snake_case.
|
||||||
type ruleNAM001 struct{}
|
type ruleNAM001 struct{}
|
||||||
|
|
||||||
func (ruleNAM001) ID() string { return "NAM001" }
|
func (ruleNAM001) ID() string { return "NAM001" }
|
||||||
func (ruleNAM001) Severity() diagnostics.Severity { return diagnostics.SeverityHint }
|
func (ruleNAM001) Severity() diagnostics.Severity { return diagnostics.SeverityHint }
|
||||||
|
|
||||||
func (ruleNAM001) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
func (ruleNAM001) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
||||||
@@ -51,7 +51,7 @@ func (ruleNAM001) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Dia
|
|||||||
// NAM002 — column names in CREATE TABLE must be snake_case.
|
// NAM002 — column names in CREATE TABLE must be snake_case.
|
||||||
type ruleNAM002 struct{}
|
type ruleNAM002 struct{}
|
||||||
|
|
||||||
func (ruleNAM002) ID() string { return "NAM002" }
|
func (ruleNAM002) ID() string { return "NAM002" }
|
||||||
func (ruleNAM002) Severity() diagnostics.Severity { return diagnostics.SeverityHint }
|
func (ruleNAM002) Severity() diagnostics.Severity { return diagnostics.SeverityHint }
|
||||||
|
|
||||||
func (ruleNAM002) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
func (ruleNAM002) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
||||||
@@ -86,7 +86,7 @@ func (ruleNAM002) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Dia
|
|||||||
// NAM003 — function/procedure names must be snake_case.
|
// NAM003 — function/procedure names must be snake_case.
|
||||||
type ruleNAM003 struct{}
|
type ruleNAM003 struct{}
|
||||||
|
|
||||||
func (ruleNAM003) ID() string { return "NAM003" }
|
func (ruleNAM003) ID() string { return "NAM003" }
|
||||||
func (ruleNAM003) Severity() diagnostics.Severity { return diagnostics.SeverityHint }
|
func (ruleNAM003) Severity() diagnostics.Severity { return diagnostics.SeverityHint }
|
||||||
|
|
||||||
func (ruleNAM003) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
func (ruleNAM003) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic {
|
||||||
|
|||||||
+83
-8
@@ -38,8 +38,8 @@ func Serve(ctx context.Context, r io.Reader, w io.Writer, startDir string) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
type server struct {
|
type server struct {
|
||||||
docs map[string]string // URI → current text
|
docs map[string]string // URI → current text
|
||||||
fixes map[string][]diagnostics.Diagnostic // URI → diagnostics that have fixes
|
fixes map[string][]diagnostics.Diagnostic // URI → diagnostics that have fixes
|
||||||
cfg config.Style
|
cfg config.Style
|
||||||
w io.Writer
|
w io.Writer
|
||||||
}
|
}
|
||||||
@@ -76,9 +76,10 @@ func (s *server) handle(raw []byte) bool {
|
|||||||
case "initialize":
|
case "initialize":
|
||||||
s.reply(req.ID, initResult{
|
s.reply(req.ID, initResult{
|
||||||
Capabilities: serverCaps{
|
Capabilities: serverCaps{
|
||||||
TextDocumentSync: 1, // full sync
|
TextDocumentSync: 1, // full sync
|
||||||
DocumentFormattingProvider: true,
|
DocumentFormattingProvider: true,
|
||||||
CodeActionProvider: true,
|
DocumentRangeFormattingProvider: true,
|
||||||
|
CodeActionProvider: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
case "initialized": // no-op notification
|
case "initialized": // no-op notification
|
||||||
@@ -124,6 +125,15 @@ func (s *server) handle(raw []byte) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
s.reply(req.ID, []textEdit{fullReplace(text, formatted)})
|
s.reply(req.ID, []textEdit{fullReplace(text, formatted)})
|
||||||
|
case "textDocument/rangeFormatting":
|
||||||
|
var p rangeFormattingParams
|
||||||
|
json.Unmarshal(req.Params, &p)
|
||||||
|
text, ok := s.docs[p.TextDocument.URI]
|
||||||
|
if !ok {
|
||||||
|
s.reply(req.ID, []textEdit{})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.reply(req.ID, s.rangeFormat(text, p.Range))
|
||||||
case "textDocument/codeAction":
|
case "textDocument/codeAction":
|
||||||
var p codeActionParams
|
var p codeActionParams
|
||||||
json.Unmarshal(req.Params, &p)
|
json.Unmarshal(req.Params, &p)
|
||||||
@@ -205,6 +215,61 @@ func (s *server) handleCodeAction(id json.RawMessage, p codeActionParams) {
|
|||||||
s.reply(id, actions)
|
s.reply(id, actions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rangeFormat formats the region of text that overlaps the given LSP range.
|
||||||
|
// It formats the complete document, then returns an edit covering only the
|
||||||
|
// minimal set of changed lines that intersects the selection. Returns nil when
|
||||||
|
// the document is already formatted or no change falls within the selection.
|
||||||
|
func (s *server) rangeFormat(text string, r lspRange) []textEdit {
|
||||||
|
formatted := format.File(parser.Parse(text), s.cfg)
|
||||||
|
if formatted == text {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split both versions into lines, keeping the trailing newline attached to
|
||||||
|
// each element so that joining them reconstructs the original string.
|
||||||
|
origLines := strings.SplitAfter(text, "\n")
|
||||||
|
fmtLines := strings.SplitAfter(formatted, "\n")
|
||||||
|
|
||||||
|
// Longest common prefix of unchanged lines.
|
||||||
|
pfx := 0
|
||||||
|
for pfx < len(origLines) && pfx < len(fmtLines) && origLines[pfx] == fmtLines[pfx] {
|
||||||
|
pfx++
|
||||||
|
}
|
||||||
|
// Longest common suffix (must not overlap the prefix).
|
||||||
|
sfx := 0
|
||||||
|
for sfx < len(origLines)-pfx && sfx < len(fmtLines)-pfx &&
|
||||||
|
origLines[len(origLines)-1-sfx] == fmtLines[len(fmtLines)-1-sfx] {
|
||||||
|
sfx++
|
||||||
|
}
|
||||||
|
|
||||||
|
// The changed region in the original spans lines [pfx, changedEnd).
|
||||||
|
changedStart := pfx
|
||||||
|
changedEnd := len(origLines) - sfx
|
||||||
|
|
||||||
|
// Only return an edit when the changed region overlaps the selection.
|
||||||
|
selStart := int(r.Start.Line)
|
||||||
|
selEnd := int(r.End.Line)
|
||||||
|
if changedEnd <= selStart || changedStart > selEnd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute byte offsets for the changed region boundary.
|
||||||
|
startByte := 0
|
||||||
|
for i := 0; i < changedStart; i++ {
|
||||||
|
startByte += len(origLines[i])
|
||||||
|
}
|
||||||
|
endByte := startByte
|
||||||
|
for i := changedStart; i < changedEnd; i++ {
|
||||||
|
endByte += len(origLines[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
newText := strings.Join(fmtLines[pfx:len(fmtLines)-sfx], "")
|
||||||
|
return []textEdit{{
|
||||||
|
Range: lspRange{Start: offsetToPosition(text, startByte), End: offsetToPosition(text, endByte)},
|
||||||
|
NewText: newText,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
// offsetToPosition converts a byte offset in text to an LSP position.
|
// offsetToPosition converts a byte offset in text to an LSP position.
|
||||||
func offsetToPosition(text string, byteOff int) position {
|
func offsetToPosition(text string, byteOff int) position {
|
||||||
if byteOff > len(text) {
|
if byteOff > len(text) {
|
||||||
@@ -360,9 +425,10 @@ type initResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type serverCaps struct {
|
type serverCaps struct {
|
||||||
TextDocumentSync int `json:"textDocumentSync"`
|
TextDocumentSync int `json:"textDocumentSync"`
|
||||||
DocumentFormattingProvider bool `json:"documentFormattingProvider"`
|
DocumentFormattingProvider bool `json:"documentFormattingProvider"`
|
||||||
CodeActionProvider bool `json:"codeActionProvider"`
|
DocumentRangeFormattingProvider bool `json:"documentRangeFormattingProvider"`
|
||||||
|
CodeActionProvider bool `json:"codeActionProvider"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type textDocItem struct {
|
type textDocItem struct {
|
||||||
@@ -397,6 +463,15 @@ type formattingParams struct {
|
|||||||
} `json:"options"`
|
} `json:"options"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type rangeFormattingParams struct {
|
||||||
|
TextDocument textDocID `json:"textDocument"`
|
||||||
|
Range lspRange `json:"range"`
|
||||||
|
Options struct {
|
||||||
|
TabSize int `json:"tabSize"`
|
||||||
|
InsertSpaces bool `json:"insertSpaces"`
|
||||||
|
} `json:"options"`
|
||||||
|
}
|
||||||
|
|
||||||
type lspDiagnostic struct {
|
type lspDiagnostic struct {
|
||||||
Range lspRange `json:"range"`
|
Range lspRange `json:"range"`
|
||||||
Severity int `json:"severity"`
|
Severity int `json:"severity"`
|
||||||
|
|||||||
@@ -156,6 +156,76 @@ func TestFormatting(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRangeFormatting(t *testing.T) {
|
||||||
|
// Two statements: first is unformatted, second is already formatted.
|
||||||
|
// Range request covers only the first statement (line 0).
|
||||||
|
sql := "select a, b from t where x = 1;\nSELECT z\nFROM s;\n"
|
||||||
|
uri := "file:///range.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": sql,
|
||||||
|
},
|
||||||
|
})...)
|
||||||
|
input = append(input, frame(2, "textDocument/rangeFormatting", map[string]interface{}{
|
||||||
|
"textDocument": map[string]interface{}{"uri": uri},
|
||||||
|
"range": map[string]interface{}{
|
||||||
|
"start": map[string]interface{}{"line": 0, "character": 0},
|
||||||
|
"end": map[string]interface{}{"line": 0, "character": 31},
|
||||||
|
},
|
||||||
|
"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)
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
r := readResp(t, out)
|
||||||
|
if r == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if id, ok := r["id"]; ok && id.(float64) == 2 {
|
||||||
|
resp = r
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
t.Fatal("no response for rangeFormatting request")
|
||||||
|
}
|
||||||
|
result, ok := resp["result"].([]interface{})
|
||||||
|
if !ok || len(result) == 0 {
|
||||||
|
t.Fatalf("expected non-empty edit array, got %v", resp["result"])
|
||||||
|
}
|
||||||
|
edit := result[0].(map[string]interface{})
|
||||||
|
newText, _ := edit["newText"].(string)
|
||||||
|
if !strings.Contains(newText, "SELECT") {
|
||||||
|
t.Errorf("expected formatted SELECT in edit, got: %q", newText)
|
||||||
|
}
|
||||||
|
if strings.Contains(newText, "SELECT z") {
|
||||||
|
t.Errorf("range formatting edited second statement unexpectedly; got: %q", newText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitialize_AdvertisesRangeFormatting(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)
|
||||||
|
caps := resp["result"].(map[string]interface{})["capabilities"].(map[string]interface{})
|
||||||
|
if caps["documentRangeFormattingProvider"] != true {
|
||||||
|
t.Error("expected documentRangeFormattingProvider=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDidClose_ClearsDiagnostics(t *testing.T) {
|
func TestDidClose_ClearsDiagnostics(t *testing.T) {
|
||||||
uri := "file:///test.sql"
|
uri := "file:///test.sql"
|
||||||
// SQL with a lint violation (SELECT * triggers COR001)
|
// SQL with a lint violation (SELECT * triggers COR001)
|
||||||
|
|||||||
Reference in New Issue
Block a user