feat(lsp): add textDocument/rangeFormatting support
CI / Test (push) Successful in 50s
CI / Build snapshot (push) Failing after 1m12s

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:
2026-06-28 16:28:27 +02:00
parent a98dee1877
commit d1150a7eea
6 changed files with 168 additions and 23 deletions
+75
View File
@@ -78,6 +78,7 @@ func (s *server) handle(raw []byte) bool {
Capabilities: serverCaps{ Capabilities: serverCaps{
TextDocumentSync: 1, // full sync TextDocumentSync: 1, // full sync
DocumentFormattingProvider: true, DocumentFormattingProvider: true,
DocumentRangeFormattingProvider: true,
CodeActionProvider: true, CodeActionProvider: true,
}, },
}) })
@@ -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) {
@@ -362,6 +427,7 @@ 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"`
DocumentRangeFormattingProvider bool `json:"documentRangeFormattingProvider"`
CodeActionProvider bool `json:"codeActionProvider"` CodeActionProvider bool `json:"codeActionProvider"`
} }
@@ -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"`
+70
View File
@@ -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)