diff --git a/internal/handler/handler.go b/internal/handler/handler.go index ffb0260..2ec4d02 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -118,6 +118,8 @@ func (s *Server) handle(ctx context.Context, conn *jsonrpc2.Conn, req *jsonrpc2. return s.handleTextDocumentRangeFormatting(ctx, conn, req) case "textDocument/signatureHelp": return s.handleTextDocumentSignatureHelp(ctx, conn, req) + case "textDocument/rename": + return s.handleTextDocumentRename(ctx, conn, req) } return nil, &jsonrpc2.Error{Code: jsonrpc2.CodeMethodNotFound, Message: fmt.Sprintf("method not supported: %s", req.Method)} } @@ -150,6 +152,7 @@ func (s *Server) handleInitialize(ctx context.Context, conn *jsonrpc2.Conn, req DefinitionProvider: false, DocumentFormattingProvider: true, DocumentRangeFormattingProvider: true, + RenameProvider: true, }, } diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index fb55c6f..fa2ea5c 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -121,6 +121,7 @@ func TestInitialized(t *testing.T) { DefinitionProvider: false, DocumentFormattingProvider: true, DocumentRangeFormattingProvider: true, + RenameProvider: true, }, } var got lsp.InitializeResult diff --git a/internal/handler/rename.go b/internal/handler/rename.go new file mode 100644 index 0000000..5a1709d --- /dev/null +++ b/internal/handler/rename.go @@ -0,0 +1,110 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/lighttiger2505/sqls/ast" + "github.com/lighttiger2505/sqls/ast/astutil" + "github.com/lighttiger2505/sqls/internal/lsp" + "github.com/lighttiger2505/sqls/parser" + "github.com/lighttiger2505/sqls/parser/parseutil" + "github.com/lighttiger2505/sqls/token" + "github.com/sourcegraph/jsonrpc2" +) + +func (s *Server) handleTextDocumentRename(ctx context.Context, conn *jsonrpc2.Conn, req *jsonrpc2.Request) (result interface{}, err error) { + if req.Params == nil { + return nil, &jsonrpc2.Error{Code: jsonrpc2.CodeInvalidParams} + } + + var params lsp.RenameParams + if err := json.Unmarshal(*req.Params, ¶ms); err != nil { + return nil, err + } + + f, ok := s.files[params.TextDocument.URI] + if !ok { + return nil, fmt.Errorf("document not found: %s", params.TextDocument.URI) + } + + res, err := rename(f.Text, params) + if err != nil { + return nil, err + } + return res, nil +} + +func rename(text string, params lsp.RenameParams) (*lsp.WorkspaceEdit, error) { + parsed, err := parser.Parse(text) + if err != nil { + return nil, err + } + + pos := token.Pos{ + Line: params.Position.Line, + Col: params.Position.Character, + } + + // Get the identifer on focus + nodeWalker := parseutil.NewNodeWalker(parsed, pos) + m := astutil.NodeMatcher{ + NodeTypes: []ast.NodeType{ast.TypeIdentifer}, + } + currentVariable := nodeWalker.CurNodeButtomMatched(m) + if currentVariable == nil { + return nil, nil + } + + // Get all identifiers in the statement + idents, err := parseutil.ExtractIdenfiers(parsed, pos) + if err != nil { + return nil, err + } + + // Extract only those with matching names + renameTarget := []ast.Node{} + for _, ident := range idents { + if ident.String() == currentVariable.String() { + renameTarget = append(renameTarget, ident) + } + } + if len(renameTarget) == 0 { + return nil, nil + } + + edits := make([]lsp.TextEdit, len(renameTarget)) + for i, target := range renameTarget { + edit := lsp.TextEdit{ + Range: lsp.Range{ + Start: lsp.Position{ + Line: target.Pos().Line, + Character: target.Pos().Col, + }, + End: lsp.Position{ + Line: target.End().Line, + Character: target.End().Col, + }, + }, + NewText: params.NewName, + } + edits[i] = edit + } + + res := &lsp.WorkspaceEdit{ + DocumentChanges: []lsp.TextDocumentEdit{ + { + TextDocument: lsp.OptionalVersionedTextDocumentIdentifier{ + Version: 0, + TextDocumentIdentifier: lsp.TextDocumentIdentifier{ + URI: params.TextDocument.URI, + }, + }, + Edits: edits, + }, + }, + } + + return res, nil +} diff --git a/internal/handler/rename_test.go b/internal/handler/rename_test.go new file mode 100644 index 0000000..f3a04eb --- /dev/null +++ b/internal/handler/rename_test.go @@ -0,0 +1,118 @@ +package handler + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/lighttiger2505/sqls/internal/config" + "github.com/lighttiger2505/sqls/internal/database" + "github.com/lighttiger2505/sqls/internal/lsp" +) + +var renameTestCases = []struct { + name string + input string + newName string + output lsp.WorkspaceEdit + pos lsp.Position +}{ + { + name: "ok", + input: "SELECT ci.ID, ci.Name FROM city as ci", + newName: "ct", + output: lsp.WorkspaceEdit{ + DocumentChanges: []lsp.TextDocumentEdit{ + { + TextDocument: lsp.OptionalVersionedTextDocumentIdentifier{ + Version: 0, + TextDocumentIdentifier: lsp.TextDocumentIdentifier{ + URI: "file:///Users/octref/Code/css-test/test.sql", + }, + }, + Edits: []lsp.TextEdit{ + { + Range: lsp.Range{ + Start: lsp.Position{ + Line: 0, + Character: 7, + }, + End: lsp.Position{ + Line: 0, + Character: 9, + }, + }, + NewText: "ct", + }, + { + Range: lsp.Range{ + Start: lsp.Position{ + Line: 0, + Character: 14, + }, + End: lsp.Position{ + Line: 0, + Character: 16, + }, + }, + NewText: "ct", + }, + { + Range: lsp.Range{ + Start: lsp.Position{ + Line: 0, + Character: 35, + }, + End: lsp.Position{ + Line: 0, + Character: 37, + }, + }, + NewText: "ct", + }, + }, + }, + }, + }, + pos: lsp.Position{ + Line: 0, + Character: 8, + }, + }, +} + +func TestRenameMain(t *testing.T) { + tx := newTestContext() + tx.setup(t) + defer tx.tearDown() + + cfg := &config.Config{ + Connections: []*database.DBConfig{ + {Driver: "mock"}, + }, + } + tx.addWorkspaceConfig(t, cfg) + + for _, tt := range renameTestCases { + t.Run(tt.name, func(t *testing.T) { + tx.textDocumentDidOpen(t, testFileURI, tt.input) + + params := lsp.RenameParams{ + TextDocument: lsp.TextDocumentIdentifier{ + URI: testFileURI, + }, + Position: tt.pos, + NewName: tt.newName, + } + var got lsp.WorkspaceEdit + err := tx.conn.Call(tx.ctx, "textDocument/rename", params, &got) + if err != nil { + t.Errorf("conn.Call textDocument/rename: %+v", err) + return + } + + if diff := cmp.Diff(tt.output, got); diff != "" { + t.Errorf("unmatch rename edits (- want, + got):\n%s", diff) + } + }) + } +} diff --git a/internal/lsp/lsp.go b/internal/lsp/lsp.go index 6e85ffc..633fea4 100644 --- a/internal/lsp/lsp.go +++ b/internal/lsp/lsp.go @@ -419,3 +419,70 @@ var ( Info MessageType = 3 Log MessageType = 4 ) + +type RenameParams struct { + TextDocument TextDocumentIdentifier `json:"textDocument"` + Position Position `json:"position"` + NewName string `json:"newName"` + WorkDoneProgressParams +} + +type RenameFile struct { + Kind string `json:"kind"` + OldURI DocumentURI `json:"oldUri"` + NewURI DocumentURI `json:"newUri"` + Options RenameFileOptions `json:"options,omitempty"` + ResourceOperation +} + +type RenameClientCapabilities struct { + DynamicRegistration bool `json:"dynamicRegistration,omitempty"` + PrepareSupport bool `json:"prepareSupport,omitempty"` + PrepareSupportDefaultBehavior PrepareSupportDefaultBehavior `json:"prepareSupportDefaultBehavior,omitempty"` + HonorsChangeAnnotations bool `json:"honorsChangeAnnotations,omitempty"` +} + +type RenameFileOptions struct { + Overwrite bool `json:"overwrite,omitempty"` + IgnoreIfExists bool `json:"ignoreIfExists,omitempty"` +} + +type RenameFilesParams struct { + Files []FileRename `json:"files"` +} + +type RenameOptions struct { + PrepareProvider bool `json:"prepareProvider,omitempty"` + WorkDoneProgressOptions +} + +type FileRename struct { + OldURI string `json:"oldUri"` + NewURI string `json:"newUri"` +} + +type DocumentURI string +type PrepareSupportDefaultBehavior = interface{} + +type ResourceOperation struct { + Kind string `json:"kind"` + AnnotationID ChangeAnnotationIdentifier `json:"annotationId,omitempty"` +} + +type ChangeAnnotationIdentifier = string + +type WorkspaceEdit struct { + Changes map[string][]TextEdit `json:"changes,omitempty"` + DocumentChanges []TextDocumentEdit `json:"documentChanges,omitempty"` + ChangeAnnotations map[string]ChangeAnnotationIdentifier `json:"changeAnnotations,omitempty"` +} + +type TextDocumentEdit struct { + TextDocument OptionalVersionedTextDocumentIdentifier `json:"textDocument"` + Edits []TextEdit `json:"edits"` +} + +type OptionalVersionedTextDocumentIdentifier struct { + Version int32 `json:"version"` + TextDocumentIdentifier +} diff --git a/parser/parseutil/idenfier.go b/parser/parseutil/idenfier.go new file mode 100644 index 0000000..f8128b4 --- /dev/null +++ b/parser/parseutil/idenfier.go @@ -0,0 +1,25 @@ +package parseutil + +import ( + "github.com/lighttiger2505/sqls/ast" + "github.com/lighttiger2505/sqls/ast/astutil" + "github.com/lighttiger2505/sqls/token" +) + +func ExtractIdenfiers(parsed ast.TokenList, pos token.Pos) ([]ast.Node, error) { + stmt, err := extractFocusedStatement(parsed, pos) + if err != nil { + return nil, err + } + + identiferMatcher := astutil.NodeMatcher{ + NodeTypes: []ast.NodeType{ + ast.TypeIdentifer, + }, + } + return parsePrefix(astutil.NewNodeReader(stmt), identiferMatcher, parseIdentifer), nil +} + +func parseIdentifer(reader *astutil.NodeReader) []ast.Node { + return []ast.Node{reader.CurNode} +}