diff --git a/analysis/src/Cli.ml b/analysis/src/Cli.ml index 6b9edc32b..ce982dafc 100644 --- a/analysis/src/Cli.ml +++ b/analysis/src/Cli.ml @@ -8,6 +8,7 @@ API examples: ./rescript-editor-analysis.exe documentSymbol src/Foo.res ./rescript-editor-analysis.exe hover src/MyFile.res 10 2 ./rescript-editor-analysis.exe references src/MyFile.res 10 2 + ./rescript-editor-analysis.exe rename src/MyFile.res 10 2 foo Dev-time examples: ./rescript-editor-analysis.exe dump src/MyFile.res src/MyFile2.res @@ -38,6 +39,10 @@ Options: ./rescript-editor-analysis.exe references src/MyFile.res 10 2 + rename: rename all appearances of item in MyFile.res at line 10 column 2 with foo: + + ./rescript-editor-analysis.exe rename src/MyFile.res 10 2 foo + dump: for debugging, show all definitions and hovers for MyFile.res and MyFile.res: ./rescript-editor-analysis.exe dump src/Foo.res src/MyFile.res @@ -50,8 +55,8 @@ Options: let main () = match Array.to_list Sys.argv with | [_; "completion"; path; line; col; currentFile] -> - Commands.completion ~path ~line:(int_of_string line) ~col:(int_of_string col) - ~currentFile + Commands.completion ~path ~line:(int_of_string line) + ~col:(int_of_string col) ~currentFile | [_; "definition"; path; line; col] -> Commands.definition ~path ~line:(int_of_string line) ~col:(int_of_string col) @@ -62,6 +67,9 @@ let main () = | [_; "references"; path; line; col] -> Commands.references ~path ~line:(int_of_string line) ~col:(int_of_string col) + | [_; "rename"; path; line; col; newName] -> + Commands.rename ~path ~line:(int_of_string line) ~col:(int_of_string col) + ~newName | [_; "test"; path] -> Commands.test ~path | args when List.mem "-h" args || List.mem "--help" args -> prerr_endline help | _ -> diff --git a/analysis/src/Commands.ml b/analysis/src/Commands.ml index d2e0dd544..417fd397e 100644 --- a/analysis/src/Commands.ml +++ b/analysis/src/Commands.ml @@ -176,6 +176,78 @@ let documentSymbol ~path = in print_endline ("[\n" ^ (allSymbols |> String.concat ",\n") ^ "\n]") +let rename ~path ~line ~col ~newName = + let uri = Uri2.fromPath path in + let result = + match ProcessCmt.getFullFromCmt ~uri with + | None -> Protocol.null + | Some full -> ( + let pos = Utils.protocolLineColToCmtLoc ~line ~col in + match References.locItemForPos ~full pos with + | None -> Protocol.null + | Some locItem -> + let allReferences = References.allReferencesForLocItem ~full locItem in + let referencesToToplevelModules, referencesToItems = + allReferences + |> List.fold_left + (fun acc (uri2, references) -> + (references |> List.map (fun loc -> (uri2, loc))) @ acc) + [] + |> List.partition (fun (_, loc) -> Utils.isTopLoc loc) + in + let fileRenames = + referencesToToplevelModules + |> List.map (fun (uri, _) -> + let path = Uri2.toPath uri in + let dir = Filename.dirname path in + let ext = Filename.extension path in + let sep = Filename.dir_sep in + let newPath = dir ^ sep ^ newName ^ ext in + let newUri = Uri2.fromPath newPath in + Protocol. + { + kind = `rename; + oldUri = uri |> Uri2.toString; + newUri = newUri |> Uri2.toString; + }) + in + let textDocumentEdits = + let module StringMap = Misc.StringMap in + let textEditsByUri = + referencesToItems + |> List.map (fun (uri, loc) -> (Uri2.toString uri, loc)) + |> List.fold_left + (fun acc (uri, loc) -> + let textEdit = + Protocol. + {range = Utils.cmtLocToRange loc; newText = newName} + in + match StringMap.find_opt uri acc with + | None -> StringMap.add uri [textEdit] acc + | Some prevEdits -> + StringMap.add uri (textEdit :: prevEdits) acc) + StringMap.empty + in + StringMap.fold + (fun uri edits acc -> + let textDocumentEdit = + Protocol.{textDocument = {uri; version = None}; edits} + in + textDocumentEdit :: acc) + textEditsByUri [] + in + let fileRenamesString = + fileRenames |> List.map Protocol.stringifyRenameFile + in + let textDocumentEditsString = + textDocumentEdits |> List.map Protocol.stringifyTextDocumentEdit + in + "[\n" + ^ (fileRenamesString @ textDocumentEditsString |> String.concat ",\n") + ^ "\n]") + in + print_endline result + let test ~path = Uri2.stripPath := true; match Files.readFile path with @@ -211,6 +283,15 @@ let test ~path = | "doc" -> print_endline ("DocumentSymbol " ^ path); documentSymbol ~path + | "ren" -> + let newName = String.sub rest 4 (len - mlen - 4) in + let () = + print_endline + ("Rename " ^ path ^ " " ^ string_of_int line ^ ":" + ^ string_of_int col ^ " " ^ newName) + in + + rename ~path ~line ~col ~newName | "com" -> print_endline ("Complete " ^ path ^ " " ^ string_of_int line ^ ":" diff --git a/analysis/src/Protocol.ml b/analysis/src/Protocol.ml index 021ca46b7..f93d9e090 100644 --- a/analysis/src/Protocol.ml +++ b/analysis/src/Protocol.ml @@ -18,6 +18,20 @@ type location = {uri : string; range : range} type documentSymbolItem = {name : string; kind : int; location : location} +type renameFile = {kind : [`rename]; oldUri : string; newUri : string} + +type textEdit = {range : range; newText : string} + +type optionalVersionedTextDocumentIdentifier = { + version : int option; + uri : string; +} + +type textDocumentEdit = { + textDocument : optionalVersionedTextDocumentIdentifier; + edits : textEdit list; +} + let null = "null" let array l = "[" ^ String.concat ", " l ^ "]" @@ -52,7 +66,7 @@ let stringifyCompletionItem c = let stringifyHover h = Printf.sprintf {|{"contents": "%s"}|} (Json.escape h.contents) -let stringifyLocation h = +let stringifyLocation (h : location) = Printf.sprintf {|{"uri": "%s", "range": %s}|} (Json.escape h.uri) (stringifyRange h.range) @@ -65,3 +79,35 @@ let stringifyDocumentSymbolItem i = }|} (Json.escape i.name) i.kind (stringifyLocation i.location) + +let stringifyRenameFile rf = + Printf.sprintf {|{ + "kind": "%s", + "oldUri": "%s", + "newUri": "%s" +}|} + (match rf.kind with `rename -> "rename") + (Json.escape rf.oldUri) (Json.escape rf.newUri) + +let stringifyTextEdit te = + Printf.sprintf {|{ + "range": %s, + "newText": "%s" + }|} + (stringifyRange te.range) (Json.escape te.newText) + +let stringifyoptionalVersionedTextDocumentIdentifier td = + Printf.sprintf {|{ + "version": %s, + "uri": "%s" + }|} + (match td.version with None -> null | Some v -> string_of_int v) + (Json.escape td.uri) + +let stringifyTextDocumentEdit tde = + Printf.sprintf {|{ + "textDocument": %s, + "edits": %s + }|} + (stringifyoptionalVersionedTextDocumentIdentifier tde.textDocument) + (tde.edits |> List.map stringifyTextEdit |> array) diff --git a/analysis/src/References.ml b/analysis/src/References.ml index 3da82433e..9dcc16aa7 100644 --- a/analysis/src/References.ml +++ b/analysis/src/References.ml @@ -192,7 +192,7 @@ let alternateDeclared ~(file : File.t) ~package declared tip = maybeLog "Have both!!"; let resiUri = Uri2.fromPath resi in let resUri = Uri2.fromPath res in - if resiUri = file.uri then + if Uri2.isInterface file.uri then match ProcessCmt.getFullFromCmt ~uri:resUri with | None -> None | Some {file; extra} -> ( @@ -459,15 +459,29 @@ let forLocalStamp ~full:{file; extra; package} stamp tip = let allReferencesForLocItem ~full:({file; package} as full) locItem = match locItem.locType with | TopLevelModule moduleName -> - let locs = - match Hashtbl.find_opt full.extra.fileReferences moduleName with + let otherModulesReferences = + package.projectFiles + |> Utils.filterMap (fun name -> + match ProcessCmt.fileForModule ~package name with + | None -> None + | Some file -> ProcessCmt.getFullFromCmt ~uri:file.uri) + |> List.map (fun full -> + match Hashtbl.find_opt full.extra.fileReferences moduleName with + | None -> [] + | Some locs -> + locs + |> List.map (fun loc -> + (Uri2.fromPath loc.Location.loc_start.pos_fname, [loc]))) + |> List.flatten + in + let targetModuleReferences = + match Hashtbl.find_opt package.pathsForModule moduleName with | None -> [] - | Some locs -> - locs - |> List.map (fun loc -> - (Uri2.fromPath loc.Location.loc_start.pos_fname, [loc])) + | Some paths -> + let moduleSrcToRef src = (Uri2.fromPath src, [Utils.topLoc src]) in + getSrc paths |> List.map moduleSrcToRef in - locs + List.append targetModuleReferences otherModulesReferences | Typed (_, _, NotFound) | LModule NotFound | Constant _ -> [] | TypeDefinition (_, _, stamp) -> forLocalStamp ~full stamp Type | Typed (_, _, (LocalReference (stamp, tip) | Definition (stamp, tip))) diff --git a/analysis/src/SharedTypes.ml b/analysis/src/SharedTypes.ml index ca5ebeddf..948996527 100644 --- a/analysis/src/SharedTypes.ml +++ b/analysis/src/SharedTypes.ml @@ -130,6 +130,12 @@ let showPaths paths = | IntfAndImpl {cmti; resi; cmt; res} -> Printf.sprintf "IntfAndImpl(%s, %s, %s, %s)" cmti resi cmt res +let getSrc p = + match p with + | Impl {res} -> [res] + | Namespace _ -> [] + | IntfAndImpl {resi; res} -> [resi; res] + let getUri p = match p with | Impl {res} -> Uri2.fromPath res diff --git a/analysis/src/Utils.ml b/analysis/src/Utils.ml index d56632619..767d8714d 100644 --- a/analysis/src/Utils.ml +++ b/analysis/src/Utils.ml @@ -7,6 +7,12 @@ let topLoc fname = loc_ghost = false; } +let isTopLoc (loc : Warnings.loc) = + let isTopPos (pos : Lexing.position) = + pos.pos_lnum = 1 && pos.pos_bol = 0 && pos.pos_cnum = 0 + in + isTopPos loc.loc_start && isTopPos loc.loc_end && loc.loc_ghost = false + (** * `startsWith(string, prefix)` * true if the string starts with the prefix diff --git a/analysis/tests/src/Cross.res b/analysis/tests/src/Cross.res index 489f5ae0b..c4fc51baf 100644 --- a/analysis/tests/src/Cross.res +++ b/analysis/tests/src/Cross.res @@ -7,3 +7,20 @@ let crossRef2 = References.x module Ref = References let crossRef3 = References.x + + +let crossRefWithInterface = ReferencesWithInterface.x +// ^ref + +let crossRefWithInterface2 = ReferencesWithInterface.x + +module RefWithInterface = ReferencesWithInterface + +let crossRefWithInterface3 = ReferencesWithInterface.x + + +let _ = RenameWithInterface.x +// ^ren RenameWithInterfacePrime + +let _ = RenameWithInterface.x +// ^ren xPrime diff --git a/analysis/tests/src/ReferencesWithInterface.res b/analysis/tests/src/ReferencesWithInterface.res new file mode 100644 index 000000000..54b4b8b16 --- /dev/null +++ b/analysis/tests/src/ReferencesWithInterface.res @@ -0,0 +1,2 @@ +let x = 2 +// ^ref diff --git a/analysis/tests/src/ReferencesWithInterface.resi b/analysis/tests/src/ReferencesWithInterface.resi new file mode 100644 index 000000000..765cf3c6d --- /dev/null +++ b/analysis/tests/src/ReferencesWithInterface.resi @@ -0,0 +1,2 @@ +let x: int +// ^ref diff --git a/analysis/tests/src/Rename.res b/analysis/tests/src/Rename.res new file mode 100644 index 000000000..b9e3270de --- /dev/null +++ b/analysis/tests/src/Rename.res @@ -0,0 +1,11 @@ +let x = 12 +// ^ren y + +let a = x + +let b = a + +let c = x + +let foo = (~xx) => xx + 1 +// ^ren yy diff --git a/analysis/tests/src/RenameWithInterface.res b/analysis/tests/src/RenameWithInterface.res new file mode 100644 index 000000000..5c68741c7 --- /dev/null +++ b/analysis/tests/src/RenameWithInterface.res @@ -0,0 +1,2 @@ +let x = 2 +// ^ren y diff --git a/analysis/tests/src/RenameWithInterface.resi b/analysis/tests/src/RenameWithInterface.resi new file mode 100644 index 000000000..8657edc21 --- /dev/null +++ b/analysis/tests/src/RenameWithInterface.resi @@ -0,0 +1,2 @@ +let x: int +// ^ren y diff --git a/analysis/tests/src/expected/Cross.res.txt b/analysis/tests/src/expected/Cross.res.txt index 63f400742..2eedb9c62 100644 --- a/analysis/tests/src/expected/Cross.res.txt +++ b/analysis/tests/src/expected/Cross.res.txt @@ -3,6 +3,81 @@ References tests/src/Cross.res 0:17 {"uri": "Cross.res", "range": {"start": {"line": 0, "character": 15}, "end": {"line": 0, "character": 25}}}, {"uri": "Cross.res", "range": {"start": {"line": 3, "character": 16}, "end": {"line": 3, "character": 26}}}, {"uri": "Cross.res", "range": {"start": {"line": 6, "character": 13}, "end": {"line": 6, "character": 23}}}, -{"uri": "Cross.res", "range": {"start": {"line": 8, "character": 16}, "end": {"line": 8, "character": 26}}} +{"uri": "Cross.res", "range": {"start": {"line": 8, "character": 16}, "end": {"line": 8, "character": 26}}}, +{"uri": "References.res", "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}} +] + +References tests/src/Cross.res 11:31 +[ +{"uri": "Cross.res", "range": {"start": {"line": 11, "character": 28}, "end": {"line": 11, "character": 51}}}, +{"uri": "Cross.res", "range": {"start": {"line": 14, "character": 29}, "end": {"line": 14, "character": 52}}}, +{"uri": "Cross.res", "range": {"start": {"line": 16, "character": 26}, "end": {"line": 16, "character": 49}}}, +{"uri": "Cross.res", "range": {"start": {"line": 18, "character": 29}, "end": {"line": 18, "character": 52}}}, +{"uri": "ReferencesWithInterface.res", "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}}, +{"uri": "ReferencesWithInterface.resi", "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}} +] + +Rename tests/src/Cross.res 21:13 RenameWithInterfacePrime +[ +{ + "kind": "rename", + "oldUri": "RenameWithInterface.res", + "newUri": "RenameWithInterfacePrime.res" +}, +{ + "kind": "rename", + "oldUri": "RenameWithInterface.resi", + "newUri": "RenameWithInterfacePrime.resi" +}, +{ + "textDocument": { + "version": null, + "uri": "Cross.res" + }, + "edits": [{ + "range": {"start": {"line": 24, "character": 8}, "end": {"line": 24, "character": 27}}, + "newText": "RenameWithInterfacePrime" + }, { + "range": {"start": {"line": 21, "character": 8}, "end": {"line": 21, "character": 27}}, + "newText": "RenameWithInterfacePrime" + }] + } +] + +Rename tests/src/Cross.res 24:28 xPrime +[ +{ + "textDocument": { + "version": null, + "uri": "RenameWithInterface.resi" + }, + "edits": [{ + "range": {"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 5}}, + "newText": "xPrime" + }] + }, +{ + "textDocument": { + "version": null, + "uri": "RenameWithInterface.res" + }, + "edits": [{ + "range": {"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 5}}, + "newText": "xPrime" + }] + }, +{ + "textDocument": { + "version": null, + "uri": "Cross.res" + }, + "edits": [{ + "range": {"start": {"line": 21, "character": 28}, "end": {"line": 21, "character": 29}}, + "newText": "xPrime" + }, { + "range": {"start": {"line": 24, "character": 28}, "end": {"line": 24, "character": 29}}, + "newText": "xPrime" + }] + } ] diff --git a/analysis/tests/src/expected/ReferencesWithInterface.res.txt b/analysis/tests/src/expected/ReferencesWithInterface.res.txt new file mode 100644 index 000000000..2a077a83f --- /dev/null +++ b/analysis/tests/src/expected/ReferencesWithInterface.res.txt @@ -0,0 +1,9 @@ +References tests/src/ReferencesWithInterface.res 0:4 +[ +{"uri": "Cross.res", "range": {"start": {"line": 18, "character": 53}, "end": {"line": 18, "character": 54}}}, +{"uri": "Cross.res", "range": {"start": {"line": 14, "character": 53}, "end": {"line": 14, "character": 54}}}, +{"uri": "Cross.res", "range": {"start": {"line": 11, "character": 52}, "end": {"line": 11, "character": 53}}}, +{"uri": "ReferencesWithInterface.resi", "range": {"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 5}}}, +{"uri": "ReferencesWithInterface.res", "range": {"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 5}}} +] + diff --git a/analysis/tests/src/expected/ReferencesWithInterface.resi.txt b/analysis/tests/src/expected/ReferencesWithInterface.resi.txt new file mode 100644 index 000000000..e7f7db246 --- /dev/null +++ b/analysis/tests/src/expected/ReferencesWithInterface.resi.txt @@ -0,0 +1,9 @@ +References tests/src/ReferencesWithInterface.resi 0:4 +[ +{"uri": "Cross.res", "range": {"start": {"line": 18, "character": 53}, "end": {"line": 18, "character": 54}}}, +{"uri": "Cross.res", "range": {"start": {"line": 14, "character": 53}, "end": {"line": 14, "character": 54}}}, +{"uri": "Cross.res", "range": {"start": {"line": 11, "character": 52}, "end": {"line": 11, "character": 53}}}, +{"uri": "ReferencesWithInterface.res", "range": {"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 5}}}, +{"uri": "ReferencesWithInterface.resi", "range": {"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 5}}} +] + diff --git a/analysis/tests/src/expected/Rename.res.txt b/analysis/tests/src/expected/Rename.res.txt new file mode 100644 index 000000000..49cb75877 --- /dev/null +++ b/analysis/tests/src/expected/Rename.res.txt @@ -0,0 +1,37 @@ +Rename tests/src/Rename.res 0:4 y +[ +{ + "textDocument": { + "version": null, + "uri": "Rename.res" + }, + "edits": [{ + "range": {"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 5}}, + "newText": "y" + }, { + "range": {"start": {"line": 3, "character": 8}, "end": {"line": 3, "character": 9}}, + "newText": "y" + }, { + "range": {"start": {"line": 7, "character": 8}, "end": {"line": 7, "character": 9}}, + "newText": "y" + }] + } +] + +Rename tests/src/Rename.res 9:19 yy +[ +{ + "textDocument": { + "version": null, + "uri": "Rename.res" + }, + "edits": [{ + "range": {"start": {"line": 9, "character": 12}, "end": {"line": 9, "character": 14}}, + "newText": "yy" + }, { + "range": {"start": {"line": 9, "character": 19}, "end": {"line": 9, "character": 21}}, + "newText": "yy" + }] + } +] + diff --git a/analysis/tests/src/expected/RenameWithInterface.res.txt b/analysis/tests/src/expected/RenameWithInterface.res.txt new file mode 100644 index 000000000..8730f2535 --- /dev/null +++ b/analysis/tests/src/expected/RenameWithInterface.res.txt @@ -0,0 +1,37 @@ +Rename tests/src/RenameWithInterface.res 0:4 y +[ +{ + "textDocument": { + "version": null, + "uri": "RenameWithInterface.resi" + }, + "edits": [{ + "range": {"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 5}}, + "newText": "y" + }] + }, +{ + "textDocument": { + "version": null, + "uri": "RenameWithInterface.res" + }, + "edits": [{ + "range": {"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 5}}, + "newText": "y" + }] + }, +{ + "textDocument": { + "version": null, + "uri": "Cross.res" + }, + "edits": [{ + "range": {"start": {"line": 21, "character": 28}, "end": {"line": 21, "character": 29}}, + "newText": "y" + }, { + "range": {"start": {"line": 24, "character": 28}, "end": {"line": 24, "character": 29}}, + "newText": "y" + }] + } +] + diff --git a/analysis/tests/src/expected/RenameWithInterface.resi.txt b/analysis/tests/src/expected/RenameWithInterface.resi.txt new file mode 100644 index 000000000..2524b6a43 --- /dev/null +++ b/analysis/tests/src/expected/RenameWithInterface.resi.txt @@ -0,0 +1,37 @@ +Rename tests/src/RenameWithInterface.resi 0:4 y +[ +{ + "textDocument": { + "version": null, + "uri": "RenameWithInterface.resi" + }, + "edits": [{ + "range": {"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 5}}, + "newText": "y" + }] + }, +{ + "textDocument": { + "version": null, + "uri": "RenameWithInterface.res" + }, + "edits": [{ + "range": {"start": {"line": 0, "character": 4}, "end": {"line": 0, "character": 5}}, + "newText": "y" + }] + }, +{ + "textDocument": { + "version": null, + "uri": "Cross.res" + }, + "edits": [{ + "range": {"start": {"line": 21, "character": 28}, "end": {"line": 21, "character": 29}}, + "newText": "y" + }, { + "range": {"start": {"line": 24, "character": 28}, "end": {"line": 24, "character": 29}}, + "newText": "y" + }] + } +] + diff --git a/server/src/server.ts b/server/src/server.ts index d6e72584b..b0e8e930e 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -15,7 +15,7 @@ import * as utils from "./utils"; import * as c from "./constants"; import * as chokidar from "chokidar"; import { assert } from "console"; -import { fileURLToPath } from "url"; +import { fileURLToPath, pathToFileURL } from "url"; import { ChildProcess } from "child_process"; import { WorkspaceEdit } from "vscode-languageserver"; import { TextEdit } from "vscode-languageserver-types"; @@ -286,30 +286,28 @@ function rename(msg: p.RequestMessage) { // https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_rename let params = msg.params as p.RenameParams; let filePath = fileURLToPath(params.textDocument.uri); - let locations: p.Location[] | null = utils.getReferencesForPosition( + let documentChanges: + | (p.RenameFile | p.TextDocumentEdit)[] + | null = utils.runAnalysisAfterSanityCheck(filePath, [ + "rename", filePath, - params.position - ); - let result: WorkspaceEdit | null; - if (locations === null) { - result = null; - } else { - let changes: { [uri: string]: TextEdit[] } = {}; - locations.forEach(({ uri, range }) => { - let textEdit: TextEdit = { range, newText: params.newName }; - if (uri in changes) { - changes[uri].push(textEdit); - } else { - changes[uri] = [textEdit]; - } - }); - result = { changes }; + params.position.line, + params.position.character, + params.newName + ]); + + let result: WorkspaceEdit | null = null; + + if (documentChanges !== null) { + result = { documentChanges }; } + let response: m.ResponseMessage = { jsonrpc: c.jsonrpcVersion, id: msg.id, - result, + result }; + return response; }