diff --git a/analysis/src/Cli.ml b/analysis/src/Cli.ml index 3a9a5fbb7..0b8985db7 100644 --- a/analysis/src/Cli.ml +++ b/analysis/src/Cli.ml @@ -80,6 +80,9 @@ let main () = | [_; "documentSymbol"; path] -> DocumentSymbol.command ~path | [_; "hover"; path; line; col] -> Commands.hover ~path ~line:(int_of_string line) ~col:(int_of_string col) + | [_; "codeAction"; path; line; col; currentFile] -> + Commands.codeAction ~path ~line:(int_of_string line) + ~col:(int_of_string col) ~currentFile | [_; "references"; path; line; col] -> Commands.references ~path ~line:(int_of_string line) ~col:(int_of_string col) diff --git a/analysis/src/CodeActions.ml b/analysis/src/CodeActions.ml new file mode 100644 index 000000000..844e25bce --- /dev/null +++ b/analysis/src/CodeActions.ml @@ -0,0 +1,17 @@ +(* This is the return that's expected when resolving code actions *) +type result = Protocol.codeAction list + +let stringifyCodeActions codeActions = + Printf.sprintf {|%s|} + (codeActions |> List.map Protocol.stringifyCodeAction |> Protocol.array) + +let make ~title ~kind ~uri ~newText ~range = + { + Protocol.title; + codeActionKind = kind; + edit = + { + documentChanges = + [{textDocument = {version = None; uri}; edits = [{newText; range}]}]; + }; + } diff --git a/analysis/src/Commands.ml b/analysis/src/Commands.ml index cccfd6f5c..13b9fd645 100644 --- a/analysis/src/Commands.ml +++ b/analysis/src/Commands.ml @@ -63,6 +63,10 @@ let hover ~path ~line ~col = in print_endline result +let codeAction ~path ~line ~col ~currentFile = + Xform.extractCodeActions ~path ~pos:(line, col) ~currentFile + |> CodeActions.stringifyCodeActions |> print_endline + let definition ~path ~line ~col = let locationOpt = match Cmt.fromPath ~path with @@ -325,6 +329,26 @@ let test ~path = dir ++ parent_dir_name ++ "lib" ++ "bs" ++ "src" ++ name in Printf.printf "%s" (CreateInterface.command ~path ~cmiFile) + | "xfm" -> + print_endline + ("Xform " ^ path ^ " " ^ string_of_int line ^ ":" + ^ string_of_int col); + let codeActions = + Xform.extractCodeActions ~path ~pos:(line, col) ~currentFile:path + in + codeActions + |> List.iter (fun {Protocol.title; edit = {documentChanges}} -> + Printf.printf "Hit: %s\n" title; + documentChanges + |> List.iter (fun {Protocol.edits} -> + edits + |> List.iter (fun {Protocol.range; newText} -> + let indent = + String.make range.start.character ' ' + in + Printf.printf "%s\nnewText:\n%s<--here\n%s%s\n" + (Protocol.stringifyRange range) + indent indent newText))) | _ -> ()); print_newline ()) in diff --git a/analysis/src/Protocol.ml b/analysis/src/Protocol.ml index 4927fd6dd..218d77775 100644 --- a/analysis/src/Protocol.ml +++ b/analysis/src/Protocol.ml @@ -1,7 +1,5 @@ type position = {line : int; character : int} - type range = {start : position; end_ : position} - type markupContent = {kind : string; value : string} type completionItem = { @@ -13,13 +11,9 @@ type completionItem = { } type hover = {contents : string} - type location = {uri : string; range : range} - type documentSymbolItem = {name : string; kind : int; location : location} - type renameFile = {oldUri : string; newUri : string} - type textEdit = {range : range; newText : string} type optionalVersionedTextDocumentIdentifier = { @@ -32,8 +26,16 @@ type textDocumentEdit = { edits : textEdit list; } -let null = "null" +type codeActionEdit = {documentChanges : textDocumentEdit list} +type codeActionKind = RefactorRewrite +type codeAction = { + title : string; + codeActionKind : codeActionKind; + edit : codeActionEdit; +} + +let null = "null" let array l = "[" ^ String.concat ", " l ^ "]" let stringifyPosition p = @@ -110,3 +112,15 @@ let stringifyTextDocumentEdit tde = }|} (stringifyoptionalVersionedTextDocumentIdentifier tde.textDocument) (tde.edits |> List.map stringifyTextEdit |> array) + +let codeActionKindToString kind = + match kind with RefactorRewrite -> "refactor.rewrite" + +let stringifyCodeActionEdit cae = + Printf.sprintf {|{"documentChanges": %s}|} + (cae.documentChanges |> List.map stringifyTextDocumentEdit |> array) + +let stringifyCodeAction ca = + Printf.sprintf {|{"title": "%s", "kind": "%s", "edit": %s}|} ca.title + (codeActionKindToString ca.codeActionKind) + (ca.edit |> stringifyCodeActionEdit) diff --git a/analysis/src/Xform.ml b/analysis/src/Xform.ml new file mode 100644 index 000000000..c6b290453 --- /dev/null +++ b/analysis/src/Xform.ml @@ -0,0 +1,312 @@ +(** Code transformations using the parser/printer and ast operations *) + +let isBracedExpr = Res_parsetree_viewer.isBracedExpr + +let mkPosition (pos : Pos.t) = + let line, character = pos in + {Protocol.line; character} + +let rangeOfLoc (loc : Location.t) = + let start = loc |> Loc.start |> mkPosition in + let end_ = loc |> Loc.end_ |> mkPosition in + {Protocol.start; end_} + +module IfThenElse = struct + (* Convert if-then-else to switch *) + + let rec listToPat ~itemToPat = function + | [] -> Some [] + | x :: xList -> ( + match (itemToPat x, listToPat ~itemToPat xList) with + | Some p, Some pList -> Some (p :: pList) + | _ -> None) + + let rec expToPat (exp : Parsetree.expression) = + let mkPat ppat_desc = + Ast_helper.Pat.mk ~loc:exp.pexp_loc ~attrs:exp.pexp_attributes ppat_desc + in + match exp.pexp_desc with + | Pexp_construct (lid, None) -> Some (mkPat (Ppat_construct (lid, None))) + | Pexp_construct (lid, Some e1) -> ( + match expToPat e1 with + | None -> None + | Some p1 -> Some (mkPat (Ppat_construct (lid, Some p1)))) + | Pexp_variant (label, None) -> Some (mkPat (Ppat_variant (label, None))) + | Pexp_variant (label, Some e1) -> ( + match expToPat e1 with + | None -> None + | Some p1 -> Some (mkPat (Ppat_variant (label, Some p1)))) + | Pexp_constant c -> Some (mkPat (Ppat_constant c)) + | Pexp_tuple eList -> ( + match listToPat ~itemToPat:expToPat eList with + | None -> None + | Some patList -> Some (mkPat (Ppat_tuple patList))) + | Pexp_record (items, None) -> ( + let itemToPat (x, e) = + match expToPat e with None -> None | Some p -> Some (x, p) + in + match listToPat ~itemToPat items with + | None -> None + | Some patItems -> Some (mkPat (Ppat_record (patItems, Closed)))) + | Pexp_record (_, Some _) -> None + | _ -> None + + let mkIterator ~pos ~changed = + let expr (iterator : Ast_iterator.iterator) (e : Parsetree.expression) = + let newExp = + match e.pexp_desc with + | Pexp_ifthenelse + ( { + pexp_desc = + Pexp_apply + ( { + pexp_desc = + Pexp_ident {txt = Lident (("=" | "<>") as op)}; + }, + [(Nolabel, arg1); (Nolabel, arg2)] ); + }, + e1, + Some e2 ) + when Loc.hasPos ~pos e.pexp_loc -> ( + let e1, e2 = if op = "=" then (e1, e2) else (e2, e1) in + let mkMatch ~arg ~pat = + let cases = + [ + Ast_helper.Exp.case pat e1; + Ast_helper.Exp.case (Ast_helper.Pat.any ()) e2; + ] + in + Ast_helper.Exp.match_ ~loc:e.pexp_loc ~attrs:e.pexp_attributes arg + cases + in + + match expToPat arg2 with + | None -> ( + match expToPat arg1 with + | None -> None + | Some pat1 -> + let newExp = mkMatch ~arg:arg2 ~pat:pat1 in + Some newExp) + | Some pat2 -> + let newExp = mkMatch ~arg:arg1 ~pat:pat2 in + Some newExp) + | _ -> None + in + match newExp with + | Some newExp -> changed := Some newExp + | None -> Ast_iterator.default_iterator.expr iterator e + in + + {Ast_iterator.default_iterator with expr} + + let xform ~pos ~codeActions ~printExpr ~path structure = + let changed = ref None in + let iterator = mkIterator ~pos ~changed in + iterator.structure iterator structure; + match !changed with + | None -> () + | Some newExpr -> + let range = rangeOfLoc newExpr.pexp_loc in + let newText = printExpr ~range newExpr in + let codeAction = + CodeActions.make ~title:"Replace with switch" ~kind:RefactorRewrite + ~uri:path ~newText ~range + in + codeActions := codeAction :: !codeActions +end + +module AddBracesToFn = struct + (* Add braces to fn without braces *) + + let mkIterator ~pos ~changed = + (* While iterating the AST, keep info on which structure item we are in. + Printing from the structure item, rather than the body of the function, + gives better local pretty printing *) + let currentStructureItem = ref None in + + let structure_item (iterator : Ast_iterator.iterator) + (item : Parsetree.structure_item) = + let saved = !currentStructureItem in + currentStructureItem := Some item; + Ast_iterator.default_iterator.structure_item iterator item; + currentStructureItem := saved + in + let expr (iterator : Ast_iterator.iterator) (e : Parsetree.expression) = + let bracesAttribute = + let loc = + { + Location.none with + loc_start = Lexing.dummy_pos; + loc_end = + { + Lexing.dummy_pos with + pos_lnum = Lexing.dummy_pos.pos_lnum + 1 (* force line break *); + }; + } + in + (Location.mkloc "ns.braces" loc, Parsetree.PStr []) + in + let isFunction = function + | {Parsetree.pexp_desc = Pexp_fun _} -> true + | _ -> false + in + (match e.pexp_desc with + | Pexp_fun (_, _, _, bodyExpr) + when Loc.hasPos ~pos bodyExpr.pexp_loc + && isBracedExpr bodyExpr = false + && isFunction bodyExpr = false -> + bodyExpr.pexp_attributes <- bracesAttribute :: bodyExpr.pexp_attributes; + changed := !currentStructureItem + | _ -> ()); + Ast_iterator.default_iterator.expr iterator e + in + + {Ast_iterator.default_iterator with expr; structure_item} + + let xform ~pos ~codeActions ~path ~printStructureItem structure = + let changed = ref None in + let iterator = mkIterator ~pos ~changed in + iterator.structure iterator structure; + match !changed with + | None -> () + | Some newStructureItem -> + let range = rangeOfLoc newStructureItem.pstr_loc in + let newText = printStructureItem ~range newStructureItem in + let codeAction = + CodeActions.make ~title:"Add braces to function" ~kind:RefactorRewrite + ~uri:path ~newText ~range + in + codeActions := codeAction :: !codeActions +end + +module AddTypeAnnotation = struct + (* Add type annotation to value declaration *) + + type annotation = Plain | WithParens + + let mkIterator ~pos ~result = + let processPattern ?(isUnlabeledOnlyArg = false) (pat : Parsetree.pattern) = + match pat.ppat_desc with + | Ppat_var {loc} when Loc.hasPos ~pos loc -> + result := Some (if isUnlabeledOnlyArg then WithParens else Plain) + | _ -> () + in + let rec processFunction ~argNum (e : Parsetree.expression) = + match e.pexp_desc with + | Pexp_fun (argLabel, _, pat, e) -> + let isUnlabeledOnlyArg = + argNum = 1 && argLabel = Nolabel + && match e.pexp_desc with Pexp_fun _ -> false | _ -> true + in + processPattern ~isUnlabeledOnlyArg pat; + processFunction ~argNum:(argNum + 1) e + | _ -> () + in + let structure_item (iterator : Ast_iterator.iterator) + (si : Parsetree.structure_item) = + match si.pstr_desc with + | Pstr_value (_recFlag, bindings) -> + let processBinding (vb : Parsetree.value_binding) = + let isReactComponent = + (* Can't add a type annotation to a react component, or the compiler crashes *) + vb.pvb_attributes + |> List.exists (function + | {Location.txt = "react.component"}, _payload -> true + | _ -> false) + in + if not isReactComponent then processPattern vb.pvb_pat; + processFunction vb.pvb_expr + in + bindings |> List.iter (processBinding ~argNum:1); + Ast_iterator.default_iterator.structure_item iterator si + | _ -> Ast_iterator.default_iterator.structure_item iterator si + in + {Ast_iterator.default_iterator with structure_item} + + let xform ~path ~pos ~full ~structure ~codeActions = + let line, col = pos in + + let result = ref None in + let iterator = mkIterator ~pos ~result in + iterator.structure iterator structure; + match !result with + | None -> () + | Some annotation -> ( + match References.getLocItem ~full ~line ~col with + | None -> () + | Some locItem -> ( + match locItem.locType with + | Typed (name, typ, _) -> + let range, newText = + match annotation with + | Plain -> + ( rangeOfLoc {locItem.loc with loc_start = locItem.loc.loc_end}, + ": " ^ (typ |> Shared.typeToString) ) + | WithParens -> + ( rangeOfLoc locItem.loc, + "(" ^ name ^ ": " ^ (typ |> Shared.typeToString) ^ ")" ) + in + let codeAction = + CodeActions.make ~title:"Add type annotation" ~kind:RefactorRewrite + ~uri:path ~newText ~range + in + codeActions := codeAction :: !codeActions + | _ -> ())) +end + +let indent n text = + let spaces = String.make n ' ' in + let len = String.length text in + let text = + if len != 0 && text.[len - 1] = '\n' then String.sub text 0 (len - 1) + else text + in + let lines = String.split_on_char '\n' text in + match lines with + | [] -> "" + | [line] -> line + | line :: lines -> + line ^ "\n" + ^ (lines |> List.map (fun line -> spaces ^ line) |> String.concat "\n") + +let parse ~filename = + let {Res_driver.parsetree = structure; comments} = + Res_driver.parsingEngine.parseImplementation ~forPrinter:false ~filename + in + let filterComments ~loc comments = + (* Relevant comments in the range of the expression *) + let filter comment = + Loc.hasPos ~pos:(Loc.start (Res_comment.loc comment)) loc + in + comments |> List.filter filter + in + let printExpr ~(range : Protocol.range) (expr : Parsetree.expression) = + let structure = [Ast_helper.Str.eval ~loc:expr.pexp_loc expr] in + structure + |> Res_printer.printImplementation ~width:!Res_cli.ResClflags.width + ~comments:(comments |> filterComments ~loc:expr.pexp_loc) + |> indent range.start.character + in + let printStructureItem ~(range : Protocol.range) + (item : Parsetree.structure_item) = + let structure = [item] in + structure + |> Res_printer.printImplementation ~width:!Res_cli.ResClflags.width + ~comments:(comments |> filterComments ~loc:item.pstr_loc) + |> indent range.start.character + in + (structure, printExpr, printStructureItem) + +let extractCodeActions ~path ~pos ~currentFile = + let fullOpt = Cmt.fromPath ~path in + match fullOpt with + | Some full when Filename.check_suffix currentFile ".res" -> + let structure, printExpr, printStructureItem = + parse ~filename:currentFile + in + let codeActions = ref [] in + AddTypeAnnotation.xform ~path ~pos ~full ~structure ~codeActions; + IfThenElse.xform ~pos ~codeActions ~printExpr ~path structure; + AddBracesToFn.xform ~pos ~codeActions ~path ~printStructureItem structure; + !codeActions + | _ -> [] diff --git a/analysis/src/vendor/compiler-libs-406/parsetree.mli b/analysis/src/vendor/compiler-libs-406/parsetree.mli index b63e31408..40edf2a6e 100644 --- a/analysis/src/vendor/compiler-libs-406/parsetree.mli +++ b/analysis/src/vendor/compiler-libs-406/parsetree.mli @@ -233,6 +233,7 @@ and expression = { pexp_desc: expression_desc; pexp_loc: Location.t; + mutable (* Careful, the original parse tree is not mutable *) pexp_attributes: attributes; (* ... [@id1] [@id2] *) } diff --git a/analysis/tests/src/Xform.res b/analysis/tests/src/Xform.res new file mode 100644 index 000000000..52baad9d8 --- /dev/null +++ b/analysis/tests/src/Xform.res @@ -0,0 +1,67 @@ +type kind = First | Second | Third +type r = {name: string, age: int} + +let ret = _ => assert false +let kind = assert false + +if kind == First { + // ^xfm + ret("First") +} else { + ret("Not First") +} + +#kind("First", {name: "abc", age: 3}) != kind ? ret("Not First") : ret("First") +// ^xfm + +let name = "hello" +// ^xfm + +let annotated: int = 34 +// ^xfm + +module T = { + type r = {a: int, x: string} +} + +let foo = x => + // ^xfm + switch x { + | None => 33 + | Some(q) => q.T.a + 1 + // ^xfm + } + +let withAs = (~x as name) => name + 1 +// ^xfm + +@react.component +let make = (~name) => React.string(name) +// ^xfm + +let _ = (~x) => x + 1 +// ^xfm + +// +// Add braces to the body of a function +// + +let noBraces = () => name +// ^xfm + +let nested = () => { + let _noBraces = (_x, _y, _z) => "someNewFunc" + // ^xfm +} + +let bar = () => { + module Inner = { + let foo = (_x, y, _z) => + switch y { + | #some => 3 + | #stuff => 4 + } + //^xfm + } + Inner.foo(1) +} diff --git a/analysis/tests/src/expected/Debug.res.txt b/analysis/tests/src/expected/Debug.res.txt index 8b8e1e426..55d36d357 100644 --- a/analysis/tests/src/expected/Debug.res.txt +++ b/analysis/tests/src/expected/Debug.res.txt @@ -4,7 +4,7 @@ Dependencies: @rescript/react Source directories: tests/node_modules/@rescript/react/src tests/node_modules/@rescript/react/src/legacy Source files: tests/node_modules/@rescript/react/src/React.res tests/node_modules/@rescript/react/src/ReactDOM.res tests/node_modules/@rescript/react/src/ReactDOMServer.res tests/node_modules/@rescript/react/src/ReactDOMStyle.res tests/node_modules/@rescript/react/src/ReactEvent.res tests/node_modules/@rescript/react/src/ReactEvent.resi tests/node_modules/@rescript/react/src/ReactTestUtils.res tests/node_modules/@rescript/react/src/ReactTestUtils.resi tests/node_modules/@rescript/react/src/RescriptReactErrorBoundary.res tests/node_modules/@rescript/react/src/RescriptReactErrorBoundary.resi tests/node_modules/@rescript/react/src/RescriptReactRouter.res tests/node_modules/@rescript/react/src/RescriptReactRouter.resi tests/node_modules/@rescript/react/src/legacy/ReactDOMRe.res tests/node_modules/@rescript/react/src/legacy/ReasonReact.res Source directories: tests/src tests/src/expected -Source files: tests/src/Auto.res tests/src/CompletePrioritize1.res tests/src/CompletePrioritize2.res tests/src/Completion.res tests/src/Component.res tests/src/Component.resi tests/src/CreateInterface.res tests/src/Cross.res tests/src/Debug.res tests/src/Definition.res tests/src/DefinitionWithInterface.res tests/src/DefinitionWithInterface.resi tests/src/Div.res tests/src/DocumentSymbol.res tests/src/Fragment.res tests/src/Highlight.res tests/src/Hover.res tests/src/Jsx.res tests/src/Jsx.resi tests/src/LongIdentTest.res tests/src/Obj.res tests/src/Patterns.res tests/src/RecModules.res tests/src/RecordCompletion.res tests/src/References.res tests/src/ReferencesWithInterface.res tests/src/ReferencesWithInterface.resi tests/src/Rename.res tests/src/RenameWithInterface.res tests/src/RenameWithInterface.resi tests/src/TableclothMap.ml tests/src/TableclothMap.mli tests/src/TypeDefinition.res +Source files: tests/src/Auto.res tests/src/CompletePrioritize1.res tests/src/CompletePrioritize2.res tests/src/Completion.res tests/src/Component.res tests/src/Component.resi tests/src/CreateInterface.res tests/src/Cross.res tests/src/Debug.res tests/src/Definition.res tests/src/DefinitionWithInterface.res tests/src/DefinitionWithInterface.resi tests/src/Div.res tests/src/DocumentSymbol.res tests/src/Fragment.res tests/src/Highlight.res tests/src/Hover.res tests/src/Jsx.res tests/src/Jsx.resi tests/src/LongIdentTest.res tests/src/Obj.res tests/src/Patterns.res tests/src/RecModules.res tests/src/RecordCompletion.res tests/src/References.res tests/src/ReferencesWithInterface.res tests/src/ReferencesWithInterface.resi tests/src/Rename.res tests/src/RenameWithInterface.res tests/src/RenameWithInterface.resi tests/src/TableclothMap.ml tests/src/TableclothMap.mli tests/src/TypeDefinition.res tests/src/Xform.res Impl cmt:tests/lib/bs/src/Auto.cmt res:tests/src/Auto.res Impl cmt:tests/lib/bs/src/CompletePrioritize1.cmt res:tests/src/CompletePrioritize1.res Impl cmt:tests/lib/bs/src/CompletePrioritize2.cmt res:tests/src/CompletePrioritize2.res @@ -32,6 +32,7 @@ Impl cmt:tests/lib/bs/src/Rename.cmt res:tests/src/Rename.res IntfAndImpl cmti:tests/lib/bs/src/RenameWithInterface.cmti resi:tests/src/RenameWithInterface.resi cmt:tests/lib/bs/src/RenameWithInterface.cmt res:tests/src/RenameWithInterface.res IntfAndImpl cmti:tests/lib/bs/src/TableclothMap.cmti resi:tests/src/TableclothMap.mli cmt:tests/lib/bs/src/TableclothMap.cmt res:tests/src/TableclothMap.ml Impl cmt:tests/lib/bs/src/TypeDefinition.cmt res:tests/src/TypeDefinition.res +Impl cmt:tests/lib/bs/src/Xform.cmt res:tests/src/Xform.res Dependency dirs: tests/node_modules/@rescript/react/lib/bs/src tests/node_modules/@rescript/react/lib/bs/src/legacy Opens from bsconfig: locItems: diff --git a/analysis/tests/src/expected/Xform.res.txt b/analysis/tests/src/expected/Xform.res.txt new file mode 100644 index 000000000..4583cee1c --- /dev/null +++ b/analysis/tests/src/expected/Xform.res.txt @@ -0,0 +1,101 @@ +Xform tests/src/Xform.res 6:5 +Hit: Replace with switch +{"start": {"line": 6, "character": 0}, "end": {"line": 11, "character": 1}} +newText: +<--here +switch kind { +| First => + // ^xfm + ret("First") +| _ => ret("Not First") +} + +Xform tests/src/Xform.res 13:15 +Hit: Replace with switch +{"start": {"line": 13, "character": 0}, "end": {"line": 13, "character": 79}} +newText: +<--here +switch kind { +| #kind("First", {name: "abc", age: 3}) => ret("First") +| _ => ret("Not First") +} + +Xform tests/src/Xform.res 16:5 +Hit: Add type annotation +{"start": {"line": 16, "character": 8}, "end": {"line": 16, "character": 8}} +newText: + <--here + : string + +Xform tests/src/Xform.res 19:5 + +Xform tests/src/Xform.res 26:10 +Hit: Add type annotation +{"start": {"line": 26, "character": 10}, "end": {"line": 26, "character": 11}} +newText: + <--here + (x: option) + +Xform tests/src/Xform.res 30:9 +Hit: Add braces to function +{"start": {"line": 26, "character": 0}, "end": {"line": 32, "character": 3}} +newText: +<--here +let foo = x => { + // ^xfm + switch x { + | None => 33 + | Some(q) => q.T.a + 1 + // ^xfm + } +} + +Xform tests/src/Xform.res 34:21 +Hit: Add type annotation +{"start": {"line": 34, "character": 24}, "end": {"line": 34, "character": 24}} +newText: + <--here + : int + +Xform tests/src/Xform.res 38:5 + +Xform tests/src/Xform.res 41:9 +Hit: Add type annotation +{"start": {"line": 41, "character": 11}, "end": {"line": 41, "character": 11}} +newText: + <--here + : int + +Xform tests/src/Xform.res 48:21 +Hit: Add braces to function +{"start": {"line": 48, "character": 0}, "end": {"line": 48, "character": 25}} +newText: +<--here +let noBraces = () => { + name +} + +Xform tests/src/Xform.res 52:34 +Hit: Add braces to function +{"start": {"line": 51, "character": 0}, "end": {"line": 54, "character": 1}} +newText: +<--here +let nested = () => { + let _noBraces = (_x, _y, _z) => { + "someNewFunc" + } + // ^xfm +} + +Xform tests/src/Xform.res 62:6 +Hit: Add braces to function +{"start": {"line": 58, "character": 4}, "end": {"line": 62, "character": 7}} +newText: + <--here + let foo = (_x, y, _z) => { + switch y { + | #some => 3 + | #stuff => 4 + } + } + diff --git a/server/src/codeActions.ts b/server/src/codeActions.ts new file mode 100644 index 000000000..ea55a1e0d --- /dev/null +++ b/server/src/codeActions.ts @@ -0,0 +1,711 @@ +// This file holds code actions derived from diagnostics. There are more code +// actions available in the extension, but they are derived via the analysis +// OCaml binary. +import * as p from "vscode-languageserver-protocol"; + +export type filesCodeActions = { + [key: string]: { range: p.Range; codeAction: p.CodeAction }[]; +}; + +interface findCodeActionsConfig { + diagnostic: p.Diagnostic; + diagnosticMessage: string[]; + file: string; + range: p.Range; + addFoundActionsHere: filesCodeActions; +} + +let wrapRangeInText = ( + range: p.Range, + wrapStart: string, + wrapEnd: string +): p.TextEdit[] => { + // We need to adjust the start of where we replace if this is a single + // character on a single line. + let offset = + range.start.line === range.end.line && + range.start.character === range.end.character + ? 1 + : 0; + + let startRange = { + start: { + line: range.start.line, + character: range.start.character - offset, + }, + end: { + line: range.start.line, + character: range.start.character - offset, + }, + }; + + let endRange = { + start: { + line: range.end.line, + character: range.end.character, + }, + end: { + line: range.end.line, + character: range.end.character, + }, + }; + + return [ + { + range: startRange, + newText: wrapStart, + }, + { + range: endRange, + newText: wrapEnd, + }, + ]; +}; + +let insertBeforeEndingChar = ( + range: p.Range, + newText: string +): p.TextEdit[] => { + let beforeEndingChar = { + line: range.end.line, + character: range.end.character - 1, + }; + + return [ + { + range: { + start: beforeEndingChar, + end: beforeEndingChar, + }, + newText, + }, + ]; +}; + +let removeTrailingComma = (text: string): string => { + let str = text.trim(); + if (str.endsWith(",")) { + return str.slice(0, str.length - 1); + } + + return str; +}; + +let extractTypename = (lines: string[]): string => { + let arrFiltered: string[] = []; + + for (let i = 0; i <= lines.length - 1; i += 1) { + let line = lines[i]; + if (line.includes("(defined as")) { + let [typeStr, _] = line.split("(defined as"); + arrFiltered.push(removeTrailingComma(typeStr)); + break; + } else { + arrFiltered.push(removeTrailingComma(line)); + } + } + + return arrFiltered.join("").trim(); +}; + +let takeUntil = (array: string[], startsWith: string): string[] => { + let res: string[] = []; + let arr = array.slice(); + + let matched = false; + arr.forEach((line) => { + if (matched) { + return; + } + + if (line.startsWith(startsWith)) { + matched = true; + } else { + res.push(line); + } + }); + + return res; +}; + +export let findCodeActionsInDiagnosticsMessage = ({ + diagnostic, + diagnosticMessage, + file, + range, + addFoundActionsHere: codeActions, +}: findCodeActionsConfig) => { + diagnosticMessage.forEach((line, index, array) => { + // Because of how actions work, there can only be one per diagnostic. So, + // halt whenever a code action has been found. + let codeActionEtractors = [ + simpleTypeMismatches, + didYouMeanAction, + addUndefinedRecordFields, + simpleConversion, + applyUncurried, + simpleAddMissingCases, + ]; + + for (let extractCodeAction of codeActionEtractors) { + let didFindAction = false; + + try { + didFindAction = extractCodeAction({ + array, + codeActions, + diagnostic, + file, + index, + line, + range, + }); + } catch (e) { + console.error(e); + } + + if (didFindAction) { + break; + } + } + }); +}; + +interface codeActionExtractorConfig { + line: string; + index: number; + array: string[]; + file: string; + range: p.Range; + diagnostic: p.Diagnostic; + codeActions: filesCodeActions; +} + +type codeActionExtractor = (config: codeActionExtractorConfig) => boolean; + +// This action extracts hints the compiler emits for misspelled identifiers, and +// offers to replace the misspelled name with the correct name suggested by the +// compiler. +let didYouMeanAction: codeActionExtractor = ({ + codeActions, + diagnostic, + file, + line, + range, +}) => { + if (line.startsWith("Hint: Did you mean")) { + let regex = /Did you mean ([A-Za-z0-9_]*)?/; + let match = line.match(regex); + + if (match === null) { + return false; + } + + let [_, suggestion] = match; + + if (suggestion != null) { + codeActions[file] = codeActions[file] || []; + let codeAction: p.CodeAction = { + title: `Replace with '${suggestion}'`, + edit: { + changes: { + [file]: [{ range, newText: suggestion }], + }, + }, + diagnostics: [diagnostic], + kind: p.CodeActionKind.QuickFix, + isPreferred: true, + }; + + codeActions[file].push({ + range, + codeAction, + }); + + return true; + } + } + + return false; +}; + +// This action handles when the compiler errors on certain fields of a record +// being undefined. We then offers an action that inserts all of the record +// fields, with an `assert false` dummy value. `assert false` is so applying the +// code action actually compiles. +let addUndefinedRecordFields: codeActionExtractor = ({ + array, + codeActions, + diagnostic, + file, + index, + line, + range, +}) => { + if (line.startsWith("Some record fields are undefined:")) { + let recordFieldNames = line + .trim() + .split("Some record fields are undefined: ")[1] + ?.split(" "); + + // This collects the rest of the fields if fields are printed on + // multiple lines. + array.slice(index + 1).forEach((line) => { + recordFieldNames.push(...line.trim().split(" ")); + }); + + if (recordFieldNames != null) { + codeActions[file] = codeActions[file] || []; + + // The formatter outputs trailing commas automatically if the record + // definition is on multiple lines, and no trailing comma if it's on a + // single line. We need to adapt to this so we don't accidentally + // insert an invalid comma. + let multilineRecordDefinitionBody = range.start.line !== range.end.line; + + // Let's build up the text we're going to insert. + let newText = ""; + + if (multilineRecordDefinitionBody) { + // If it's a multiline body, we know it looks like this: + // ``` + // let someRecord = { + // atLeastOneExistingField: string, + // } + // ``` + // We can figure out the formatting from the range the code action + // gives us. We'll insert to the direct left of the ending brace. + + // The end char is the closing brace, and it's always going to be 2 + // characters back from the record fields. + let paddingCharacters = multilineRecordDefinitionBody + ? range.end.character + 2 + : 0; + let paddingContentRecordField = Array.from({ + length: paddingCharacters, + }).join(" "); + let paddingContentEndBrace = Array.from({ + length: range.end.character, + }).join(" "); + + recordFieldNames.forEach((fieldName, index) => { + if (index === 0) { + // This adds spacing from the ending brace up to the equivalent + // of the last record field name, needed for the first inserted + // record field name. + newText += " "; + } else { + // The rest of the new record field names will start from a new + // line, so they need left padding all the way to the same level + // as the rest of the record fields. + newText += paddingContentRecordField; + } + + newText += `${fieldName}: assert false,\n`; + }); + + // Let's put the end brace back where it was (we still have it to the direct right of us). + newText += `${paddingContentEndBrace}`; + } else { + // A single line record definition body is a bit easier - we'll just add the new fields on the same line. + newText += ", "; + newText += recordFieldNames + .map((fieldName) => `${fieldName}: assert false`) + .join(", "); + } + + let codeAction: p.CodeAction = { + title: `Add missing record fields`, + edit: { + changes: { + [file]: insertBeforeEndingChar(range, newText), + }, + }, + diagnostics: [diagnostic], + kind: p.CodeActionKind.QuickFix, + isPreferred: true, + }; + + codeActions[file].push({ + range, + codeAction, + }); + + return true; + } + } + + return false; +}; + +// This action detects suggestions of converting between mismatches in types +// that the compiler tells us about. +let simpleConversion: codeActionExtractor = ({ + line, + codeActions, + file, + range, + diagnostic, +}) => { + if (line.startsWith("You can convert ")) { + let regex = /You can convert (\w*) to (\w*) with ([\w.]*).$/; + let match = line.match(regex); + + if (match === null) { + return false; + } + + let [_, from, to, fn] = match; + + if (from != null && to != null && fn != null) { + codeActions[file] = codeActions[file] || []; + + let codeAction: p.CodeAction = { + title: `Convert ${from} to ${to} with ${fn}`, + edit: { + changes: { + [file]: wrapRangeInText(range, `${fn}(`, `)`), + }, + }, + diagnostics: [diagnostic], + kind: p.CodeActionKind.QuickFix, + isPreferred: true, + }; + + codeActions[file].push({ + range, + codeAction, + }); + + return true; + } + } + + return false; +}; + +// This action will apply a curried function (essentially inserting a dot in the +// correct place). +let applyUncurried: codeActionExtractor = ({ + line, + codeActions, + file, + range, + diagnostic, +}) => { + if ( + line.startsWith( + "This is an uncurried ReScript function. It must be applied with a dot." + ) + ) { + const locOfOpenFnParens = { + line: range.end.line, + character: range.end.character + 1, + }; + + codeActions[file] = codeActions[file] || []; + let codeAction: p.CodeAction = { + title: `Apply uncurried function call with dot`, + edit: { + changes: { + [file]: [ + { + range: { + start: locOfOpenFnParens, + end: locOfOpenFnParens, + }, + /* + * Turns `fn(123)` into `fn(. 123)`. + */ + newText: `. `, + }, + ], + }, + }, + diagnostics: [diagnostic], + kind: p.CodeActionKind.QuickFix, + isPreferred: true, + }; + + codeActions[file].push({ + range, + codeAction, + }); + + return true; + } + + return false; +}; + +// Untransformed is typically OCaml, and looks like these examples: +// +// `SomeVariantName +// +// SomeVariantWithPayload _ +// +// ...and we'll need to transform this into proper ReScript. In the future, the +// compiler itself should of course output real ReScript. But it currently does +// not. +// +// Note that we're trying to not be too clever here, so we'll only try to +// convert the very simplest cases - single variant/polyvariant, with single +// payloads. No records, tuples etc yet. We can add those when the compiler +// outputs them in proper ReScript. +let transformMatchPattern = (matchPattern: string): string | null => { + let text = matchPattern.replace(/`/g, "#"); + + let payloadRegexp = / /g; + let matched = text.match(payloadRegexp); + + // Constructors are preceded by a single space. Bail if there's more than 1. + if (matched != null && matched.length > 2) { + return null; + } + + // Fix payloads if they can be fixed. If not, bail. + if (text.includes(" ")) { + let [variantText, payloadText] = text.split(" "); + + let transformedPayloadText = transformMatchPattern(payloadText); + if (transformedPayloadText == null) { + return null; + } + + text = `${variantText}(${payloadText})`; + } + + return text; +}; + +// This action detects missing cases for exhaustive pattern matches, and offers +// to insert dummy branches (using `assert false`) for those branches. Right now +// it works on single variants/polyvariants with and without payloads. In the +// future it could be made to work on anything the compiler tell us about, but +// the compiler needs to emit proper ReScript in the error messages for that to +// work. +let simpleAddMissingCases: codeActionExtractor = ({ + line, + codeActions, + file, + range, + diagnostic, + array, + index, +}) => { + // Examples: + // + // You forgot to handle a possible case here, for example: + // (AnotherValue|Third|Fourth) + // + // You forgot to handle a possible case here, for example: + // (`AnotherValue|`Third|`Fourth) + // + // You forgot to handle a possible case here, for example: + // `AnotherValue + // + // You forgot to handle a possible case here, for example: + // AnotherValue + // + // You forgot to handle a possible case here, for example: + // (`One _|`Two _| + // `Three _) + + if ( + line.startsWith("You forgot to handle a possible case here, for example:") + ) { + let cases: string[] = []; + + // This collects the rest of the fields if fields are printed on + // multiple lines. + let allCasesAsOneLine = array + .slice(index + 1) + .join("") + .trim(); + + // We only handle the simplest possible cases until the compiler actually + // outputs ReScript. This means bailing on anything that's not a + // variant/polyvariant, with one payload (or no payloads at all). + let openParensCount = allCasesAsOneLine.split("(").length - 1; + + if (openParensCount > 1 || allCasesAsOneLine.includes("{")) { + return false; + } + + // Remove surrounding braces if they exist + if (allCasesAsOneLine[0] === "(") { + allCasesAsOneLine = allCasesAsOneLine.slice( + 1, + allCasesAsOneLine.length - 1 + ); + } + + cases.push( + ...(allCasesAsOneLine + .split("|") + .map(transformMatchPattern) + .filter(Boolean) as string[]) + ); + + if (cases.length === 0) { + return false; + } + + // The end char is the closing brace. In switches, the leading `|` always + // has the same left padding as the end brace. + let paddingContentSwitchCase = Array.from({ + length: range.end.character, + }).join(" "); + + let newText = cases + .map((variantName, index) => { + // The first case will automatically be padded because we're inserting + // it where the end brace is currently located. + let padding = index === 0 ? "" : paddingContentSwitchCase; + return `${padding}| ${variantName} => assert false`; + }) + .join("\n"); + + // Let's put the end brace back where it was (we still have it to the direct right of us). + newText += `\n${paddingContentSwitchCase}`; + + codeActions[file] = codeActions[file] || []; + let codeAction: p.CodeAction = { + title: `Insert missing cases`, + edit: { + changes: { + [file]: insertBeforeEndingChar(range, newText), + }, + }, + diagnostics: [diagnostic], + kind: p.CodeActionKind.QuickFix, + isPreferred: true, + }; + + codeActions[file].push({ + range, + codeAction, + }); + + return true; + } + + return false; +}; + +// This detects concrete variables or values put in a position which expects an +// optional of that same type, and offers to wrap the value/variable in +// `Some()`. +let simpleTypeMismatches: codeActionExtractor = ({ + line, + codeActions, + file, + range, + diagnostic, + array, + index, +}) => { + // Examples: + // + // 46 │ let as_ = { + // 47 │ someProp: "123", + // 48 │ another: "123", + // 49 │ } + // 50 │ + // This has type: string + // Somewhere wanted: option + // + // ...but types etc can also be on multilines, so we need a good + // amount of cleanup. + + let lookFor = "This has type:"; + + if (line.startsWith(lookFor)) { + let thisHasTypeArr = takeUntil( + [line.slice(lookFor.length), ...array.slice(index + 1)], + "Somewhere wanted:" + ); + let somewhereWantedArr = array + .slice(index + thisHasTypeArr.length) + .map((line) => line.replace("Somewhere wanted:", "")); + + let thisHasType = extractTypename(thisHasTypeArr); + let somewhereWanted = extractTypename(somewhereWantedArr); + + // Switching over an option + if (thisHasType === `option<${somewhereWanted}>`) { + codeActions[file] = codeActions[file] || []; + + // We can figure out default values for primitives etc. + let defaultValue = "assert false"; + + switch (somewhereWanted) { + case "string": { + defaultValue = `"-"`; + break; + } + case "bool": { + defaultValue = `false`; + break; + } + case "int": { + defaultValue = `-1`; + break; + } + case "float": { + defaultValue = `-1.`; + break; + } + } + + let codeAction: p.CodeAction = { + title: `Unwrap optional value`, + edit: { + changes: { + [file]: wrapRangeInText( + range, + "switch ", + ` { | None => ${defaultValue} | Some(v) => v }` + ), + }, + }, + diagnostics: [diagnostic], + kind: p.CodeActionKind.QuickFix, + isPreferred: true, + }; + + codeActions[file].push({ + range, + codeAction, + }); + + return true; + } + + // Wrapping a non-optional in Some + if (`option<${thisHasType}>` === somewhereWanted) { + codeActions[file] = codeActions[file] || []; + + let codeAction: p.CodeAction = { + title: `Wrap value in Some`, + edit: { + changes: { + [file]: wrapRangeInText(range, "Some(", ")"), + }, + }, + diagnostics: [diagnostic], + kind: p.CodeActionKind.QuickFix, + isPreferred: true, + }; + + codeActions[file].push({ + range, + codeAction, + }); + + return true; + } + } + + return false; +}; diff --git a/server/src/server.ts b/server/src/server.ts index 664e37c64..1bd547988 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -12,6 +12,7 @@ import { DidCloseTextDocumentNotification, } from "vscode-languageserver-protocol"; import * as utils from "./utils"; +import * as codeActions from "./codeActions"; import * as c from "./constants"; import * as chokidar from "chokidar"; import { assert } from "console"; @@ -36,6 +37,9 @@ let projectsFiles: Map< > = new Map(); // ^ caching AND states AND distributed system. Why does LSP has to be stupid like this +// This keeps track of code actions extracted from diagnostics. +let codeActionsFromDiagnostics: codeActions.filesCodeActions = {}; + // will be properly defined later depending on the mode (stdio/node-rpc) let send: (msg: m.Message) => void = (_) => {}; @@ -65,8 +69,13 @@ let sendUpdatedDiagnostics = () => { path.join(projectRootPath, c.compilerLogPartialPath), { encoding: "utf-8" } ); - let { done, result: filesAndErrors } = - utils.parseCompilerLogOutput(content); + let { + done, + result: filesAndErrors, + codeActions, + } = utils.parseCompilerLogOutput(content); + + codeActionsFromDiagnostics = codeActions; // diff Object.keys(filesAndErrors).forEach((file) => { @@ -458,6 +467,53 @@ function completion(msg: p.RequestMessage) { return response; } +function codeAction(msg: p.RequestMessage): p.ResponseMessage { + let params = msg.params as p.CodeActionParams; + let filePath = fileURLToPath(params.textDocument.uri); + let code = getOpenedFileContent(params.textDocument.uri); + let extension = path.extname(params.textDocument.uri); + let tmpname = utils.createFileInTempDir(extension); + + // Check local code actions coming from the diagnostics. + let localResults: v.CodeAction[] = []; + codeActionsFromDiagnostics[params.textDocument.uri]?.forEach( + ({ range, codeAction }) => { + if (utils.rangeContainsRange(range, params.range)) { + localResults.push(codeAction); + } + } + ); + + fs.writeFileSync(tmpname, code, { encoding: "utf-8" }); + let response = utils.runAnalysisCommand( + filePath, + [ + "codeAction", + filePath, + params.range.start.line, + params.range.start.character, + tmpname, + ], + msg + ); + fs.unlink(tmpname, () => null); + + let { result } = response; + + // We must send `null` when there are no results, empty array isn't enough. + let codeActions = + result != null && Array.isArray(result) + ? [...localResults, ...result] + : localResults; + + let res: v.ResponseMessage = { + jsonrpc: c.jsonrpcVersion, + id: msg.id, + result: codeActions.length > 0 ? codeActions : null, + }; + return res; +} + function format(msg: p.RequestMessage): Array { // technically, a formatting failure should reply with the error. Sadly // the LSP alert box for these error replies sucks (e.g. doesn't actually @@ -750,6 +806,7 @@ function onMessage(msg: m.Message) { definitionProvider: true, typeDefinitionProvider: true, referencesProvider: true, + codeActionProvider: true, renameProvider: { prepareProvider: true }, documentSymbolProvider: true, completionProvider: { triggerCharacters: [".", ">", "@", "~", '"'] }, @@ -831,6 +888,8 @@ function onMessage(msg: m.Message) { send(completion(msg)); } else if (msg.method === p.SemanticTokensRequest.method) { send(semanticTokens(msg)); + } else if (msg.method === p.CodeActionRequest.method) { + send(codeAction(msg)); } else if (msg.method === p.DocumentFormattingRequest.method) { let responses = format(msg); responses.forEach((response) => send(response)); diff --git a/server/src/utils.ts b/server/src/utils.ts index 61af68264..21be671af 100644 --- a/server/src/utils.ts +++ b/server/src/utils.ts @@ -1,4 +1,5 @@ import * as c from "./constants"; +import * as codeActions from "./codeActions"; import * as childProcess from "child_process"; import * as p from "vscode-languageserver-protocol"; import * as path from "path"; @@ -479,6 +480,7 @@ type filesDiagnostics = { type parsedCompilerLogResult = { done: boolean; result: filesDiagnostics; + codeActions: codeActions.filesCodeActions; }; export let parseCompilerLogOutput = ( content: string @@ -596,6 +598,8 @@ export let parseCompilerLogOutput = ( } let result: filesDiagnostics = {}; + let foundCodeActions: codeActions.filesCodeActions = {}; + parsedDiagnostics.forEach((parsedDiagnostic) => { let [fileAndRangeLine, ...diagnosticMessage] = parsedDiagnostic.content; let { file, range } = parseFileAndRange(fileAndRangeLine); @@ -603,7 +607,8 @@ export let parseCompilerLogOutput = ( if (result[file] == null) { result[file] = []; } - result[file].push({ + + let diagnostic: p.Diagnostic = { severity: parsedDiagnostic.severity, tags: parsedDiagnostic.tag === undefined ? [] : [parsedDiagnostic.tag], code: parsedDiagnostic.code, @@ -611,8 +616,50 @@ export let parseCompilerLogOutput = ( source: "ReScript", // remove start and end whitespaces/newlines message: diagnosticMessage.join("\n").trim() + "\n", + }; + + // Check for potential code actions + codeActions.findCodeActionsInDiagnosticsMessage({ + addFoundActionsHere: foundCodeActions, + diagnostic, + diagnosticMessage, + file, + range, }); + + result[file].push(diagnostic); }); - return { done, result }; + return { done, result, codeActions: foundCodeActions }; +}; + +export let rangeContainsRange = ( + range: p.Range, + otherRange: p.Range +): boolean => { + if ( + otherRange.start.line < range.start.line || + otherRange.end.line < range.start.line + ) { + return false; + } + if ( + otherRange.start.line > range.end.line || + otherRange.end.line > range.end.line + ) { + return false; + } + if ( + otherRange.start.line === range.start.line && + otherRange.start.character < range.start.character + ) { + return false; + } + if ( + otherRange.end.line === range.end.line && + otherRange.end.character > range.end.character + ) { + return false; + } + return true; };