diff --git a/pkg/format/body.go b/pkg/format/body.go index c7390f5..5512b6a 100644 --- a/pkg/format/body.go +++ b/pkg/format/body.go @@ -215,13 +215,13 @@ func formatBodyStatements(text string, st config.Style) string { rawLines := strings.Split(normalised, "\n") var ( - result strings.Builder - stmt []bline - parenDepth int - blockDepth int // 0=col-0 (BEGIN/END/EXCEPTION), 1=body, 2=nested… - inException bool + result strings.Builder + stmt []bline + parenDepth int + blockDepth int // 0=col-0 (BEGIN/END/EXCEPTION), 1=body, 2=nested… + inException bool pendingBlanks int - depthInc bool // increment blockDepth after next flush + depthInc bool // increment blockDepth after next flush ) flush := func() { diff --git a/pkg/lint/rules_correctness.go b/pkg/lint/rules_correctness.go index 79a3737..96b6efe 100644 --- a/pkg/lint/rules_correctness.go +++ b/pkg/lint/rules_correctness.go @@ -14,7 +14,7 @@ import ( // break callers silently. Enumerate the columns you need explicitly. 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) 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. 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) 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. 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) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic { diff --git a/pkg/lint/rules_migration.go b/pkg/lint/rules_migration.go index bfb214f..de76058 100644 --- a/pkg/lint/rules_migration.go +++ b/pkg/lint/rules_migration.go @@ -16,7 +16,7 @@ import ( // CREATE INDEX CONCURRENTLY instead. 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) 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. 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) 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. 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) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic { diff --git a/pkg/lint/rules_naming.go b/pkg/lint/rules_naming.go index 27f64c7..1566a39 100644 --- a/pkg/lint/rules_naming.go +++ b/pkg/lint/rules_naming.go @@ -22,7 +22,7 @@ func isSnakeCase(s string) bool { // NAM001 — table names must be snake_case. 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) 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. 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) 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. 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) Check(stmts []*pg_query.RawStmt, src string) []diagnostics.Diagnostic { diff --git a/pkg/lsp/server.go b/pkg/lsp/server.go index ca14d2e..abfdaf5 100644 --- a/pkg/lsp/server.go +++ b/pkg/lsp/server.go @@ -38,8 +38,8 @@ func Serve(ctx context.Context, r io.Reader, w io.Writer, startDir string) error } type server struct { - docs map[string]string // URI → current text - fixes map[string][]diagnostics.Diagnostic // URI → diagnostics that have fixes + docs map[string]string // URI → current text + fixes map[string][]diagnostics.Diagnostic // URI → diagnostics that have fixes cfg config.Style w io.Writer } @@ -76,9 +76,10 @@ func (s *server) handle(raw []byte) bool { case "initialize": s.reply(req.ID, initResult{ Capabilities: serverCaps{ - TextDocumentSync: 1, // full sync - DocumentFormattingProvider: true, - CodeActionProvider: true, + TextDocumentSync: 1, // full sync + DocumentFormattingProvider: true, + DocumentRangeFormattingProvider: true, + CodeActionProvider: true, }, }) case "initialized": // no-op notification @@ -124,6 +125,15 @@ func (s *server) handle(raw []byte) bool { return false } 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": var p codeActionParams json.Unmarshal(req.Params, &p) @@ -205,6 +215,61 @@ func (s *server) handleCodeAction(id json.RawMessage, p codeActionParams) { 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. func offsetToPosition(text string, byteOff int) position { if byteOff > len(text) { @@ -360,9 +425,10 @@ type initResult struct { } type serverCaps struct { - TextDocumentSync int `json:"textDocumentSync"` - DocumentFormattingProvider bool `json:"documentFormattingProvider"` - CodeActionProvider bool `json:"codeActionProvider"` + TextDocumentSync int `json:"textDocumentSync"` + DocumentFormattingProvider bool `json:"documentFormattingProvider"` + DocumentRangeFormattingProvider bool `json:"documentRangeFormattingProvider"` + CodeActionProvider bool `json:"codeActionProvider"` } type textDocItem struct { @@ -397,6 +463,15 @@ type formattingParams struct { } `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 { Range lspRange `json:"range"` Severity int `json:"severity"` diff --git a/pkg/lsp/server_test.go b/pkg/lsp/server_test.go index 911a4db..7499836 100644 --- a/pkg/lsp/server_test.go +++ b/pkg/lsp/server_test.go @@ -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) { uri := "file:///test.sql" // SQL with a lint violation (SELECT * triggers COR001)