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..667f98bef 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 diff --git a/analysis/src/NewCompletions.ml b/analysis/src/NewCompletions.ml index bbec85cc9..97e81b5ed 100644 --- a/analysis/src/NewCompletions.ml +++ b/analysis/src/NewCompletions.ml @@ -773,7 +773,7 @@ let getItems ~full ~package ~rawOpens ~allFiles ~pos ~parts = in (* TODO complete the namespaced name too *) let localModuleNames = - allFiles + allFiles |> FileSet.elements |> Utils.filterMap (fun name -> if Utils.startsWith name suffix && not (String.contains name '-') then Some {(emptyDeclared name) with item = FileModule name} @@ -1148,7 +1148,9 @@ let computeCompletions ~uri ~textOpt ~pos = | Some full -> let rawOpens = PartialParser.findOpens text offset in let package = full.package in - let allFiles = package.projectFiles @ package.dependenciesFiles in + let allFiles = + FileSet.union package.projectFiles package.dependenciesFiles + in let findItems ~exact parts = let items = getItems ~full ~package ~rawOpens ~allFiles ~pos ~parts diff --git a/analysis/src/Packages.ml b/analysis/src/Packages.ml index d095503b7..7401d71f0 100644 --- a/analysis/src/Packages.ml +++ b/analysis/src/Packages.ml @@ -84,8 +84,12 @@ let newBsPackage rootPath = Log.log ("Opens from bsconfig: " ^ (opens |> String.concat " ")); { SharedTypes.rootPath; - projectFiles = projectFilesAndPaths |> List.map fst; - dependenciesFiles = dependenciesFilesAndPaths |> List.map fst; + projectFiles = + projectFilesAndPaths |> List.map fst + |> SharedTypes.FileSet.of_list; + dependenciesFiles = + dependenciesFilesAndPaths |> List.map fst + |> SharedTypes.FileSet.of_list; pathsForModule; opens; namespace; diff --git a/analysis/src/ProcessCmt.ml b/analysis/src/ProcessCmt.ml index e5b372827..e13e000bc 100644 --- a/analysis/src/ProcessCmt.ml +++ b/analysis/src/ProcessCmt.ml @@ -734,12 +734,12 @@ struct else [])) let addFileReference moduleName loc = - Hashtbl.replace extra.fileReferences moduleName - (loc - :: - (if Hashtbl.mem extra.fileReferences moduleName then - Hashtbl.find extra.fileReferences moduleName - else [])) + let newLocs = + match Hashtbl.find_opt extra.fileReferences moduleName with + | Some oldLocs -> LocationSet.add loc oldLocs + | None -> LocationSet.singleton loc + in + Hashtbl.replace extra.fileReferences moduleName newLocs let env = QueryEnv.fromFile Collector.file 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..d6738a551 100644 --- a/analysis/src/References.ml +++ b/analysis/src/References.ml @@ -393,7 +393,6 @@ let forLocalStamp ~full:{file; extra; package} stamp tip = match Hashtbl.find_opt extra.internalReferences localStamp with | None -> [] | Some local -> - maybeLog ("Checking externals: " ^ string_of_int stamp); let externals = match declaredForTip ~stamps:env.file.stamps stamp tip with | None -> [] @@ -426,7 +425,7 @@ let forLocalStamp ~full:{file; extra; package} stamp tip = maybeLog ("Now checking path " ^ pathToString path); let thisModuleName = file.moduleName in let externals = - package.projectFiles + package.projectFiles |> FileSet.elements |> List.filter (fun name -> name <> file.moduleName) |> Utils.filterMap (fun name -> match ProcessCmt.fileForModule ~package name with @@ -459,15 +458,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 |> FileSet.elements + |> 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 |> LocationSet.elements + |> 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..a5c25b1b6 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 @@ -194,10 +200,16 @@ type openTracker = { mutable used : (path * tip * Location.t) list; } +module LocationSet = Set.Make (struct + include Location + + let compare = compare (* polymorphic compare should be OK *) +end) + type extra = { internalReferences : (int, Location.t list) Hashtbl.t; externalReferences : (string, (path * tip * Location.t) list) Hashtbl.t; - fileReferences : (string, Location.t list) Hashtbl.t; + fileReferences : (string, LocationSet.t) Hashtbl.t; mutable locItems : locItem list; (* This is the "open location", like the location... or maybe the >> location of the open ident maybe *) @@ -207,10 +219,12 @@ type extra = { type file = string +module FileSet = Set.Make (String) + type package = { rootPath : filePath; - projectFiles : file list; - dependenciesFiles : file list; + projectFiles : FileSet.t; + dependenciesFiles : FileSet.t; pathsForModule : (file, paths) Hashtbl.t; namespace : string option; opens : string list; 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..d427b7d12 100644 --- a/analysis/tests/src/Cross.res +++ b/analysis/tests/src/Cross.res @@ -7,3 +7,13 @@ 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 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..57dc6b819 --- /dev/null +++ b/analysis/tests/src/ReferencesWithInterface.resi @@ -0,0 +1,5 @@ + + + +let x: int +// ^ref diff --git a/analysis/tests/src/expected/Cross.res.txt b/analysis/tests/src/expected/Cross.res.txt index 63f400742..82d8d20af 100644 --- a/analysis/tests/src/expected/Cross.res.txt +++ b/analysis/tests/src/expected/Cross.res.txt @@ -1,8 +1,19 @@ 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": 8, "character": 16}, "end": {"line": 8, "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": 3, "character": 16}, "end": {"line": 3, "character": 26}}}, +{"uri": "Cross.res", "range": {"start": {"line": 0, "character": 15}, "end": {"line": 0, "character": 25}}}, +{"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": 18, "character": 29}, "end": {"line": 18, "character": 52}}}, +{"uri": "Cross.res", "range": {"start": {"line": 16, "character": 26}, "end": {"line": 16, "character": 49}}}, +{"uri": "Cross.res", "range": {"start": {"line": 14, "character": 29}, "end": {"line": 14, "character": 52}}}, +{"uri": "Cross.res", "range": {"start": {"line": 11, "character": 28}, "end": {"line": 11, "character": 51}}}, +{"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}}} ] diff --git a/analysis/tests/src/expected/ReferencesWithInterface.res.txt b/analysis/tests/src/expected/ReferencesWithInterface.res.txt new file mode 100644 index 000000000..2372f686b --- /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": 3, "character": 4}, "end": {"line": 3, "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..5f96d5af6 --- /dev/null +++ b/analysis/tests/src/expected/ReferencesWithInterface.resi.txt @@ -0,0 +1,9 @@ +References tests/src/ReferencesWithInterface.resi 3: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": 3, "character": 4}, "end": {"line": 3, "character": 5}}} +] + diff --git a/server/src/server.ts b/server/src/server.ts index d6e72584b..81330412f 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"; @@ -294,16 +294,34 @@ function rename(msg: p.RequestMessage) { if (locations === null) { result = null; } else { - let changes: { [uri: string]: TextEdit[] } = {}; + let textEdits: { [uri: string]: TextEdit[] } = {}; + let documentChanges: (p.RenameFile | p.TextDocumentEdit)[] = []; + locations.forEach(({ uri, range }) => { - let textEdit: TextEdit = { range, newText: params.newName }; - if (uri in changes) { - changes[uri].push(textEdit); + if (utils.isRangeTopOfFile(range)) { + let filePath = fileURLToPath(uri); + let newFilePath = `${path.dirname(filePath)}/${params.newName}${path.extname(filePath)}`; + let newUri = pathToFileURL(newFilePath).href; + let rename: p.RenameFile = { kind: "rename", oldUri: uri, newUri }; + documentChanges.push(rename); } else { - changes[uri] = [textEdit]; + let textEdit: TextEdit = { range, newText: params.newName }; + if (uri in textEdits) { + textEdits[uri].push(textEdit); + } else { + textEdits[uri] = [textEdit]; + } } }); - result = { changes }; + + Object.entries(textEdits) + .forEach(([uri, edits]) => { + let textDocumentEdit = { textDocument: { uri, version: null }, edits }; + documentChanges.push(textDocumentEdit); + }); + + + result = { documentChanges }; } let response: m.ResponseMessage = { jsonrpc: c.jsonrpcVersion, diff --git a/server/src/utils.ts b/server/src/utils.ts index 2c6d236f9..b32fd80a6 100644 --- a/server/src/utils.ts +++ b/server/src/utils.ts @@ -476,3 +476,11 @@ export let parseCompilerLogOutput = ( return { done, result }; }; + +export let isRangeTopOfFile = (range: p.Range) => + [ + range.start.character, + range.start.line, + range.end.character, + range.end.line + ].every(n => n === 0);