diff --git a/CHANGELOG.md b/CHANGELOG.md index 356942d0a..5bb7cd495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ ## master +#### :rocket: New Feature + +- Inlay Hints (experimetal). `rescript.settings.inlayHints.enable: true` ## v1.4.2 #### :bug: Bug Fix diff --git a/README.md b/README.md index 92ca8b97f..ed047c0d0 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,18 @@ The extension will look for the existence of a `/node_modules/.bin/rescript` fil To override this lookup process, the path can be configured explicitly using the setting `rescript.settings.binaryPath`. +### Inlay Hints (experimental) + +This allows an editor to place annotations inline with text to display type hints. + +```jsonc +// Enable (experimental) inlay hints. +rescript.settings.inlayHints.enable: true + +// Maximum length of character for inlay hints. Set to null to have an unlimited length. Inlay hints that exceed the maximum length will not be shown +rescript.settings.inlayHints.maxLength: 25 +``` + ### Hide generated files You can configure VSCode to collapse the JavaScript files ReScript generates under its source ReScript file. This will "hide" the generated files in the VSCode file explorer, but still leaving them accessible by expanding the source ReScript file they belong to. diff --git a/analysis/examples/larger-project/.merlin b/analysis/examples/larger-project/.merlin index c5130bee7..05434917e 100644 --- a/analysis/examples/larger-project/.merlin +++ b/analysis/examples/larger-project/.merlin @@ -1,12 +1,12 @@ ####{BSB GENERATED: NO EDIT -FLG -ppx '/Users/cristianocalcagno/GitHub/rescript-vscode/analysis/examples/larger-project/node_modules/rescript/darwin/bsc.exe -as-ppx -bs-jsx 3' -S /Users/cristianocalcagno/GitHub/rescript-vscode/analysis/examples/larger-project/node_modules/rescript/lib/ocaml -B /Users/cristianocalcagno/GitHub/rescript-vscode/analysis/examples/larger-project/node_modules/rescript/lib/ocaml +FLG -ppx '/home/pedro/Desktop/Projects/rescript-vscode/analysis/examples/larger-project/node_modules/rescript/linux/bsc.exe -as-ppx -bs-jsx 3' +S /home/pedro/Desktop/Projects/rescript-vscode/analysis/examples/larger-project/node_modules/rescript/lib/ocaml +B /home/pedro/Desktop/Projects/rescript-vscode/analysis/examples/larger-project/node_modules/rescript/lib/ocaml FLG -w +a-4-9-20-40-41-42-50-61-102 -S /Users/cristianocalcagno/GitHub/rescript-vscode/analysis/examples/larger-project/node_modules/@rescript/react/lib/ocaml -B /Users/cristianocalcagno/GitHub/rescript-vscode/analysis/examples/larger-project/node_modules/@rescript/react/lib/ocaml -S /Users/cristianocalcagno/GitHub/rescript-vscode/analysis/examples/larger-project/node_modules/@glennsl/bs-json/lib/ocaml -B /Users/cristianocalcagno/GitHub/rescript-vscode/analysis/examples/larger-project/node_modules/@glennsl/bs-json/lib/ocaml +S /home/pedro/Desktop/Projects/rescript-vscode/analysis/examples/larger-project/node_modules/@rescript/react/lib/ocaml +B /home/pedro/Desktop/Projects/rescript-vscode/analysis/examples/larger-project/node_modules/@rescript/react/lib/ocaml +S /home/pedro/Desktop/Projects/rescript-vscode/analysis/examples/larger-project/node_modules/@glennsl/bs-json/lib/ocaml +B /home/pedro/Desktop/Projects/rescript-vscode/analysis/examples/larger-project/node_modules/@glennsl/bs-json/lib/ocaml S src B lib/bs/src S src/exception diff --git a/analysis/src/Cli.ml b/analysis/src/Cli.ml index 6d57cb5bc..9acc01417 100644 --- a/analysis/src/Cli.ml +++ b/analysis/src/Cli.ml @@ -11,6 +11,7 @@ API examples: ./rescript-editor-analysis.exe references src/MyFile.res 10 2 ./rescript-editor-analysis.exe rename src/MyFile.res 10 2 foo ./rescript-editor-analysis.exe diagnosticSyntax src/MyFile.res + /rescript-editor-analysis.exe inlayHint src/MyFile.res 0 3 25 Dev-time examples: ./rescript-editor-analysis.exe dump src/MyFile.res src/MyFile2.res @@ -65,6 +66,10 @@ Options: ./rescript-editor-analysis.exe diagnosticSyntax src/MyFile.res + inlayHint: get all inlay Hint between line 0 and 3 declared in MyFile.res. Last argument is maximum of character length for inlay hints + + ./rescript-editor-analysis.exe inlayHint src/MyFile.res 0 3 25 + test: run tests specified by special comments in file src/MyFile.res ./rescript-editor-analysis.exe test src/src/MyFile.res @@ -89,6 +94,10 @@ let main () = Commands.hover ~path ~pos:(int_of_string line, int_of_string col) ~currentFile ~debug:false + | [_; "inlayHint"; path; line_start; line_end; maxLength] -> + Commands.inlayhint ~path + ~pos:(int_of_string line_start, int_of_string line_end) + ~maxLength ~debug:false | [_; "codeAction"; path; line; col; currentFile] -> Commands.codeAction ~path ~pos:(int_of_string line, int_of_string col) diff --git a/analysis/src/Commands.ml b/analysis/src/Commands.ml index ee0f54283..3dd16c468 100644 --- a/analysis/src/Commands.ml +++ b/analysis/src/Commands.ml @@ -28,6 +28,10 @@ let completion ~debug ~path ~pos ~currentFile = |> List.map Protocol.stringifyCompletionItem |> Protocol.array) +let inlayhint ~path ~pos ~maxLength ~debug = + let result = Hint.inlay ~path ~pos ~maxLength ~debug |> Protocol.array in + print_endline result + let hover ~path ~pos ~currentFile ~debug = let result = match Cmt.fullFromPath ~path with @@ -382,6 +386,11 @@ let test ~path = (Protocol.stringifyRange range) indent indent newText))) | "dia" -> diagnosticSyntax ~path + | "hin" -> ( + let line_start = 0 in + let line_end = 6 in + print_endline ("Inlay Hint " ^ path ^ " " ^ string_of_int line_start ^ ":" ^ string_of_int line_end); + inlayhint ~path ~pos:(line_start, line_end) ~maxLength:"25" ~debug:false) | _ -> ()); print_newline ()) in diff --git a/analysis/src/Hint.ml b/analysis/src/Hint.ml new file mode 100644 index 000000000..c3d8a9682 --- /dev/null +++ b/analysis/src/Hint.ml @@ -0,0 +1,122 @@ +open SharedTypes + +type inlayHintKind = Type | Parameter +let inlayKindToNumber = function + | Type -> 1 + | Parameter -> 2 + +let locItemToTypeHint ~full:{file; package} locItem = + match locItem.locType with + | Constant t -> + Some + (match t with + | Const_int _ -> "int" + | Const_char _ -> "char" + | Const_string _ -> "string" + | Const_float _ -> "float" + | Const_int32 _ -> "int32" + | Const_int64 _ -> "int64" + | Const_nativeint _ -> "int") + | Typed (_, t, locKind) -> + let fromType typ = + typ |> Shared.typeToString + |> Str.global_replace (Str.regexp "[\r\n\t]") "" + in + Some + (match References.definedForLoc ~file ~package locKind with + | None -> fromType t + | Some (_, res) -> ( + match res with + | `Declared -> fromType t + | `Constructor _ -> fromType t + | `Field -> fromType t)) + | _ -> None + +let inlay ~path ~pos ~maxLength ~debug = + let maxlen = try Some (int_of_string maxLength) with Failure _ -> None in + let hints = ref [] in + let start_line, end_line = pos in + let push loc kind = + let range = Utils.cmtLocToRange loc in + if start_line <= range.end_.line && end_line >= range.start.line then + hints := (range, kind) :: !hints + in + let rec processFunction (exp : Parsetree.expression) = + match exp.pexp_desc with + | Pexp_fun (_, _, pat_exp, e) -> ( + match pat_exp with + | {ppat_desc = Ppat_var _} -> + push pat_exp.ppat_loc Type; + processFunction e + | _ -> processFunction e) + | _ -> () + in + let value_binding (iterator : Ast_iterator.iterator) + (vb : Parsetree.value_binding) = + (match vb with + | { + pvb_pat = {ppat_desc = Ppat_var _}; + pvb_expr = + { + pexp_desc = + ( Pexp_constant _ | Pexp_tuple _ | Pexp_record _ | Pexp_variant _ + | Pexp_apply _ | Pexp_match _ | Pexp_construct _ | Pexp_ifthenelse _ + | Pexp_array _ | Pexp_ident _ | Pexp_try _ | Pexp_lazy _ + | Pexp_send _ | Pexp_field _ | Pexp_open _ ); + }; + } -> + push vb.pvb_pat.ppat_loc Type + | {pvb_pat = {ppat_desc = Ppat_tuple tuples}} -> + List.iter + (fun (tuple : Parsetree.pattern) -> push tuple.ppat_loc Type) + tuples + | { + pvb_pat = {ppat_desc = Ppat_var _}; + pvb_expr = {pexp_desc = Pexp_fun (_, _, pat, e)}; + } -> + (match pat with + | {ppat_desc = Ppat_var _} -> push pat.ppat_loc Type + | _ -> ()); + processFunction e + | _ -> ()); + Ast_iterator.default_iterator.value_binding iterator vb + in + let iterator = {Ast_iterator.default_iterator with value_binding} in + (if Filename.check_suffix path ".res" then + let parser = + Res_driver.parsingEngine.parseImplementation ~forPrinter:false + in + let {Res_driver.parsetree = structure} = parser ~filename:path in + iterator.structure iterator structure |> ignore); + !hints + |> List.filter_map (fun ((range : Protocol.range), hintKind) -> + match Cmt.fullFromPath ~path with + | None -> None + | Some full -> ( + match + References.getLocItem ~full + ~pos:(range.start.line, range.start.character + 1) + ~debug + with + | None -> None + | Some locItem -> ( + let position : Protocol.position = + {line = range.start.line; character = range.end_.character} + in + match locItemToTypeHint locItem ~full with + | Some label -> ( + let result = + Protocol.stringifyHint + { + kind = inlayKindToNumber hintKind; + position; + paddingLeft = true; + paddingRight = false; + label = ": " ^ label; + } + in + match maxlen with + | Some value -> + if String.length label > value then None else Some result + | None -> Some result) + | None -> None))) \ No newline at end of file diff --git a/analysis/src/Protocol.ml b/analysis/src/Protocol.ml index 4d179b038..6838a29ca 100644 --- a/analysis/src/Protocol.ml +++ b/analysis/src/Protocol.ml @@ -1,6 +1,13 @@ type position = {line: int; character: int} type range = {start: position; end_: position} type markupContent = {kind: string; value: string} +type inlayHint = { + position: position; + label: string; + kind: int; + paddingLeft: bool; + paddingRight: bool; +} type completionItem = { label: string; @@ -128,6 +135,18 @@ let stringifyCodeAction ca = (codeActionKindToString ca.codeActionKind) (ca.edit |> stringifyCodeActionEdit) +let stringifyHint hint = + Printf.sprintf + {|{ + "position": %s, + "label": "%s", + "kind": %i, + "paddingLeft": %b, + "paddingRight": %b +}|} + (stringifyPosition hint.position) + (Json.escape hint.label) hint.kind hint.paddingLeft hint.paddingRight + (* https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#diagnostic *) let stringifyDiagnostic d = Printf.sprintf diff --git a/analysis/tests/src/InlayHint.res b/analysis/tests/src/InlayHint.res new file mode 100644 index 000000000..ef49412e9 --- /dev/null +++ b/analysis/tests/src/InlayHint.res @@ -0,0 +1,20 @@ +let string = "ReScript" +let number = 1 +let float = 1.1 +let char = 'c' + +let add = (x, y) => x + y + +let my_sum = 3->add(1)->add(1)->add(1)->add(8) + +let withAs = (~xx as yyy) => yyy + 1 + + +@react.component +let make = (~name) => React.string(name) + +let tuple = ("ReScript", "lol") + +let (lang, _) = tuple + +//^hin \ No newline at end of file diff --git a/analysis/tests/src/expected/Dce.res.txt b/analysis/tests/src/expected/Dce.res.txt index 308ef678c..441bb5807 100644 --- a/analysis/tests/src/expected/Dce.res.txt +++ b/analysis/tests/src/expected/Dce.res.txt @@ -1,3 +1,3 @@ DCE src/Dce.res -issues:235 +issues:243 diff --git a/analysis/tests/src/expected/Debug.res.txt b/analysis/tests/src/expected/Debug.res.txt index ff23fed2d..ff23fe834 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: ./node_modules/@rescript/react/src ./node_modules/@rescript/react/src/legacy Source files: ./node_modules/@rescript/react/src/React.res ./node_modules/@rescript/react/src/ReactDOM.res ./node_modules/@rescript/react/src/ReactDOMServer.res ./node_modules/@rescript/react/src/ReactDOMStyle.res ./node_modules/@rescript/react/src/ReactEvent.res ./node_modules/@rescript/react/src/ReactEvent.resi ./node_modules/@rescript/react/src/ReactTestUtils.res ./node_modules/@rescript/react/src/ReactTestUtils.resi ./node_modules/@rescript/react/src/RescriptReactErrorBoundary.res ./node_modules/@rescript/react/src/RescriptReactErrorBoundary.resi ./node_modules/@rescript/react/src/RescriptReactRouter.res ./node_modules/@rescript/react/src/RescriptReactRouter.resi ./node_modules/@rescript/react/src/legacy/ReactDOMRe.res ./node_modules/@rescript/react/src/legacy/ReasonReact.res Source directories: ./src ./src/expected -Source files: ./src/Auto.res ./src/CompletePrioritize1.res ./src/CompletePrioritize2.res ./src/Completion.res ./src/Component.res ./src/Component.resi ./src/CreateInterface.res ./src/Cross.res ./src/Dce.res ./src/Debug.res ./src/Definition.res ./src/DefinitionWithInterface.res ./src/DefinitionWithInterface.resi ./src/Div.res ./src/DocumentSymbol.res ./src/Fragment.res ./src/Highlight.res ./src/Hover.res ./src/Jsx.res ./src/Jsx.resi ./src/LongIdentTest.res ./src/Object.res ./src/Patterns.res ./src/RecModules.res ./src/RecordCompletion.res ./src/RecoveryOnProp.res ./src/References.res ./src/ReferencesWithInterface.res ./src/ReferencesWithInterface.resi ./src/Rename.res ./src/RenameWithInterface.res ./src/RenameWithInterface.resi ./src/TableclothMap.ml ./src/TableclothMap.mli ./src/TypeDefinition.res ./src/Xform.res +Source files: ./src/Auto.res ./src/CompletePrioritize1.res ./src/CompletePrioritize2.res ./src/Completion.res ./src/Component.res ./src/Component.resi ./src/CreateInterface.res ./src/Cross.res ./src/Dce.res ./src/Debug.res ./src/Definition.res ./src/DefinitionWithInterface.res ./src/DefinitionWithInterface.resi ./src/Div.res ./src/DocumentSymbol.res ./src/Fragment.res ./src/Highlight.res ./src/Hover.res ./src/InlayHint.res ./src/Jsx.res ./src/Jsx.resi ./src/LongIdentTest.res ./src/Object.res ./src/Patterns.res ./src/RecModules.res ./src/RecordCompletion.res ./src/RecoveryOnProp.res ./src/References.res ./src/ReferencesWithInterface.res ./src/ReferencesWithInterface.resi ./src/Rename.res ./src/RenameWithInterface.res ./src/RenameWithInterface.resi ./src/TableclothMap.ml ./src/TableclothMap.mli ./src/TypeDefinition.res ./src/Xform.res Impl cmt:./lib/bs/src/Auto.cmt res:./src/Auto.res Impl cmt:./lib/bs/src/CompletePrioritize1.cmt res:./src/CompletePrioritize1.res Impl cmt:./lib/bs/src/CompletePrioritize2.cmt res:./src/CompletePrioritize2.res @@ -21,6 +21,7 @@ Impl cmt:./lib/bs/src/DocumentSymbol.cmt res:./src/DocumentSymbol.res Impl cmt:./lib/bs/src/Fragment.cmt res:./src/Fragment.res Impl cmt:./lib/bs/src/Highlight.cmt res:./src/Highlight.res Impl cmt:./lib/bs/src/Hover.cmt res:./src/Hover.res +Impl cmt:./lib/bs/src/InlayHint.cmt res:./src/InlayHint.res IntfAndImpl cmti:./lib/bs/src/Jsx.cmti resi:./src/Jsx.resi cmt:./lib/bs/src/Jsx.cmt res:./src/Jsx.res Impl cmt:./lib/bs/src/LongIdentTest.cmt res:./src/LongIdentTest.res Impl cmt:./lib/bs/src/Object.cmt res:./src/Object.res diff --git a/analysis/tests/src/expected/InlayHint.res.txt b/analysis/tests/src/expected/InlayHint.res.txt new file mode 100644 index 000000000..a129f9331 --- /dev/null +++ b/analysis/tests/src/expected/InlayHint.res.txt @@ -0,0 +1,39 @@ +Inlay Hint src/InlayHint.res 0:6 +[{ + "position": {"line": 5, "character": 15}, + "label": ": int", + "kind": 1, + "paddingLeft": true, + "paddingRight": false +}, { + "position": {"line": 5, "character": 12}, + "label": ": int", + "kind": 1, + "paddingLeft": true, + "paddingRight": false +}, { + "position": {"line": 3, "character": 8}, + "label": ": char", + "kind": 1, + "paddingLeft": true, + "paddingRight": false +}, { + "position": {"line": 2, "character": 9}, + "label": ": float", + "kind": 1, + "paddingLeft": true, + "paddingRight": false +}, { + "position": {"line": 1, "character": 10}, + "label": ": int", + "kind": 1, + "paddingLeft": true, + "paddingRight": false +}, { + "position": {"line": 0, "character": 10}, + "label": ": string", + "kind": 1, + "paddingLeft": true, + "paddingRight": false +}] + diff --git a/client/src/extension.ts b/client/src/extension.ts index 4b57f6ddf..95d3387de 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -235,6 +235,17 @@ export function activate(context: ExtensionContext) { // Start the client. This will also launch the server client.start(); + // Restart the language client automatically when certain configuration + // changes. These are typically settings that affect the capabilities of the + // language client, and because of that requires a full restart. + context.subscriptions.push( + workspace.onDidChangeConfiguration(({ affectsConfiguration }) => { + if (affectsConfiguration("rescript.settings.inlayHints")) { + commands.executeCommand("rescript-vscode.restart_language_server"); + } + }) + ); + // Autostart code analysis if wanted if ( workspace diff --git a/package.json b/package.json index 7632ddfa7..f24f89a35 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,20 @@ "default": false, "description": "Automatically start ReScript's code analysis." }, + "rescript.settings.inlayHints.enable": { + "type": "boolean", + "default": false, + "description": "Enable (experimental) inlay hints." + }, + "rescript.settings.inlayHints.maxLength": { + "markdownDescription": "Maximum length of character for inlay hints. Set to null to have an unlimited length. Inlay hints that exceed the maximum length will not be shown.", + "default": 25, + "type": [ + "null", + "integer" + ], + "minimum": 0 + }, "rescript.settings.binaryPath": { "type": ["string", "null"], "default": null, diff --git a/server/src/server.ts b/server/src/server.ts index a6ef8038a..542d1010b 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -12,6 +12,7 @@ import { DidCloseTextDocumentNotification, DidChangeConfigurationNotification, InitializeParams, + InlayHintParams, } from "vscode-languageserver-protocol"; import * as utils from "./utils"; import * as codeActions from "./codeActions"; @@ -25,10 +26,18 @@ import { filesDiagnostics } from "./utils"; interface extensionConfiguration { askToStartBuild: boolean; + inlayHints: { + enable: boolean; + maxLength: number | null; + }; binaryPath: string | null; } let extensionConfiguration: extensionConfiguration = { askToStartBuild: true, + inlayHints: { + enable: false, + maxLength: 25 + }, binaryPath: null, }; let pullConfigurationPeriodically: NodeJS.Timeout | null = null; @@ -63,7 +72,7 @@ let projectsFiles: Map< let codeActionsFromDiagnostics: codeActions.filesCodeActions = {}; // will be properly defined later depending on the mode (stdio/node-rpc) -let send: (msg: p.Message) => void = (_) => {}; +let send: (msg: p.Message) => void = (_) => { }; let findBinaryDirPathFromProjectRoot = ( directory: p.DocumentUri // This must be a directory and not a file! @@ -217,6 +226,9 @@ let compilerLogsWatcher = chokidar .on("all", (_e, changedPath) => { sendUpdatedDiagnostics(); sendCompilationFinishedMessage(); + if (extensionConfiguration.inlayHints.enable === true) { + sendInlayHintsRefresh(); + } }); let stopWatchingCompilerLog = () => { // TODO: cleanup of compilerLogs? @@ -378,6 +390,27 @@ function hover(msg: p.RequestMessage) { return response; } +function inlayHint(msg: p.RequestMessage) { + const params = msg.params as p.InlayHintParams; + const filePath = fileURLToPath(params.textDocument.uri); + + const response = utils.runAnalysisCommand( + filePath, + ["inlayHint", filePath, params.range.start.line, params.range.end.line, extensionConfiguration.inlayHints.maxLength], + msg + ); + return response; +} + +function sendInlayHintsRefresh() { + let request: p.RequestMessage = { + jsonrpc: c.jsonrpcVersion, + method: p.InlayHintRefreshRequest.method, + id: serverSentRequestIdCounter++, + }; + send(request); +} + function definition(msg: p.RequestMessage) { // https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_definition let params = msg.params as p.DefinitionParams; @@ -923,6 +956,15 @@ function onMessage(msg: p.Message) { }; send(response); } else if (msg.method === "initialize") { + // Save initial configuration, if present + let initParams = msg.params as InitializeParams; + let initialConfiguration = initParams.initializationOptions + ?.extensionConfiguration as extensionConfiguration | undefined; + + if (initialConfiguration != null) { + extensionConfiguration = initialConfiguration; + } + // send the list of features we support let result: p.InitializeResult = { // This tells the client: "hey, we support the following operations". @@ -958,6 +1000,7 @@ function onMessage(msg: p.Message) { // TODO: Support range for full, and add delta support full: true, }, + inlayHintProvider: extensionConfiguration.inlayHints.enable, }, }; let response: p.ResponseMessage = { @@ -972,26 +1015,6 @@ function onMessage(msg: p.Message) { askForAllCurrentConfiguration(); }, c.pullConfigurationInterval); - // Save initial configuration, if present - let initParams = msg.params as InitializeParams; - let initialConfiguration = initParams.initializationOptions - ?.extensionConfiguration as extensionConfiguration | undefined; - - if (initialConfiguration != null) { - extensionConfiguration = initialConfiguration; - if ( - extensionConfiguration.binaryPath != null && - extensionConfiguration.binaryPath[0] === "~" - ) { - // What should happen if the path contains the home directory symbol? - // This situation is handled below, but maybe it isn't the best option. - extensionConfiguration.binaryPath = path.join( - os.homedir(), - extensionConfiguration.binaryPath.slice(1) - ); - } - } - send(response); } else if (msg.method === "initialized") { // sent from client after initialize. Nothing to do for now @@ -1057,6 +1080,12 @@ function onMessage(msg: p.Message) { send(createInterface(msg)); } else if (msg.method === openCompiledFileRequest.method) { send(openCompiledFile(msg)); + } else if (msg.method === p.InlayHintRequest.method) { + let params = msg.params as InlayHintParams; + let extName = path.extname(params.textDocument.uri); + if (extName === c.resExt) { + send(inlayHint(msg)); + } } else { let response: p.ResponseMessage = { jsonrpc: c.jsonrpcVersion,