diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e30a5cc22c7..f631c3c3bdc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,15 +68,13 @@ jobs: exe-suffix: ".exe" dune-profile: release + # Disable for now. eio and eio_main require ocaml >= 5.2.0 # Verify that the compiler still builds with the oldest OCaml version we support. - # Steps comparing OCaml and Rust rewatch are skipped on this job; the - # first linux-x64 job runs them. OCaml 5.0 does not build with GCC 15 - # (C23 by default), so this job stays on Ubuntu 24.04. - - os: ubuntu-24.04 - ocaml_compiler: ocaml-variants.5.0.0+options,ocaml-option-static - node-target: linux-x64 - rust-target: x86_64-unknown-linux-musl - dune-profile: static + # - os: ubuntu-24.04 + # ocaml_compiler: ocaml-variants.5.0.0+options,ocaml-option-static + # node-target: linux-x64 + # rust-target: x86_64-unknown-linux-musl + # dune-profile: static runs-on: ${{matrix.os}} @@ -109,6 +107,56 @@ jobs: exe-suffix: ${{ matrix.exe-suffix }} run-quality-checks: "true" + # The static OCaml switch uses musl-gcc. linux-libc-dev installs Linux + # headers under /usr/include, but musl-gcc searches the musl include dir. + # Link the Linux headers into musl's include path so packages with C stubs + # such as uring can include headers. + - name: Make Linux headers visible to musl-gcc + if: runner.os == 'Linux' + run: | + set -eux + + # Get the GNU multiarch triplet for the current machine. + # Examples: + # x86_64-linux-gnu + # aarch64-linux-gnu + GNU_MULTIARCH="$(gcc -print-multiarch)" + + # Convert the GNU triplet into the musl include directory name. + # Examples: + # x86_64-linux-gnu -> x86_64-linux-musl + # aarch64-linux-gnu -> aarch64-linux-musl + MUSL_MULTIARCH="${GNU_MULTIARCH%-gnu}-musl" + + # musl-gcc searches this include directory. + MUSL_INCLUDE="/usr/include/${MUSL_MULTIARCH}" + + # Linux arch-specific asm headers are installed here by linux-libc-dev. + GNU_ASM="/usr/include/${GNU_MULTIARCH}/asm" + + # Ensure the musl include directory exists. + sudo mkdir -p "$MUSL_INCLUDE" + + # Remove old paths first. + # This avoids silently keeping broken/stale symlinks from previous runs. + sudo rm -rf "$MUSL_INCLUDE/linux" + sudo rm -rf "$MUSL_INCLUDE/asm" + sudo rm -rf "$MUSL_INCLUDE/asm-generic" + + # Expose Linux UAPI headers to musl-gcc. + # This fixes packages that include headers like . + sudo ln -s /usr/include/linux "$MUSL_INCLUDE/linux" + + # Expose architecture-specific asm headers to musl-gcc. + sudo ln -s "$GNU_ASM" "$MUSL_INCLUDE/asm" + + # Expose generic asm headers used by many Linux headers. + sudo ln -s /usr/include/asm-generic "$MUSL_INCLUDE/asm-generic" + + # Smoke test: fail early if musl-gcc still cannot find Linux headers. + echo '#include ' > /tmp/test.c + musl-gcc -c /tmp/test.c -o /tmp/test.o + - name: Setup OCaml ${{ matrix.ocaml_compiler }} id: setup_ocaml uses: ./.github/actions/setup-ocaml @@ -212,6 +260,11 @@ jobs: if: runner.os != 'Windows' run: make -C tests/gentype_tests/typescript-react-example clean test + # The Makefile skip some tests on Windows because OCaml Eio process operations is not supported on Windows yet + # Eio.Stdenv.process_mgr raise a error, see https://github.com/ocaml-multicore/eio/blob/37d6e67f7e25b43e4a66574ed98838c79f1a21b4/lib_eio_windows/eio_windows.ml#L36 + - name: Run LSP tests + run: opam exec -- make test-lsp + # On Windows, after running setup-ocaml (if it wasn't cached yet or the cache couldn't be restored), # Cygwin bash is used instead of Git Bash for Windows, breaking the rewatch tests. # So we need to adjust the path to bring back Git Bash for Windows. @@ -525,7 +578,7 @@ jobs: uses: actions/checkout@v7 - name: Run make in dev container uses: devcontainers/ci@v0.3 - with: + with: push: never runCmd: make diff --git a/Makefile b/Makefile index a49799ae4bb..71ce43e652b 100644 --- a/Makefile +++ b/Makefile @@ -103,7 +103,7 @@ clean-rewatch: # Compiler -COMPILER_SOURCE_DIRS := compiler tests analysis tools rewatch-ocaml +COMPILER_SOURCE_DIRS := compiler tests analysis tools rewatch-ocaml lsp COMPILER_SOURCES = $(shell find $(COMPILER_SOURCE_DIRS) -type f \( -name '*.ml' -o -name '*.mli' -o -name '*.c' -o -name '*.h' -o -name '*.dune' -o -name dune -o -name dune-project \)) COMPILER_BIN_NAMES := bsc rescript-editor-analysis rescript-tools rescript COMPILER_EXES := $(addsuffix .exe,$(addprefix $(BIN_DIR)/,$(COMPILER_BIN_NAMES))) @@ -188,10 +188,10 @@ test-gentype: lib test-rewatch: lib ./rewatch/tests/suite.sh $(RESCRIPT_EXE) -test-rewatch-rust: lib rewatch - ./rewatch/tests/suite.sh $(RESCRIPT_RUST_EXE) +test-lsp: lib + @dune runtest tests/lsp_tests -test-all: test test-gentype test-analysis test-tools test-rewatch +test-all: test test-gentype test-analysis test-tools test-rewatch test-lsp # Playground diff --git a/analysis/src/code_actions.ml b/analysis/src/code_actions.ml index c876bc7e52e..e8c7cd7b77b 100644 --- a/analysis/src/code_actions.ml +++ b/analysis/src/code_actions.ml @@ -2,7 +2,7 @@ let make ~title ~kind ~uri ~new_text ~range = let text_document = - Lsp.Types.OptionalVersionedTextDocumentIdentifier.create + Lsp.Types.OptionalVersionedTextDocumentIdentifier.create ~version:0 ~uri:(Uri.from_string uri) () in let edit = diff --git a/analysis/src/diagnostics.ml b/analysis/src/diagnostics.ml index fa1fc720c86..b0aaa64109e 100644 --- a/analysis/src/diagnostics.ml +++ b/analysis/src/diagnostics.ml @@ -16,7 +16,7 @@ let document_syntax ~source ~kind_file = ~end_: (Lsp.Types.Position.create ~line:(endline - 1) ~character:endcol) in - Lsp.Types.Diagnostic.create ~range + Lsp.Types.Diagnostic.create ~range ~source:"ReScript" ~message:(`String (Res_diagnostics.explain diagnostic)) ~severity:Lsp.Types.DiagnosticSeverity.Error ()) in diff --git a/analysis/src/markdown.ml b/analysis/src/markdown.ml index 4bc7203a171..b5254b9b257 100644 --- a/analysis/src/markdown.ml +++ b/analysis/src/markdown.ml @@ -4,13 +4,9 @@ let divider = "\n---\n" type link = {start_pos: Lsp.Types.Position.t; file: string; label: string} -let link_to_command_args link = - Printf.sprintf "[\"%s\",%i,%i]" link.file link.start_pos.line - link.start_pos.character - let make_goto_command link = - Printf.sprintf "[%s](command:rescript-vscode.go_to_location?%s)" link.label - (Uri.encode_uri_component (link_to_command_args link)) + Printf.sprintf "[%s](%s#L%d,%d)" link.label link.file (link.start_pos.line + 1) + (link.start_pos.character + 1) let go_to_definition_text ~env ~pos = let start_line, start_col = Pos.of_lexing pos in diff --git a/analysis/src/module_resolution.ml b/analysis/src/module_resolution.ml index 459ae3f69f9..48fd1e83b16 100644 --- a/analysis/src/module_resolution.ml +++ b/analysis/src/module_resolution.ml @@ -1,18 +1,51 @@ let ( /+ ) = Filename.concat -let rec resolve_node_module_path ~start_path name = - if name = "@rescript/runtime" then - (* Hack: we need a reliable way to resolve modules in monorepos. *) - Some (Runtime_package.get_path ()) +let package_path name = + let scope = Filename.dirname name in + let name = Filename.basename name in + match scope.[0] with + | '@' -> scope /+ name + | _ -> name + +let resolve_deno_pnpm ~start_path name = + let create_path folder = + start_path /+ "node_modules" /+ folder /+ "node_modules" + /+ package_path name + in + let deno = create_path ".deno" in + let pnpm = create_path ".pnpm" in + match (Files.if_exists deno, Files.if_exists pnpm) with + | Some deno, None -> Some deno + | None, Some pnpm -> Some pnpm + | Some deno, Some pnpm -> + failwith + (Printf.sprintf + "Failed to resolve path for %s. Conflict: two directories found. one \ + for Deno (%s) and one for pnpm (%s)." + name deno pnpm) + | _ -> None + +let rec resolve_from_node_modules ~start_path name = + let path = start_path /+ "node_modules" /+ package_path name in + if Files.exists path then Some path else - let scope = Filename.dirname name in - let name = Filename.basename name in - let name = - match scope.[0] with - | '@' -> scope /+ name - | _ -> name - in - let path = start_path /+ "node_modules" /+ name in - if Files.exists path then Some path - else if Filename.dirname start_path = start_path then None - else resolve_node_module_path ~start_path:(Filename.dirname start_path) name + match resolve_deno_pnpm ~start_path name with + | Some p -> Some p + | None -> + if Filename.dirname start_path = start_path then None + else + resolve_from_node_modules ~start_path:(Filename.dirname start_path) name + +let resolve_node_module_path ?(fallback_to_runtime_package = true) ~start_path + name = + match resolve_from_node_modules ~start_path name with + | Some path -> Some path + | None + when fallback_to_runtime_package + && name = Runtime_package.name + && Files.exists (Runtime_package.get_path ()) -> + (* Compiler-local fallback for development builds where the runtime package + is not installed in the target workspace. Real project installs must win + so LSP definition locations point at the workspace's node_modules. *) + Some (Runtime_package.get_path ()) + | None -> None diff --git a/analysis/src/shared_types.ml b/analysis/src/shared_types.ml index 5333f408325..08f3d6cbc4e 100644 --- a/analysis/src/shared_types.ml +++ b/analysis/src/shared_types.ml @@ -979,3 +979,108 @@ let extract_exp_apply_args ~args = | [] -> List.rev acc in args |> process_args ~acc:[] + +let state_to_yojson (state : state) = + let option_to_yojson f = function + | None -> `Null + | Some value -> f value + in + + let string_set_to_yojson set = + `List (set |> File_set.elements |> List.map (fun value -> `String value)) + in + + let path_to_yojson path = `List (List.map (fun item -> `String item) path) in + + let paths_to_yojson = function + | Impl {cmt; res} -> + `Assoc + [("kind", `String "Impl"); ("cmt", `String cmt); ("res", `String res)] + | Namespace {cmt} -> + `Assoc [("kind", `String "Namespace"); ("cmt", `String cmt)] + | IntfAndImpl {cmti; resi; cmt; res} -> + `Assoc + [ + ("kind", `String "IntfAndImpl"); + ("cmti", `String cmti); + ("resi", `String resi); + ("cmt", `String cmt); + ("res", `String res); + ] + in + + let paths_for_module_to_yojson paths_for_module = + paths_for_module |> Hashtbl.to_seq + |> Seq.map (fun (file, paths) -> (file, paths_to_yojson paths)) + |> List.of_seq + |> fun fields -> `Assoc fields + in + + let autocomplete_to_yojson autocomplete = + autocomplete |> Misc.String_map.bindings + |> List.map (fun (name, files) -> + (name, `List (List.map (fun file -> `String file) files))) + |> fun fields -> `Assoc fields + in + + let package_to_yojson (package : package) = + let major, minor = package.rescript_version in + `Assoc + [ + ( "generic_jsx_module", + option_to_yojson + (fun value -> `String value) + package.generic_jsx_module ); + ("suffix", `String package.suffix); + ("root_path", `String package.root_path); + ("project_files", string_set_to_yojson package.project_files); + ( "dependencies", + `List (package.dependencies |> List.map (fun x -> `String x)) ); + ("dependencies_files", string_set_to_yojson package.dependencies_files); + ("paths_for_module", paths_for_module_to_yojson package.paths_for_module); + ( "namespace", + option_to_yojson (fun value -> `String value) package.namespace ); + ("opens", `List (List.map path_to_yojson package.opens)); + ( "rescript_version", + `Assoc [("major", `Int major); ("minor", `Int minor)] ); + ("autocomplete", autocomplete_to_yojson package.autocomplete); + ] + in + + let file_to_yojson (file : File.t) = + `Assoc + [ + ("uri", `String (file.uri |> Lsp.Uri.to_string)); + ("module_name", `String file.module_name); + ("stamps_count", `Int (List.length (Stamps.get_entries file.stamps))); + ("structure_name", `String file.structure.name); + ( "structure_docstring", + `List (List.map (fun value -> `String value) file.structure.docstring) + ); + ("structure_items_count", `Int (List.length file.structure.items)); + ] + in + + let cmt_cache = + state.cmt_cache |> Hashtbl.to_seq + |> Seq.map (fun (file_path, file) -> (file_path, file_to_yojson file)) + |> List.of_seq + in + + let root_for_uri = + state.root_for_uri |> Hashtbl.to_seq |> List.of_seq + |> List.map (fun (uri, str) -> [(Lsp.Uri.to_string uri, `String str)]) + |> List.flatten + in + + let packages_by_root = + state.packages_by_root |> Hashtbl.to_seq |> List.of_seq + |> List.map (fun (root, package) -> (root, package_to_yojson package)) + in + + `Assoc + [ + ("cmt_cache", `Assoc cmt_cache); + ("root_for_uri", `Assoc root_for_uri); + ("packages_by_root", `Assoc packages_by_root); + ] diff --git a/analysis/src/uri.ml b/analysis/src/uri.ml index f4fbc98b6c3..143b97c8fbd 100644 --- a/analysis/src/uri.ml +++ b/analysis/src/uri.ml @@ -24,37 +24,3 @@ let to_top_level_loc (uri : Lsp.Uri.t) = let to_string t = if !strip_path then Filename.basename (Lsp.Uri.to_path t) else Lsp.Uri.to_string t - -(* Light weight, hopefully-enough-for-the-purpose fn to encode URI components. - Built to handle the reserved characters listed in - https://en.wikipedia.org/wiki/Percent-encoding. Note that this function is not - general purpose, rather it's currently only for URL encoding the argument list - passed to command links in markdown. *) -let encode_uri_component text = - let ln = String.length text in - let buf = Buffer.create ln in - let rec loop i = - if i < ln then ( - (match text.[i] with - | '"' -> Buffer.add_string buf "%22" - | '\'' -> Buffer.add_string buf "%22" - | ':' -> Buffer.add_string buf "%3A" - | ';' -> Buffer.add_string buf "%3B" - | '/' -> Buffer.add_string buf "%2F" - | '\\' -> Buffer.add_string buf "%5C" - | ',' -> Buffer.add_string buf "%2C" - | '&' -> Buffer.add_string buf "%26" - | '[' -> Buffer.add_string buf "%5B" - | ']' -> Buffer.add_string buf "%5D" - | '#' -> Buffer.add_string buf "%23" - | '$' -> Buffer.add_string buf "%24" - | '+' -> Buffer.add_string buf "%2B" - | '=' -> Buffer.add_string buf "%3D" - | '?' -> Buffer.add_string buf "%3F" - | '@' -> Buffer.add_string buf "%40" - | '%' -> Buffer.add_string buf "%25" - | c -> Buffer.add_char buf c); - loop (i + 1)) - in - loop 0; - Buffer.contents buf diff --git a/analysis/src/uri.mli b/analysis/src/uri.mli new file mode 100644 index 00000000000..5c79077f7d2 --- /dev/null +++ b/analysis/src/uri.mli @@ -0,0 +1,9 @@ +type t = Lsp.Uri.t + +val from_path : string -> t +val from_string : string -> t +val is_interface : t -> bool +val strip_path : bool ref +val to_path : t -> string +val to_string : t -> string +val to_top_level_loc : t -> Location.t diff --git a/changes/opam-ppx-expect.internal.md b/changes/opam-ppx-expect.internal.md new file mode 100644 index 00000000000..16f68504462 --- /dev/null +++ b/changes/opam-ppx-expect.internal.md @@ -0,0 +1 @@ +- Update ppx_expect to v0.17.3 to resolve test dependencies with ppxlib 0.38 and OCaml 5.5. diff --git a/dune b/dune index 526e18fbc01..43ecb90b567 100644 --- a/dune +++ b/dune @@ -1,4 +1,4 @@ -(dirs compiler tests analysis tools rewatch-ocaml) +(dirs compiler tests analysis tools rewatch-ocaml lsp) ; All warnings are enabled, except: ; - 4 (fragile-match): wildcard cases are used deliberately. diff --git a/dune-project b/dune-project index dafd9675b9a..56f25c08463 100644 --- a/dune-project +++ b/dune-project @@ -78,3 +78,21 @@ (yojson (= 3.0.0)) (odoc :with-doc))) + +(package + (name rescript-language-server) + (synopsis "ReScript LSP") + (depends + (ocaml + (>= 5.0.0)) + (lsp + (>= 1.22.0)) + (eio + (>= 1.3)) + (eio_main + (>= 1.3)) + analysis + dune + ppx_deriving_yojson + (ppx_expect + (and :with-test (= v0.17.3))))) diff --git a/lsp/lsp.md b/lsp/lsp.md new file mode 100644 index 00000000000..5ae508a84c1 --- /dev/null +++ b/lsp/lsp.md @@ -0,0 +1,362 @@ +> Very experimental, WIP!! + +This branch introduces a standalone ReScript LSP server +(`rescript lsp` subcommand) built on top of the existing `analysis` library. +It's a separate, OCaml-side exploration alongside the Rust/rewatch-based +experiment in #8243, the two share the same goal (a LSP server for ReScript) but +approach it from different ends of the toolchain. + +## Why rewriting the server in OCaml is a good fit + +The editor features are already implemented in the OCaml analysis library. +Hover, completion, references, rename, document symbols, code actions, and +diagnostics all depend on compiler data structures such as `.cmt` / `.cmti`, +typed trees, locations, package metadata, and compiler diagnostics. Running the +LSP server in OCaml lets the server call those APIs directly instead of shelling +out to `rescript-editor-analysis.exe` or passing JSON through stdin. That +removes a large amount of process orchestration and serialization glue, and +keeps the LSP closer to the compiler types it needs to understand. This also +makes the server easier to evolve with the compiler. When typedtree, diagnostic +formats, package discovery, or `.cmt` handling changes, the LSP can be updated +in the same language and build system as the analysis code. That reduces version +skew and makes bugs easier to reproduce with Dune/expect tests. + +## Why ship the language server with the compiler + +The language server is tightly coupled to the compiler version used by a +project. It reads `.cmt` and `.cmti` files containing marshalled compiler +internals such as `Typedtree`, `Types`, and `Env`. These files are not a stable, +schema-aware protocol: changing one of those OCaml types can make an analysis +binary incompatible with artifacts produced by another compiler revision. + +This is not only a theoretical compatibility concern. In +[#8475](https://github.com/rescript-lang/rescript/issues/8475), adding typedtree +constructors for `for...of` without changing the CMT magic number allowed a +newer analysis binary to read incompatible ReScript 12 artifacts and +segmentation fault. The same class of failure appeared after record-rest +changes altered compiler data stored in CMT files. Bumping the CMT magic number +makes these mismatches rejectable, but the project must still be rebuilt with +the matching compiler. + +Shipping the server in the same platform package as the compiler, and exposing +it through `rescript lsp --stdio`, gives clients one project-local entrypoint. +The selected ReScript dependency then supplies `bsc`, the analysis code, and the +language server from the same release. This has several practical benefits: + +- Editors do not need to install or select a separate language-server version. +- Projects pinned to different ReScript versions get the corresponding server. +- Compiler, analysis, and LSP changes can be released and upgraded atomically. +- The native `rescript-language-server.exe` path remains a packaging detail. + +Bundling does not make stale artifacts safe. Compiler changes that alter +marshalled CMT data must still bump the magic number, and the server should turn +version mismatches into an actionable request to rebuild. A separately +versioned server becomes practical only if analysis moves to a stable, +explicitly versioned artifact format or maintains decoders for multiple +compiler-internal formats. Until then, promising one server binary that supports +multiple compiler versions would hide a compatibility boundary that already +exists. + +## What's here + +- New `lsp/` package split into `lsp/bin` (entrypoint) and `lsp/src` (library), + with its own opam file (`rescript-language-server.opam`). Depends on `lsp` (>= + 1.22.0), `eio`/`eio_main`, `ppx_deriving_yojson`, `ppx_expect` and the in-tree + `analysis` library. +- New rescript command `rescript lsp`. The language server is shipped with the + compiler. + +## Status / what's not here yet + +The main objective is to first maintain feature parity with the current server +(server.ts) as much as possible. Below is a list of some requests and +notifications. Some are out of scope because they don't make sense. + +- [x] `initialize` - client request +- [x] `initialized` - client notification +- [x] `shutdown` - client request +- [x] `exit` - client notification +- [x] `textDocument/didOpen` - client notification +- [x] `textDocument/didChange` - client notification + - Incremental change +- [x] `textDocument/didClose` - client notification +- [ ] `textDocument/didSave` - client notification - **It will not be + implemented for now.** +- [x] `textDocument/hover` - client request +- [x] `textDocument/publishDiagnostics` - server notification + - [x] More work was needed for some kinds of errors. How should circular + dependency errors be published? + - Circular dependency diagnostics are special-cased because the compiler log + does not point at a precise source range. When the document is open, we + expand the diagnostic range to cover the whole document so the editor can + display a file-level diagnostic. If the document is not open, we keep the + range parsed from the compiler log, i.e., + `{start: {line: 0, character: 0}, end: {line: 0, character: 6}}` + - The shortest possible code has at least 7 characters: `let a=1`. + Protocol positions are zero-based, so the position is `length - 1`. + - [x] Use `Analysis` for syntax errors and ignore them in the compiler log + parser. + - This lets the server publish syntax errors on `TextDocumentDidChange` and + provide instant feedback. + - [x] Compiler-log diagnostics from `.compiler.log` - server feature + - [x] Add more test cases; see `compiler_log.ml`. See `tests/build_tests` + for more examples. + - [ ] Remove support for parsing OCaml messages? + - [x] Monorepo diagnostics via `.sourcedirs.json` - server feature + - Requires ReScript v12.1.0. In this version, `.sourcedirs.json` is always + generated with `build_root` field for each subpackage. + - [x] Add the warning number to the diagnostic message. +- [x] `workspace/didChangeWatchedFiles` - client notification + - The server uses this notification to detect changes to generated + `.compiler.log` files, which usually means a ReScript build has finished. + When a watched log changes, the server re-reads every known compiler log and + republishes diagnostics so stale errors are cleared and monorepo diagnostics + stay in sync. + - Use `GlobPattern` with `baseUri` (workspace root)? + - Should we watch all `rescript.json` files in the workspace? + - Some features require data defined in `rescript.json`. For example, + `suffix` and `package-specs` are needed to create the code action + `Open compiled JS file`. Changes in `dependencies` impact various + functionalities because they modify the state in + `Analysis.Shared_types.state`. + - Users can restart the server after changing the configuration. +- [x] `client/registerCapability` - server request + - We use this because `workspace/didChangeWatchedFiles` is commonly registered + dynamically. The server does not know all file-watch patterns during the + static `initialize` response. In this LSP, the watcher list depends on + project state, especially `.sourcedirs.json`, which tells us where each + ReScript build root lives. In monorepos, that means the server needs to + register watchers for compiler logs after initialization, once it has + workspace context. That means watching generated `.compiler.log` files. When + the client sees one change, it sends `workspace/didChangeWatchedFiles`, and + the server refreshes diagnostics. +- [x] `textDocument/diagnostic` - client request +- [x] `textDocument/completion` - client request +- [x] `completionItem/resolve` - client request +- [x] `textDocument/signatureHelp` - client request +- [x] `textDocument/definition` - client request +- [ ] `textDocument/declaration` - client request - **It will not be implemented + for now.** +- [x] `textDocument/typeDefinition` - client request +- [ ] `textDocument/implementation` - client request - **It will not be + implemented for now.** +- [x] `textDocument/references` - client request + - 🐛 Neovim and Zed kill the server. I need to investigate. Fixed in + https://github.com/rescript-lang/rescript/pull/8477 +- [ ] `textDocument/documentHighlight` - client request - **It will not be + implemented for now.** +- [x] `textDocument/documentSymbol` - client request + - Zed needs the `"document_symbols": "on"` configuration. When enabled, + tree-sitter is not used for document symbols. +- [ ] `workspace/symbol` - client request - **It will not be implemented for + now.** +- [x] `textDocument/codeAction` - client request + - [x] Code actions from analysis + - [x] Code actions from diagnostics (refactor, quick fixes, etc) + - https://github.com/rescript-lang/rescript-vscode/pull/373 + - [x] Open compiled js file (Trigger workspace execute command + `rescript/openCompiled`) + - Available if the client supports `window/showDocument`. + - [x] Create interface file (Trigger workspace execute command + `rescript/createInterface`) + - Available if the interface does not exist. + - [x] Switch to implementation/interface (Trigger workspace execute command + `rescript/switchImplementationInterface`) + - Available if the client supports the `window/showDocument` request. +- [ ] `codeAction/resolve` - client request - **It will not be implemented for + now.** +- [x] `textDocument/codeLens` - client request +- [x] `workspace/codeLens/refresh` - server request +- [ ] `codeLens/resolve` - client request - **It will not be implemented for + now.** +- [x] `textDocument/inlayHint` - client request +- [ ] `inlayHint/resolve` - client request - **It will not be implemented for + now.** +- [x] `textDocument/semanticTokens/full` - client request +- [ ] `textDocument/semanticTokens/full/delta` - client request - **It will not + be implemented for now.** +- [ ] `textDocument/semanticTokens/range` - client request - **It will not be + implemented for now.** +- [x] `textDocument/rename` - client request +- [x] `textDocument/prepareRename` - client request +- [x] `textDocument/formatting` - client request +- [ ] `textDocument/rangeFormatting` - client request - **It will not be + implemented for now.** +- [ ] `textDocument/onTypeFormatting` - client request - **It will not be + implemented for now.** +- [ ] `textDocument/foldingRange` - client request - **It will not be + implemented for now.** +- [ ] `textDocument/selectionRange` - client request - **It will not be + implemented for now.** +- [ ] `textDocument/documentLink` - client request - **It will not be + implemented for now.** +- [ ] `documentLink/resolve` - client request - **It will not be implemented for + now.** +- [ ] `textDocument/documentColor` - client request - **It will not be + implemented for now.** +- [ ] `textDocument/linkedEditingRange` - client request - **It will not be + implemented for now.** +- [x] `workspace/didChangeConfiguration` - client notification +- [x] `workspace/configuration` - server request +- [x] `workspace/executeCommand` - client request + - [x] `rescript/dumpServerState` + - Dump the `State.t` (diagnostics, document store, status, configuration, + analysis state (`Analysis.Shared_types.state`) and compiler config). + - [x] `rescript/openCompiled`: Triggered by a code action + - Only works if the client supports `window/showDocument`. + - [x] `rescript/createInterface`: Triggered by a code action + - The server creates the interface if it does not exist and opens the + interface file if the client supports the `window/showDocument` request. + - If the interface file already exists, the server sends an error. + - [x] `rescript/switchImplementationInterface`: Triggered by a code action + - Only available if the client supports the `window/showDocument` request. + - [x] `rescript/dumpCmt`: Dump cmt file content + - [ ] `rescript/dumpParseTree` **It will not be implemented for now.** + - Dump parsed tree + - [ ] `rescript/dumpTypedTree` **It will not be implemented for now.** + - Dump typed tree + - Flow to execute a command: + - ``` + CodeAction.command = "rescript/commandName" + ↓ + workspace/executeCommand handler + ↓ + server sends window/showDocument + ↓ + client opens file + ``` + - Zed does not support the `window/showDocument` request. + https://github.com/zed-industries/zed/discussions/58099. +- [x] Custom requests + - [x] `textDocument/openCompiled` - client request + - [x] `textDocument/createInterface` - client request + - [x] `textDocument/switchImplementationInterface`: Currently it's done on the + client side, but it could be a command triggered by a code action. + - Move to code action feature + - [ ] `rescript/startBuild` - client request - **How the build integration + will be done. See last point**. **It will not be implemented for now** + - Some questions + - Create a setting to automatically start the build watcher during + initialization and drop this custom request? + - When should the build watcher be stopped? On shutdown/exit? + - Create work-done progress? + - Add a custom request to kill the watcher `rescript/stopBuild`? +- [ ] Custom notifications + - [x] `rescript/compilationFinished` - server notification - Sent from the + server to the client when compilation is finished + - The server (`server.ts`) sends this notification, and the VSCode client + uses it to run Code Analysis. The server sends it when `.compiler.log` + changes. Only VSCode uses this notification. + - We can add this notification for compatibility, but in the future, + reanalyze may be integrated into the server instead of running on the + client side. + - We should use progress support `$/progress` server notification? + - https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workDoneProgress + - [ ] `rescript/compilationStatus` - server notification - Sends the + compilation status from the server to the client. + - VSCode uses this notification to show the compilation status in the status + bar. Only VSCode uses this notification. + - We should use progress support `$/progress` server notification? + - https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workDoneProgress +- [x] Channels to support + - The current server (`server.ts`) supports stdio and node-ipc. The VSCode + client uses node-ipc. This implementation supports only stdio. + - [x] stdio + - [ ] socket - **It will not be implemented for now** +- [ ] Support package managers: Deno, Bun, Yarn Plug'n'Play, npm, and pnpm +- [ ] Incremental compilation - server feature +- [ ] Build integration managed by the LSP server - server feature - **It will + not be implemented for now.** + - Build integration is still external: diagnostics come from compiler logs + produced by an existing `rescript build`, rather than the LSP starting or + managing builds itself. +- [ ] First-class compiler support on the server. **It will not be implemented + for now. Idea to explore.** + - Instead of running a `rescript watch` process, it's much harder to compile + in memory? + +## Tests + +`tests/lsp_tests/` adds a dune-driven integration test (`test.ml`) that boots +the server against a real ReScript workspace (`basic-workspace/`), initializes +the LSP session, sends the `initialized` notification, opens source documents, +and snapshots responses for representative fixtures. + +The LSP test target now also runs `dune runtest`, covering inline tests such as +`.sourcedirs.json` build-root, `.compiler.log` parsing used by diagnostics and +file-watcher setup. + +## Breaking Changes + +- No configuration/settings in `initializationOptions`. This server ignores + settings coming from `initializationOptions`. See + https://github.com/microsoft/language-server-protocol/issues/567#issuecomment-448538082. + We use `workspace/configuration`. +- Signature help is enabled by default. + +## Server settings + +Proposed interface. + +- Currently (server.ts), `supportMarkdownLinks` is not a setting. It's a great + feature, but some clients don't have good support; Neovim are a example. + Therefore, I'm promoting it to a setting so users can enable or disable it. + +```ts +/** + * Server settings. + * These configurations are requested by the server using `workspace/configuration`. + */ +interface Settings { + hover?: { + /** + * Enable markdown links in hover responses + * @default false + */ + supportMarkdownLinks?: boolean; + }; + /** + * Enable code lenses for function definitions, showing the full type above each definition + * @default false + */ + codeLens?: boolean; + /** + * Place annotations inline with text to display type hints + */ + inlayHints?: { + /** + * Enable inlay hints + * @default false + */ + enable?: boolean; + /** + * Maximum number of characters for inlay hints. Set to null to have an unlimited length. + * Inlay hints that exceed the maximum length will not be shown + * @default 25 + */ + maxLength?: number | null; + }; +} +``` + +## Release and transition plan + +> How should the server be published? How should developers use the server? + +- Update the VSCode and Zed clients to support the experimental server + - VSCode: https://github.com/rescript-lang/rescript-vscode/pull/1183 + - Zed: https://github.com/rescript-lang/rescript-zed/pull/24 +- Neovim users need to make an adjustment to their LSP setup. + +## Other related topics + +### Refactor analysis for use on the server side + +- Parsing from source (not just files) / decouple I/O from core logic + [#8426](https://github.com/rescript-lang/rescript/pull/8426) + [#8466](https://github.com/rescript-lang/rescript/pull/8466) + [#8478](https://github.com/rescript-lang/rescript/pull/8478) +- Use the `yojson` and `lsp` libraries in the analysis library + [##8436](https://github.com/rescript-lang/rescript/pull/8436) +- Remove global state `Shared_types.state` + [#8465](https://github.com/rescript-lang/rescript/pull/8465) diff --git a/lsp/notes.md b/lsp/notes.md new file mode 100644 index 00000000000..11d5847d7e6 --- /dev/null +++ b/lsp/notes.md @@ -0,0 +1,298 @@ +# Notes + +## Neovim setup + +This section describes how Neovim users can configure the experimental server. +The new server requires some changes to the LSP setup. Use the `root_dir` +handler function instead of `root_markers`. + +```lua +local use_experimental_server = true + +--- You can change the path of binary +--- Search for `node_modules/.bin/rescript` in the current working directory +local new_rescript_ls_cmd = vim.fs.joinpath( + vim.uv.cwd(), + 'node_modules', + '.bin', + 'rescript' +) +local new_rescript_ls_available = vim.fn.executable(new_rescript_ls_cmd) == 1 and use_experimental_server + +local capabilities = vim.lsp.protocol.make_client_capabilities() +-- Enable workspace.didChangeWatchedFiles capabilities +capabilities.workspace.didChangeWatchedFiles.dynamicRegistration = true + +---@param client vim.lsp.Client +---@param bufnr integer +local dump_server_state = function(client, bufnr) + client:exec_cmd({ + title = 'ReScript Dump server state', + command = 'rescript/dumpServerState', + }, { bufnr = bufnr }, function(err, result) + if err then + vim.notify(tostring(err), vim.log.levels.ERROR) + return + end + + if not result or type(result.content) ~= 'string' then + vim.notify('Invalid server response', vim.log.levels.ERROR) + return + end + + local content = result.content + + -- Create a listed scratch buffer. + local dump_buf = vim.api.nvim_create_buf(true, true) + + vim.api.nvim_buf_set_name( + dump_buf, + 'rescriptls://rescript-dump-server-state' + ) + + -- Do not specify the file type to avoid freezing with syntax highlighting using Tree-sitter. + -- The state is a large JSON file. + vim.bo[dump_buf].buftype = 'nofile' + vim.bo[dump_buf].bufhidden = 'wipe' + vim.bo[dump_buf].swapfile = false + + -- Open it in the current window. + vim.api.nvim_set_current_buf(dump_buf) + + -- Fill buffer with content. + local lines = vim.split(content, '\n', { plain = true }) + vim.api.nvim_buf_set_lines(dump_buf, 0, -1, false, lines) + + -- Optional: make it read-only after writing. + vim.bo[dump_buf].modifiable = false + vim.bo[dump_buf].readonly = true + end) +end + +if new_rescript_ls_available then + -- Dot notation defines a new configuration instead of extending one with + -- vim.lsp.config(name, cfg). + ---@types vim.lsp.Config + vim.lsp.config.rescriptls = { + cmd = { new_rescript_ls_cmd, "lsp", "--stdio" }, + filetypes = { 'rescript' }, + -- Prefer root_dir over root_markers for monorepos. root_markers stops at + -- the nearest rescript.json, which may be a package inside the monorepo. + -- The ReScript LSP needs the workspace root instead, because package + -- discovery, lock files, and build roots are resolved from the directory + -- where Neovim was opened. + -- This root_dir callback first finds the repository root, then falls back to + -- the package-manager lockfile root. It only starts the server when that + -- root contains a ReScript project. + root_dir = function(bufnr, on_dir) + local fname = vim.api.nvim_buf_get_name(bufnr) + -- Find the repository root. + local git_dir = + vim.fs.dirname(vim.fs.find('.git', { path = fname, upward = true })[1]) + -- Monorepos usually keep one lock file at the workspace root. + local lock_file_dir = vim.fs.dirname( + vim.fs.find( + { 'yarn.lock', 'package-lock.json', 'deno.lock', 'bun.lock', 'pnpm-lock.yaml' }, + { path = fname, upward = true } + )[1] + ) + + + if git_dir and vim.fs.root(git_dir, 'rescript.json') then + on_dir(git_dir) + elseif lock_file_dir and vim.fs.root(lock_file_dir, 'rescript.json') then + on_dir(lock_file_dir) + end + end, + ---@param client vim.lsp.Client + ---@param bufnr integer + on_attach = function(client, bufnr) + vim.api.nvim_buf_create_user_command( + bufnr, + 'LspDumpServerState', + function() + dump_server_state(client, bufnr) + end, + { desc = 'rescriptls: Dump server state' } + ) + on_attach(client, bufnr) + end, + capabilities = capabilities, + settings = { + rescript = { + hover = { + supportMarkdownLinks = false, + }, + codeLens = false, + inlayHints = { + enable = false, + maxLength = 25, + }, + }, + }, + } +else + vim.lsp.config('rescriptls', { + init_options = { + extensionConfiguration = { + askToStartBuild = false, + codeLens = false, + signatureHelp = { + enable = true, + }, + inlayHints = { + enable = true, + }, + incrementalTypechecking = { + enabled = true, + }, + }, + }, + on_attach = function(client, bufnr) + on_attach(client, bufnr) + end, + }) +end +``` + +## Some strange behaviors with diagnostics when testing the server + +Below I describe the scenarios. I don't know if this is the expected behavior. + +1. I have three files: A, B, and C. Only A is open in the editor. I changed the + type of a variable in A that B and C use. The compiler reported the errors in + files B and C. All good. I edited A to introduce a syntax error but did not + save it. The server now reports three errors: the syntax error in A and the + two type errors in B and C. Then I saved file A with the syntax error. The + server publishes only the syntax diagnostic. The other two are cleared. + - This happens because, when a syntax error is introduced, `.compiler.log` is + changed, and the compiler emits only the syntax error. + - How can we improve this? + +2. Considering the example described above, I now fixed the syntax error in A. + The server publishes the two type errors in B and C. I close A, and the + server does not clear the diagnostics. In this case, all files are closed. + The compiler is running. + - Should we clear diagnostics when closing a document? + +3. I have an open file. I added a syntax error, and the server reports it. All + good. I closed the file, and the server clears the syntax error diagnostic. + This is the opposite behavior from the case above. The compiler is running. + - This happens because we ignore syntax errors coming from the compiler. + +### Codex suggestion + +This is mostly a diagnostic ownership problem. Right now the server mixes two +different sources: + +1. **Editor syntax diagnostics** from open, unsaved documents. +2. **Compiler diagnostics** from `.compiler.log`. + +Those sources have different freshness guarantees, so they should be tracked +separately. + +**1. Saved Syntax Error Clears B/C Type Errors** This happens because +`.compiler.log` becomes incomplete when the compiler stops at the syntax error. +If the server treats that log as a full compiler snapshot, it overwrites +previous compiler diagnostics and clears B/C. + +I would fix this by making compiler-log updates conditional: + +```text +If compiler log contains syntax errors: + parse syntax diagnostics, but do not replace previous compiler diagnostics. + +If compiler log contains non-syntax diagnostics: + replace previous compiler diagnostics with the new snapshot. + +If compiler log is empty / successful: + clear previous compiler diagnostics. +``` + +Since you already ignore compiler syntax errors, the important part is: **do not +let a syntax-only compiler log clear the previous type-check diagnostics**. + +Tradeoff: B/C diagnostics may be stale while A has a syntax error. But that is +better than flickering/clearing useful errors. Once syntax is fixed and compiler +produces a real type-check log, replace them. + +**2. Close A While B/C Diagnostics Remain** Do not clear compiler diagnostics on +document close. Compiler diagnostics are workspace/build diagnostics, not +open-document diagnostics. + +If B and C have errors from the compiler, those should remain even when no files +are open, because the workspace still has build errors. + +So the rule should be: + +```text +On didClose: + clear only diagnostics owned by the open-document syntax checker for that URI. + keep compiler diagnostics unchanged. +``` + +**3. Closing File Clears Syntax Error** This behavior is reasonable if the +syntax error only exists in the unsaved buffer. Once the document closes, the +server no longer owns the in-memory text, so it should remove in-memory syntax +diagnostics. + +But if the file was saved with the syntax error, the compiler should own that +diagnostic. Since you currently ignore compiler syntax diagnostics, saved syntax +errors disappear on close. + +The fix is to distinguish syntax source: + +```text +Open unsaved file: + use in-memory syntax diagnostics. + +Saved file / closed file: + allow compiler syntax diagnostics to be published. +``` + +Or more simply: + +```text +Ignore compiler syntax diagnostics only for currently open documents. +Keep compiler syntax diagnostics for closed documents. +``` + +**Suggested Model** Maintain separate diagnostic maps: + +```ocaml +type diagnostics = { + syntax_by_uri: Diagnostic.t list Uri_map.t; + compiler_by_uri: Diagnostic.t list Uri_map.t; +} +``` + +Publishing becomes: + +```text +published(uri) = syntax_by_uri(uri) + compiler_by_uri(uri) +``` + +Then events become predictable: + +```text +didChange: + update syntax_by_uri for that open document + publish merged diagnostics + +compiler log changed: + if syntax-only/incomplete log: + update compiler syntax diagnostics for closed files only + keep previous compiler type diagnostics + else: + replace compiler_by_uri with new compiler diagnostics + publish merged diagnostics + +didClose: + remove syntax_by_uri for that document + keep compiler_by_uri + publish merged diagnostics +``` + +Main practical fix: stop treating every `.compiler.log` change as a complete +replacement. Syntax-error logs are incomplete build results, so they need +special handling. diff --git a/lsp/src/code_actions.ml b/lsp/src/code_actions.ml new file mode 100644 index 00000000000..e2da1284c0c --- /dev/null +++ b/lsp/src/code_actions.ml @@ -0,0 +1,525 @@ +open Lsp +open Lsp.Types + +module From_diagnostics : sig + val get : doc:Document.t -> diagnostics:Diagnostic.t list -> CodeAction.t list +end = struct + let diagnostic_message (diagnostic : Diagnostic.t) = + match diagnostic.message with + | `String message -> message + | `MarkupContent {value} -> value + + let ansi_escape_regex = Str.regexp "\027\\[[0-9;]*m" + let whitespace_regex = Str.regexp "[ \t\r\n]+" + + let strip_ansi str = Str.global_replace ansi_escape_regex "" str + + let empty_range position = Range.create ~start:position ~end_:position + + let replace_text range new_text = [TextEdit.create ~range ~newText:new_text] + + let spaces length = String.make (max 0 length) ' ' + + let quick_fix ~uri ~diagnostic ~title ~edits = + let edit = WorkspaceEdit.create ~changes:[(uri, edits)] () in + Some + (CodeAction.create ~title ~edit ~diagnostics:[diagnostic] + ~kind:CodeActionKind.QuickFix ~isPreferred:true ()) + + let insert_before_ending_char (range : Range.t) new_text = + let position = + Position.create ~line:range.end_.line + ~character:(max 0 (range.end_.character - 1)) + in + [TextEdit.create ~range:(empty_range position) ~newText:new_text] + + let wrap_range_in_text (range : Range.t) before after = + [ + TextEdit.create ~range:(empty_range range.start) ~newText:before; + TextEdit.create ~range:(empty_range range.end_) ~newText:after; + ] + + let split_fields fields = + fields |> String.trim |> Str.split (Str.regexp "[ \t\r\n]+") + + let drop_prefix ~prefix line = + let line = String.trim line in + if String.starts_with ~prefix line then + let prefix_length = String.length prefix in + Some (String.sub line prefix_length (String.length line - prefix_length)) + else None + + let before_first_dot line = + match String.index_opt line '.' with + | Some index -> String.sub line 0 index + | None -> line + + let before_defined_as typ = + match Str.search_forward (Str.regexp_string "(defined as") typ 0 with + | index -> String.sub typ 0 index + | exception Not_found -> typ + + let extract_typename lines = + lines |> String.concat " " |> strip_ansi |> before_defined_as |> String.trim + |> Str.global_replace whitespace_regex "" + + let line_starts_with ~prefix line = + line |> strip_ansi |> String.trim |> String.starts_with ~prefix + + let did_you_mean ~uri ~diagnostic = + let did_you_mean_prefix = "Hint: Did you mean" in + let did_you_mean_regex = Str.regexp "Did you mean \\([A-Za-z0-9_]+\\)" in + + let suggestion_from_line line = + match Str.search_forward did_you_mean_regex line 0 with + | _ -> Some (Str.matched_group 1 line) + | exception Not_found -> None + in + + let find_did_you_mean_line message = + message |> String.split_on_char '\n' + |> List.find_opt (fun line -> + String.starts_with ~prefix:did_you_mean_prefix (String.trim line)) + in + + let message = diagnostic_message diagnostic in + + match find_did_you_mean_line message with + | None -> None + | Some line -> ( + match suggestion_from_line line with + | None -> None + | Some suggestion -> + let edit = + WorkspaceEdit.create + ~changes: + [ + ( uri, + [TextEdit.create ~range:diagnostic.range ~newText:suggestion] + ); + ] + () + in + Some + (CodeAction.create + ~title:(Printf.sprintf "Replace with '%s'" suggestion) + ~edit ~diagnostics:[diagnostic] ~kind:CodeActionKind.QuickFix + ~isPreferred:true ())) + + let wrap_in_some ~uri ~diagnostic = + let pattern_prefix = "This pattern matches values of type" in + let pattern_type_regex = + Str.regexp "This pattern matches values of type \\(.*\\)$" + in + let expected_type_prefix = + "but a pattern was expected which matches values of type" + in + let expected_type_regex = + Str.regexp + "but a pattern was expected which matches values of type\\(.*\\)$" + in + let option_type_regex = Str.regexp_string "option<" in + let is_option_type typ = + match Str.search_forward option_type_regex typ 0 with + | _ -> true + | exception Not_found -> false + in + let type_from_pattern_line line = + match Str.search_forward pattern_type_regex line 0 with + | _ -> Some (String.trim (Str.matched_group 1 line)) + | exception Not_found -> None + in + let type_from_expected_line line = + match Str.search_forward expected_type_regex line 0 with + | _ -> Some (String.trim (Str.matched_group 1 line)) + | exception Not_found -> None + in + let rec expected_type lines = + match lines with + | [] -> None + | line :: next_lines -> + if String.starts_with ~prefix:expected_type_prefix (String.trim line) + then + match type_from_expected_line line with + | Some typ when typ <> "" -> Some typ + | _ -> ( + match next_lines with + | next_line :: _ -> Some (String.trim next_line) + | [] -> None) + else expected_type next_lines + in + let message = diagnostic_message diagnostic in + let lines = String.split_on_char '\n' message in + let rec loop lines = + match lines with + | [] -> None + | line :: rest -> + if String.starts_with ~prefix:pattern_prefix (String.trim line) then + match type_from_pattern_line line with + | Some actual_type when not (is_option_type actual_type) -> ( + match expected_type rest with + | Some expected_type when is_option_type expected_type -> + let edit = + WorkspaceEdit.create + ~changes: + [(uri, wrap_range_in_text diagnostic.range "Some(" ")")] + () + in + Some + (CodeAction.create ~title:"Wrap in option Some" ~edit + ~diagnostics:[diagnostic] ~kind:CodeActionKind.QuickFix + ~isPreferred:true ()) + | _ -> None) + | _ -> None + else loop rest + in + loop lines + + let simple_conversion ~uri ~(diagnostic : Diagnostic.t) = + let prefix = "You can convert " in + let conversion_regex = + Str.regexp + "You can convert \\([A-Za-z0-9_]+\\) to \\([A-Za-z0-9_]+\\) with \ + \\([A-Za-z0-9_.]+\\)\\.$" + in + let code_action_from_line line = + let line = line |> strip_ansi |> String.trim in + if String.starts_with ~prefix line then + match Str.search_forward conversion_regex line 0 with + | _ -> + let from_type = Str.matched_group 1 line in + let to_type = Str.matched_group 2 line in + let fn = Str.matched_group 3 line in + quick_fix ~uri ~diagnostic + ~title: + (Printf.sprintf "Convert %s to %s with %s" from_type to_type fn) + ~edits:(wrap_range_in_text diagnostic.range (fn ^ "(") ")") + | exception Not_found -> None + else None + in + let rec loop lines = + match lines with + | [] -> None + | line :: rest -> ( + match code_action_from_line line with + | Some _ as code_action -> code_action + | None -> loop rest) + in + diagnostic |> diagnostic_message |> String.split_on_char '\n' |> loop + + let apply_uncurried ~uri ~(diagnostic : Diagnostic.t) = + let prefix = + "This is an uncurried ReScript function. It must be applied with a dot." + in + let make_code_action () = + let position = + Position.create ~line:diagnostic.range.end_.line + ~character:(diagnostic.range.end_.character + 1) + in + quick_fix ~uri ~diagnostic ~title:"Apply uncurried function call with dot" + ~edits:[TextEdit.create ~range:(empty_range position) ~newText:". "] + in + let rec loop lines = + match lines with + | [] -> None + | line :: rest -> + if line_starts_with ~prefix line then make_code_action () else loop rest + in + diagnostic |> diagnostic_message |> String.split_on_char '\n' |> loop + + let simple_add_missing_cases ~uri ~(diagnostic : Diagnostic.t) ~source = + let prefix = "You forgot to handle a possible case here, for example:" in + let make_code_action hint = + match + Analysis.Codemod.transform_opt ~source + ~pos:(diagnostic.range.start.line, diagnostic.range.start.character) + ~debug:false ~typ:Analysis.Codemod.AddMissingCases ~hint + with + | None -> None + | Some new_switch_code -> + quick_fix ~uri ~diagnostic ~title:"Insert missing cases" + ~edits:(replace_text diagnostic.range new_switch_code) + in + let rec loop lines = + match lines with + | [] -> None + | line :: rest -> + if line_starts_with ~prefix line then + let hint = rest |> String.concat "" |> String.trim in + if hint = "" then None else make_code_action hint + else loop rest + in + diagnostic |> diagnostic_message |> String.split_on_char '\n' |> loop + + let simple_type_mismatches ~uri ~(diagnostic : Diagnostic.t) = + let this_has_type_prefix = "This has type:" in + let somewhere_wanted_prefix = "Somewhere wanted:" in + let default_value = function + | "string" -> "\"-\"" + | "bool" -> "false" + | "int" -> "-1" + | "float" -> "-1." + | _ -> "assert false" + in + let rec collect_until_somewhere_wanted acc lines = + match lines with + | [] -> None + | line :: rest -> ( + match drop_prefix ~prefix:somewhere_wanted_prefix line with + | Some wanted -> Some (List.rev acc, wanted, rest) + | None -> collect_until_somewhere_wanted (line :: acc) rest) + in + let rec collect_until_blank acc lines = + match lines with + | [] -> List.rev acc + | line :: rest -> + if line |> strip_ansi |> String.trim = "" then List.rev acc + else collect_until_blank (line :: acc) rest + in + let code_action_from_types ~actual_type ~wanted_type = + if actual_type = Printf.sprintf "option<%s>" wanted_type then + quick_fix ~uri ~diagnostic ~title:"Unwrap optional value" + ~edits: + (wrap_range_in_text diagnostic.range "switch " + (Printf.sprintf " { | None => %s | Some(v) => v }" + (default_value wanted_type))) + else if Printf.sprintf "option<%s>" actual_type = wanted_type then + quick_fix ~uri ~diagnostic ~title:"Wrap value in Some" + ~edits:(wrap_range_in_text diagnostic.range "Some(" ")") + else None + in + let rec loop lines = + match lines with + | [] -> None + | line :: rest -> ( + match drop_prefix ~prefix:this_has_type_prefix line with + | None -> loop rest + | Some actual -> ( + match collect_until_somewhere_wanted [actual] rest with + | None -> None + | Some (actual_lines, wanted, rest) -> + let wanted_lines = wanted :: collect_until_blank [] rest in + let actual_type = extract_typename actual_lines in + let wanted_type = extract_typename wanted_lines in + code_action_from_types ~actual_type ~wanted_type)) + in + diagnostic |> diagnostic_message |> String.split_on_char '\n' |> loop + + let handle_undefined_record_fields ~uri ~(diagnostic : Diagnostic.t) + ~record_field_names ~todo_value = + match record_field_names with + | [] -> None + | _ -> + let range = diagnostic.range in + let multiline_record_definition_body = + range.start.line <> range.end_.line + in + let new_text = + if multiline_record_definition_body then + let padding_content_record_field = + spaces (range.end_.character + 1) + in + let padding_content_end_brace = spaces (range.end_.character - 1) in + let fields = + record_field_names + |> List.mapi (fun index field_name -> + let padding = + if index = 0 then " " else padding_content_record_field + in + Printf.sprintf "%s%s: %s,\n" padding field_name todo_value) + |> String.concat "" + in + fields ^ padding_content_end_brace + else + let prefix = + if range.end_.character - range.start.character > 2 then ", " + else "" + in + let fields = + record_field_names + |> List.map (fun field_name -> + Printf.sprintf "%s: %s" field_name todo_value) + |> String.concat ", " + in + prefix ^ fields + in + let edit = + WorkspaceEdit.create + ~changes:[(uri, insert_before_ending_char range new_text)] + () + in + Some + (CodeAction.create ~title:"Add missing record fields" ~edit + ~diagnostics:[diagnostic] ~kind:CodeActionKind.QuickFix + ~isPreferred:true ()) + + let add_undefined_record_fields_v10 ~uri ~diagnostic = + let prefix = "Some record fields are undefined:" in + let message = diagnostic_message diagnostic in + let lines = String.split_on_char '\n' message in + let rec loop lines = + match lines with + | [] -> None + | line :: rest -> ( + match drop_prefix ~prefix line with + | None -> loop rest + | Some fields -> + let record_field_names = + split_fields fields @ (rest |> List.concat_map split_fields) + in + handle_undefined_record_fields ~uri ~diagnostic ~record_field_names + ~todo_value:"%todo") + in + loop lines + + let add_undefined_record_fields_v11 ~uri ~diagnostic = + let prefix = "Some required record fields are missing:" in + let message = diagnostic_message diagnostic in + let lines = String.split_on_char '\n' message in + let rec collect_until_dot acc lines = + match lines with + | [] -> List.rev acc + | line :: rest -> + let fields = line |> before_first_dot |> split_fields in + let acc = List.rev_append fields acc in + if String.contains line '.' then List.rev acc + else collect_until_dot acc rest + in + let rec loop lines = + match lines with + | [] -> None + | line :: rest -> ( + match drop_prefix ~prefix line with + | None -> loop rest + | Some fields -> + let record_field_names = + split_fields (before_first_dot fields) + @ if String.contains line '.' then [] else collect_until_dot [] rest + in + handle_undefined_record_fields ~uri ~diagnostic ~record_field_names + ~todo_value:"%todo") + in + loop lines + + let extractor ~doc ~diagnostic = + let code_actions = ref [] in + + let append code_action = + match code_action with + | Some code_action -> code_actions := code_action :: !code_actions + | None -> () + in + let uri = Document.uri doc in + + did_you_mean ~uri ~diagnostic |> append; + wrap_in_some ~uri ~diagnostic |> append; + simple_conversion ~uri ~diagnostic |> append; + apply_uncurried ~uri ~diagnostic |> append; + simple_add_missing_cases ~uri ~diagnostic ~source:(Document.text doc) + |> append; + simple_type_mismatches ~uri ~diagnostic |> append; + add_undefined_record_fields_v10 ~uri ~diagnostic |> append; + add_undefined_record_fields_v11 ~uri ~diagnostic |> append; + + !code_actions + + let get ~doc ~diagnostics = + diagnostics + |> List.map (fun diagnostic -> extractor ~doc ~diagnostic) + |> List.flatten +end + +module Open_compiled_file = struct + let make ~doc ~state = + let uri = Document.uri doc in + let compiled_uri = + Helpers.get_compiled_file ~uri + ~compiler_config:(State.compiler_config state) + ~fs:state.fs + ~workspace_root:(State.workspace_root state) + in + match compiled_uri with + | Some uri -> + let title = "Open compiled file" in + [ + CodeAction.create + ~command: + (Command.create + ~arguments:[`String (Uri.to_string uri)] + ~command:Execute_commands.Open_compiled.name ~title ()) + ~title (); + ] + | None -> [] +end + +module Create_interface_file = struct + let make ~doc ~(state : State.t) = + let uri = Document.uri doc in + + let should_create = + match Document.kind doc with + | Res -> not (Fs.exists ~fs:state.fs ~follow:false (Uri.to_path uri)) + | _ -> false + in + + let cmi_file = + Helpers.get_cmi_file ~uri ~fs:state.fs + ~compiler_config:(State.compiler_config state) + ~workspace_root:(State.workspace_root state) + in + + match (should_create, cmi_file) with + | true, Some cmi_file -> + let title = "Create interface file" in + [ + CodeAction.create + ~command: + (Command.create + ~arguments: + [ + `String (Uri.to_string uri); + `String (Uri.of_path cmi_file |> Uri.to_string); + ] + ~command:Execute_commands.Create_interface.name ~title ()) + ~title (); + ] + | _ -> [] +end + +module Switch_implementation_interface_file = struct + let make ~doc ~(state : State.t) = + match Document.kind doc with + | Res -> + let uri = Document.uri doc in + let target = Uri.to_path uri ^ "i" in + if Fs.exists ~follow:false ~fs:state.fs target then + let title = "Switch to interface file" in + [ + CodeAction.create + ~command: + (Command.create + ~arguments:[`String (Uri.of_path target |> Uri.to_string)] + ~command:Execute_commands.Switch_implementation_interface.name + ~title ()) + ~title (); + ] + else [] + | Resi -> + let uri = Document.uri doc in + let target = (Uri.to_path uri |> Filename.remove_extension) ^ ".res" in + if Fs.exists ~follow:false ~fs:state.fs target then + let title = "Switch to implementation file" in + [ + CodeAction.create + ~command: + (Command.create + ~arguments:[`String (Uri.of_path target |> Uri.to_string)] + ~command:Execute_commands.Switch_implementation_interface.name + ~title ()) + ~title (); + ] + else [] + (* TODO: Offer "create implementation file" when a .resi has no matching + .res file, instead of only showing the switch action for existing files. *) + | _ -> [] +end diff --git a/lsp/src/compiler_config.ml b/lsp/src/compiler_config.ml new file mode 100644 index 00000000000..4e2c2405ecb --- /dev/null +++ b/lsp/src/compiler_config.ml @@ -0,0 +1,272 @@ +module Parse = struct + type namespace = Namespace_bool of bool | Namespace_string of string + + let namespace_to_yojson = function + | Namespace_bool value -> `Bool value + | Namespace_string value -> `String value + + let namespace_of_yojson = function + | `Bool value -> Ok (Namespace_bool value) + | `String value -> Ok (Namespace_string value) + | json -> + Error + ("Expected namespace to be a boolean or string, got " + ^ Yojson.Safe.to_string json) + + type module_format = Commonjs | Es6 | Es6_global | Esmodule + + let module_format_to_yojson = function + | Commonjs -> `String "commonjs" + | Es6 -> `String "es6" + | Es6_global -> `String "es6-global" + | Esmodule -> `String "esmodule" + + let module_format_of_yojson = function + | `String "commonjs" -> Ok Commonjs + | `String "es6" -> Ok Es6 + | `String "es6-global" -> Ok Es6_global + | `String "esmodule" -> Ok Esmodule + | _ -> Error "Compiler_config.module_format" + + type module_format_object = { + in_source: bool option; [@key "in-source"] [@default None] + module_: module_format; [@key "module"] + suffix: string option; [@default None] + } + [@@deriving yojson {strict = false}] + + type package_spec = + | Module_format of module_format + | Module_format_object of module_format_object + + let package_spec_to_yojson = function + | Module_format module_format -> module_format_to_yojson module_format + | Module_format_object module_format_object -> + module_format_object_to_yojson module_format_object + + let package_spec_of_yojson json = + match module_format_of_yojson json with + | Ok module_format -> Ok (Module_format module_format) + | Error _ -> ( + match module_format_object_of_yojson json with + | Ok module_format_object -> + Ok (Module_format_object module_format_object) + | Error message -> Error message) + + type package_specs = package_spec list + + let package_specs_to_yojson package_specs = + `List (List.map package_spec_to_yojson package_specs) + + let package_specs_of_yojson json = + let rec collect = function + | [] -> Ok [] + | json :: rest -> ( + match package_spec_of_yojson json with + | Ok package_spec -> ( + match collect rest with + | Ok package_specs -> Ok (package_spec :: package_specs) + | Error _ as error -> error) + | Error _ as error -> error) + in + + match json with + | `List package_specs -> collect package_specs + | json -> ( + match package_spec_of_yojson json with + | Ok package_spec -> Ok [package_spec] + | Error _ as error -> error) + + type t = { + name: string; + namespace: namespace option; [@default None] + package_specs: package_specs option; [@key "package-specs"] [@default None] + suffix: string option; [@default None] + } + [@@deriving yojson {strict = false}] + + let%expect_test "parse build schema" = + let prin_config config = + match config with + | Error message -> print_endline ("Error: " ^ message) + | Ok config -> ( + Printf.printf "name=%s\n" config.name; + (match config.namespace with + | Some (Namespace_string namespace) -> + Printf.printf "namespace=%s\n" namespace + | Some (Namespace_bool namespace) -> + Printf.printf "namespace=%b\n" namespace + | None -> print_endline "namespace=None"); + (match config.package_specs with + | Some specs -> + let r = + specs + |> List.map (fun spec -> + package_spec_to_yojson spec |> Yojson.Safe.pretty_to_string) + |> String.concat ", " + in + Printf.sprintf "package_specs=Some(%s)" r |> print_endline + | None -> print_endline "package_specs=None"); + match config.suffix with + | Some suffix -> Printf.sprintf "suffix=%s" suffix |> print_endline + | None -> print_endline "suffix=None") + in + + let json = + Yojson.Safe.from_string + {|{ + "name": "app", + "namespace": "App", + "package-specs": [ + "esmodule", + { + "in-source": true, + "module": "commonjs", + "suffix": ".cjs" + } + ], + "suffix": ".js" + }|} + in + prin_config (of_yojson json); + [%expect + {| + name=app + namespace=App + package_specs=Some("esmodule", { "in-source": true, "module": "commonjs", "suffix": ".cjs" }) + suffix=.js + |}]; + + let json_2 = + Yojson.Safe.from_string + {|{ + "name": "@rescript-lang/guide", + "namespace": false, + "dependencies": [ + "@rescript-lang/playground", + "@rescript-lang/shared", + "@rescript/react", + "@rescript/webapi" + ], + "compiler-flags": ["-open WebAPI.Global"], + "sources": [ + { + "dir": "__tests__", + "subdirs": true, + "type": "dev" + }, + { + "dir": "app", + "subdirs": true + } + ], + "warnings": { + "error": "+8" + } + }|} + in + prin_config (of_yojson json_2); + [%expect + {| + name=@rescript-lang/guide + namespace=false + package_specs=None + suffix=None + |}]; + + let json_3 = + Yojson.Safe.from_string + {|{ + "name": "rescript-lang.org-monorepo", + "dependencies": [ + "@rescript-lang/shared", + "@rescript-lang/playground", + "@rescript-lang/guide", + "@rescript-lang/docs" + ], + "sources": [], + "jsx": { + "preserve": true, + "version": 4 + }, + "package-specs": { + "module": "esmodule", + "in-source": true + }, + "suffix": ".jsx" + } +|} + in + prin_config (of_yojson json_3); + [%expect + {| + name=rescript-lang.org-monorepo + namespace=None + package_specs=Some({ "in-source": true, "module": "esmodule" }) + suffix=.jsx + |}] +end + +module Uri_map = Map.Make (Lsp.Uri) + +type t = Parse.t Uri_map.t + +let parse ~root ~fs = + let ( /+ ) = Filename.concat in + let rescript_json = root /+ "rescript.json" in + match Fs.load ~fs rescript_json with + | Some content -> ( + match Yojson.Safe.from_string content with + | json -> ( + match Parse.of_yojson json with + | Ok s -> Ok s + | Error _ -> + Error + ("Failed to parse compiler config for rescript.json at " + ^ rescript_json)) + | exception _ -> Error ("Failed to parse rescript.json at " ^ rescript_json) + ) + | None -> Error ("Failed to read rescript.json file at " ^ rescript_json) + +type output_config = {suffix: string; folder: string; in_source: bool} +let get_output_config (config : Parse.t) = + let default_suffix = ".js" in + let default_in_source = false in + let folder_name = function + | Parse.Commonjs -> "js" + | Es6_global -> "es6_global" + | Es6 | Esmodule -> "es6" + in + match config.package_specs with + | Some pkg_spec -> ( + match pkg_spec with + | [] -> + { + suffix = config.suffix |> Option.value ~default:default_suffix; + folder = folder_name Parse.Commonjs; + in_source = default_in_source; + } + | Module_format module_ :: _ -> + { + suffix = config.suffix |> Option.value ~default:default_suffix; + folder = folder_name module_; + in_source = default_in_source; + } + | Module_format_object {module_; suffix; in_source} :: _ -> + let suffix = + match (suffix, config.suffix) with + | Some s, _ -> s + | None, Some s -> s + | _ -> default_suffix + in + { + suffix; + folder = folder_name module_; + in_source = in_source |> Option.value ~default:default_in_source; + }) + | None -> + { + suffix = config.suffix |> Option.value ~default:default_suffix; + folder = folder_name Parse.Commonjs; + in_source = default_in_source; + } diff --git a/lsp/src/compiler_log.ml b/lsp/src/compiler_log.ml new file mode 100644 index 00000000000..dedad285030 --- /dev/null +++ b/lsp/src/compiler_log.ml @@ -0,0 +1,1514 @@ +module Parse : sig + type path = Relative_path of string | Full_path of string + + type kind = + | Syntax_error + | Warning of {number: int; configured_as_error: bool} + | Common_error (* type error, value can't be found *) + | Circular_dependency + | Unknown + + type diagnostic_entry = { + kind: kind; + path: path; + message: string; + range: Lsp.Types.Range.t option; + } + + val is_complete : string -> bool + val parse_log_content : string -> diagnostic_entry list +end = struct + type position = {line: int; col: int} + + type parsed_range = {start_pos: position; end_pos: position} + + type path = Relative_path of string | Full_path of string + + type kind = + | Syntax_error + | Warning of {number: int; configured_as_error: bool} + | Common_error + | Circular_dependency + | Unknown + + type diagnostic_entry = { + kind: kind; + path: path; + message: string; + range: Lsp.Types.Range.t option; + } + + type location_format = Path_location | File_location + + let split_lines s = s |> String.split_on_char '\n' |> Array.of_list + + let is_blank s = String.trim s = "" + + let starts_with prefix s = + let prefix_len = String.length prefix in + String.length s >= prefix_len && String.sub s 0 prefix_len = prefix + + let ends_with suffix s = + let suffix_len = String.length suffix in + let len = String.length s in + len >= suffix_len && String.sub s (len - suffix_len) suffix_len = suffix + + let is_rescript_source_path s = + (not (String.contains s ' ')) + && List.exists + (fun suffix -> ends_with suffix s) + [".res"; ".resi"; ".re"; ".rei"] + + let filepath_of_path path = + if Filename.is_relative path then Relative_path path else Full_path path + + let parse_path_location line = + (* Supported formats: + + /path/file.res:3:9 + start = 3:9 + end = 3:9 + + /path/file.res:3:5-8 + start = 3:5 + end = 3:8 + + /path/file.res:1:8-2:3 + start = 1:8 + end = 2:3 + + /path/file.res + no range + *) + let line = String.trim line in + + let point_re = Str.regexp "^\\(.+\\):\\([0-9]+\\):\\([0-9]+\\)$" in + let same_line_range_re = + Str.regexp "^\\(.+\\):\\([0-9]+\\):\\([0-9]+\\)-\\([0-9]+\\)$" + in + let cross_line_range_re = + Str.regexp + "^\\(.+\\):\\([0-9]+\\):\\([0-9]+\\)-\\([0-9]+\\):\\([0-9]+\\)$" + in + let make_location filepath start_line start_col end_line end_col = + Some + ( filepath_of_path (Str.matched_group filepath line), + Some + { + start_pos = + { + line = Str.matched_group start_line line |> int_of_string; + col = Str.matched_group start_col line |> int_of_string; + }; + end_pos = + { + line = Str.matched_group end_line line |> int_of_string; + col = Str.matched_group end_col line |> int_of_string; + }; + } ) + in + + try + if Str.string_match cross_line_range_re line 0 then + make_location 1 2 3 4 5 + else if Str.string_match same_line_range_re line 0 then + make_location 1 2 3 2 4 + else if Str.string_match point_re line 0 then make_location 1 2 3 2 3 + else if is_rescript_source_path line then + Some (filepath_of_path line, None) + else None + with Not_found | Failure _ -> + if is_rescript_source_path line then Some (filepath_of_path line, None) + else None + + let parse_file_location line = + (* Supported formats: + + File "/path/file.res", line 3, characters 5-8: + start = 3:5 + end = 3:8 + + File "/path/file.res", lines 11-13, characters 6-7: + start = 11:6 + end = 13:7 + *) + let line = String.trim line in + + let single_line_re = + Str.regexp + "^File \"\\([^\"]+\\)\", line \\([0-9]+\\), characters \ + \\([0-9]+\\)-\\([0-9]+\\):$" + in + let multi_line_re = + Str.regexp + "^File \"\\([^\"]+\\)\", lines \\([0-9]+\\)-\\([0-9]+\\), characters \ + \\([0-9]+\\)-\\([0-9]+\\):$" + in + + if Str.string_match single_line_re line 0 then + let filepath = Str.matched_group 1 line in + let line_number = Str.matched_group 2 line |> int_of_string in + let start_col = Str.matched_group 3 line |> int_of_string in + let end_col = Str.matched_group 4 line |> int_of_string in + Some + ( filepath_of_path filepath, + Some + { + start_pos = {line = line_number; col = start_col}; + end_pos = {line = line_number; col = end_col}; + } ) + else if Str.string_match multi_line_re line 0 then + let filepath = Str.matched_group 1 line in + let start_line = Str.matched_group 2 line |> int_of_string in + let end_line = Str.matched_group 3 line |> int_of_string in + let start_col = Str.matched_group 4 line |> int_of_string in + let end_col = Str.matched_group 5 line |> int_of_string in + Some + ( filepath_of_path filepath, + Some + { + start_pos = {line = start_line; col = start_col}; + end_pos = {line = end_line; col = end_col}; + } ) + else None + + let parse_location line = + match parse_path_location line with + | Some location -> Some (Path_location, location) + | None -> ( + match parse_file_location line with + | Some location -> Some (File_location, location) + | None -> None) + + let is_location_line line = + match parse_location line with + | Some _ -> true + | None -> false + + let is_source_code_line line = + (* Matches lines like: + 1 │ let a = 1 + 2 │ let b = "hi" + 11 | ......Todos { + 46 ┆ module Input = { + + Unnumbered OCaml gutter continuation lines are handled by + [message_from_lines] only after a numbered source line has started an + excerpt. A diagnostic message can itself start with [|], for example + missing pattern-match cases. + *) + let rescript_re = Str.regexp "^[ \t]*[0-9]+[ \t]*│" in + let rescript_rei_re = Str.regexp "^[ \t]*[0-9]+[ \t]*┆" in + let ocaml_re = Str.regexp "^[ \t]*[0-9]+[ \t]*|" in + Str.string_match rescript_re line 0 + || Str.string_match rescript_rei_re line 0 + || Str.string_match ocaml_re line 0 + + let is_source_continuation_line line = + Str.string_match (Str.regexp "^[ \t]*|") line 0 + + let is_source_excerpt_line line = + is_source_code_line line || is_source_continuation_line line + + let trim_empty_edges lines = + let rec drop_start = function + | [] -> [] + | x :: xs when is_blank x -> drop_start xs + | xs -> xs + in + + let rec drop_end xs = + match List.rev xs with + | [] -> [] + | x :: rest when is_blank x -> drop_end (List.rev rest) + | _ -> xs + in + + lines |> drop_start |> drop_end + + let find_title_index lines loc_index = + let rec loop i = + if i < 0 then None + else + let line = lines.(i) in + let trimmed = String.trim line in + + if is_blank line || starts_with "#Start" trimmed then loop (i - 1) + else Some i + in + loop (loc_index - 1) + + let warning_from_line line = + let line = String.trim line in + let warning_number_re = Str.regexp "^Warning number[ \t]+\\([0-9]+\\)" in + let warning_re = Str.regexp "^Warning[ \t]+\\([0-9]+\\)" in + let configured_as_error = + try + ignore + (Str.search_forward + (Str.regexp_string "(configured as error)") + line 0); + true + with Not_found -> false + in + if Str.string_match warning_number_re line 0 then + Some (Str.matched_group 1 line |> int_of_string, configured_as_error) + else if Str.string_match warning_re line 0 then + Some (Str.matched_group 1 line |> int_of_string, configured_as_error) + else None + + let is_diagnostic_message_start line = + let content = String.trim line in + String.starts_with ~prefix:"Warning " content + || String.starts_with ~prefix:"Error" content + + let kind_from_title title = + match title with + | "Syntax error!" -> Syntax_error + | "We've found a bug for you!" -> Common_error + | other -> ( + match warning_from_line other with + | Some (number, configured_as_error) -> + Warning {number; configured_as_error} + | None -> Unknown) + + let kind_from_message lines = + let rec loop = function + | [] -> Unknown + | line :: rest -> + let content = String.trim line in + if is_blank content || is_source_excerpt_line content then loop rest + else if String.starts_with ~prefix:"Warning " content then + match warning_from_line content with + | Some (number, configured_as_error) -> + Warning {number; configured_as_error} + | None -> Unknown + else if String.starts_with ~prefix:"Error" content then Unknown + else loop rest + in + loop lines + + let message_from_lines lines = + let rec filter_source_lines in_source_excerpt acc = function + | [] -> List.rev acc + | line :: rest -> + let trimmed = String.trim line in + if + is_location_line line + || starts_with "#Done" trimmed + || starts_with "#Start" trimmed + then filter_source_lines false acc rest + else if is_source_code_line line then filter_source_lines true acc rest + else if in_source_excerpt then + if is_diagnostic_message_start line then + filter_source_lines false (line :: acc) rest + else if is_blank line then + filter_source_lines false (line :: acc) rest + else filter_source_lines true acc rest + else filter_source_lines false (line :: acc) rest + in + lines + |> filter_source_lines false [] + |> trim_empty_edges |> List.map String.trim |> String.concat "\n" + + let unique_preserve_order items = + let rec loop seen acc = function + | [] -> List.rev acc + | item :: rest -> + if List.mem item seen then loop seen acc rest + else loop (item :: seen) (item :: acc) rest + in + loop [] [] items + + let collect_matches re group line = + let rec loop start acc = + try + ignore (Str.search_forward re line start); + let matched = Str.matched_group group line in + loop (Str.match_end ()) (matched :: acc) + with Not_found -> List.rev acc + in + loop 0 [] + + let source_path_from_artifact_path path = + if ends_with ".cmj" path then + String.sub path 0 (String.length path - 4) ^ ".res" + else path + + let parse_dependency_cycle_paths message_lines = + let paren_source_path_re = + Str.regexp "(\\([^()]+\\.\\(res\\|resi\\|re\\|rei\\)\\))" + in + let artifact_path_re = Str.regexp "\\([^ ]+\\.cmj\\)" in + let source_paths = + message_lines |> List.concat_map (collect_matches paren_source_path_re 1) + in + match source_paths with + | _ :: _ -> unique_preserve_order source_paths + | [] -> + message_lines + |> List.concat_map (collect_matches artifact_path_re 1) + |> List.map source_path_from_artifact_path + |> unique_preserve_order + + let parse_dependency_cycle_entries lines len = + let rec collect_until_done i acc = + if i >= len then (List.rev acc, i) + else + let trimmed = String.trim lines.(i) in + if starts_with "#Done" trimmed then (List.rev acc, i) + else collect_until_done (i + 1) (lines.(i) :: acc) + in + let rec loop i acc = + if i >= len then List.rev acc + else + let trimmed = String.trim lines.(i) in + if starts_with "Can't continue... Found a circular dependency" trimmed + then + let message_lines, next_i = collect_until_done i [] in + let message = + message_lines |> trim_empty_edges |> List.map String.trim + |> String.concat "\n" + in + let paths = parse_dependency_cycle_paths message_lines in + let entries = + paths + |> List.map (fun filepath -> + { + path = filepath_of_path filepath; + kind = Circular_dependency; + message; + range = None; + }) + in + loop (next_i + 1) (List.rev_append entries acc) + else if starts_with "FAILED: dependency cycle:" trimmed then + let message = trimmed in + let paths = parse_dependency_cycle_paths [trimmed] in + let entries = + paths + |> List.map (fun filepath -> + { + path = filepath_of_path filepath; + kind = Circular_dependency; + message; + range = None; + }) + in + loop (i + 1) (List.rev_append entries acc) + else loop (i + 1) acc + in + loop 0 [] + + let replace_double_newline message = + Str.global_replace (Str.regexp "\n\n") "\n" message + + let range_of_parsed_range = function + | None -> None + | Some range -> + (* Compiler log locations are 1-based, while LSP locations are 0-based. + End columns are already exclusive in compiler output, so only line + numbers and start columns are shifted. *) + let minus_one v = max 0 (v - 1) in + Some + (Lsp.Types.Range.create + ~start: + (Lsp.Types.Position.create + ~line:(range.start_pos.line |> minus_one) + ~character:(range.start_pos.col |> minus_one)) + ~end_: + (Lsp.Types.Position.create + ~line:(range.end_pos.line |> minus_one) + ~character:range.end_pos.col)) + + let is_complete content = + content |> String.split_on_char '\n' |> List.rev + |> List.find_opt (fun line -> not (is_blank line)) + |> function + | Some line -> starts_with "#Done" (String.trim line) + | None -> false + + let parse_log_content (content : string) = + let lines = split_lines content in + let len = Array.length lines in + + let diagnostics = + let rec loop i acc = + if i >= len then List.rev acc + else + match parse_location lines.(i) with + | Some (location_format, location) -> + let title_index = + match location_format with + | Path_location -> find_title_index lines i + | File_location -> None + in + loop (i + 1) ((i, title_index, location_format, location) :: acc) + | None -> loop (i + 1) acc + in + loop 0 [] + in + + let rec build entries = + match entries with + | [] -> [] + | (loc_index, title_index, location_format, (path, range)) :: rest -> + let next_boundary = + match rest with + | (next_loc_index, next_title_index, next_location_format, _) :: _ + -> ( + match next_location_format with + | File_location -> next_loc_index + | Path_location -> ( + match next_title_index with + | Some i -> i + | None -> next_loc_index)) + | [] -> len + in + + let message_start = + match title_index with + | Some i -> i + 1 + | None -> loc_index + 1 + in + + let raw_message_lines = + let rec collect i acc = + if i >= next_boundary then List.rev acc + else collect (i + 1) (lines.(i) :: acc) + in + collect message_start [] + in + + let kind = + match location_format with + | File_location -> kind_from_message raw_message_lines + | Path_location -> ( + match title_index with + | Some i -> kind_from_title (String.trim lines.(i)) + | None -> kind_from_message raw_message_lines) + in + + let message = + raw_message_lines |> message_from_lines |> replace_double_newline + in + + {kind; path; message; range = range_of_parsed_range range} :: build rest + in + + let dependency_cycle_diagnostics = + parse_dependency_cycle_entries lines len + in + + build diagnostics @ dependency_cycle_diagnostics +end + +let%expect_test "parse log" = + let diagnostic_of_entry (entry : Parse.diagnostic_entry) = + let range = + match entry.range with + | None -> `String "None" + | Some range -> Lsp.Types.Range.yojson_of_t range + in + Yojson.Safe.pretty_to_string + (`Assoc [("message", `String entry.message); ("range", range)]) + in + let print_logs logs = + logs + |> List.iter (fun (entry : Parse.diagnostic_entry) -> + let path = + match entry.path with + | Parse.Relative_path p -> Printf.sprintf "Relative_path(%s)" p + | Full_path p -> Printf.sprintf "Full_path(%s)" p + in + print_endline + ((match entry.kind with + | Syntax_error -> "Syntax_error" + | Warning {number; configured_as_error} -> + if configured_as_error then + Printf.sprintf "Warning %d (configured as error)" number + else Printf.sprintf "Warning %d" number + | Unknown -> "Unknown" + | Circular_dependency -> "Circular_dependency" + | Common_error -> "Common_error") + ^ " - " ^ path); + diagnostic_of_entry entry |> print_endline; + print_newline ()) + in + let example_log_1 = + {| + #Start(1600519680823) + + Syntax error! + /Users/chenglou/github/reason-react/src/test.res:1:8-2:3 + + 1 │ let a = + 2 │ let b = + 3 │ + + This let-binding misses an expression + + + Warning number 8 + /Users/chenglou/github/reason-react/src/test.res:3:5-8 + + 1 │ let a = j`😀` + 2 │ let b = `😀` + 3 │ let None = None + 4 │ let bla: int = " + 5 │ hi + + You forgot to handle a possible case here, for example: + Some _ + + + We've found a bug for you! + /Users/chenglou/github/reason-react/src/test.res:3:9 + + 1 │ let a = 1 + 2 │ let b = "hi" + 3 │ let a = b + 1 + + This has type: string + Somewhere wanted: int + + #Done(1600519680836) + |} + in + + Parse.parse_log_content example_log_1 |> print_logs; + [%expect + {| + Syntax_error - Full_path(/Users/chenglou/github/reason-react/src/test.res) + { + "message": "This let-binding misses an expression", + "range": { + "end": { "character": 3, "line": 1 }, + "start": { "character": 7, "line": 0 } + } + } + + Warning 8 - Full_path(/Users/chenglou/github/reason-react/src/test.res) + { + "message": "You forgot to handle a possible case here, for example:\nSome _", + "range": { + "end": { "character": 8, "line": 2 }, + "start": { "character": 4, "line": 2 } + } + } + + Common_error - Full_path(/Users/chenglou/github/reason-react/src/test.res) + { + "message": "This has type: string\nSomewhere wanted: int", + "range": { + "end": { "character": 9, "line": 2 }, + "start": { "character": 8, "line": 2 } + } + } + |}]; + + let example_log_2 = {|#Start(1780532423603) + #Done(1780532423840)|} in + + Parse.parse_log_content example_log_2 |> print_logs; + [%expect {| |}]; + + (* https://github.com/rescript-lang/rescript-vscode/issues/386#issuecomment-1221093517 *) + let example_log_3 = + {| + #Start(1660943070627) + File "/home/pedro/Desktop/rescript-apollo-client/EXAMPLES/src/fragmentsUsage/Fragments.res", lines 11-13, characters 6-7: + 11 | ......Todos { + | ...TodoItem + | } + 13 | ... + Warning 22 [preprocessor]: Field "allTodos" has been deprecated. Reason: null + File "/home/pedro/Desktop/rescript-apollo-client/EXAMPLES/src/clientUsage/PromiseChaining.res", lines 17-21, characters 6-7: + 17 | ......os: allTodos { + | id + | text + | completed + | } + 21 | ... + Warning 22 [preprocessor]: Field "allTodos" has been deprecated. Reason: null + File "/home/pedro/Desktop/rescript-apollo-client/EXAMPLES/src/clientUsage/ClientBasics.res", lines 18-22, characters 6-7: + 18 | ......os: allTodos { + | id + | text + | completed + | } + 22 | ... + Warning 22 [preprocessor]: Field "allTodos" has been deprecated. Reason: null + File "/home/pedro/Desktop/rescript-apollo-client/EXAMPLES/src/docs/Docs.res", lines 7-11, characters 6-7: + 7 | ......os: allTodos { + | id + | text + | completed + | } + 11 | ... + Warning 22 [preprocessor]: Field "allTodos" has been deprecated. Reason: null + File "/home/pedro/Desktop/rescript-apollo-client/EXAMPLES/src/fragmentsUsage/Query_Fragments.res", lines 6-9, characters 6-7: + 6 | ......os: allTodos { + | # This references the TodoItem fragment definition module above! + | ...TodoItem + | } + 9 | ... + Warning 22 [preprocessor]: Field "allTodos" has been deprecated. Reason: null + File "/home/pedro/Desktop/rescript-apollo-client/EXAMPLES/src/hooksUsage/Mutation.res", lines 18-22, characters 6-7: + 18 | ......os: allTodos { + | id + | completed + | text + | } + 22 | ... + Warning 22 [preprocessor]: Field "allTodos" has been deprecated. Reason: null + File "/home/pedro/Desktop/rescript-apollo-client/EXAMPLES/src/hooksUsage/Query_Lazy.res", lines 3-7, characters 6-7: + 3 | ......os: allTodos { + | id + | text + | completed + | } + 7 | ... + Warning 22 [preprocessor]: Field "allTodos" has been deprecated. Reason: null + File "/home/pedro/Desktop/rescript-apollo-client/EXAMPLES/src/hooksUsage/Query_OverlySimple.res", lines 3-7, characters 4-5: + 3 | ....os: allTodos { + | id + | text + | completed + | } + 7 | . + Warning 22 [preprocessor]: Field "allTodos" has been deprecated. Reason: null + File "/home/pedro/Desktop/rescript-apollo-client/EXAMPLES/src/hooksUsage/Query_SubscribeToMore.res", lines 5-9, characters 6-7: + 5 | ......os: allTodos { + | id + | completed + | text + | } + 9 | ... + Warning 22 [preprocessor]: Field "allTodos" has been deprecated. Reason: null + File "/home/pedro/Desktop/rescript-apollo-client/EXAMPLES/src/hooksUsage/Query_Typical.res", lines 4-8, characters 6-7: + 4 | ......os: allTodos { + | id + | text + | completed + | } + 8 | ... + Warning 22 [preprocessor]: Field "allTodos" has been deprecated. Reason: null + #Done(1660943070848) + |} + in + + Parse.parse_log_content example_log_3 |> print_logs; + [%expect + {| + Warning 22 - Full_path(/home/pedro/Desktop/rescript-apollo-client/EXAMPLES/src/fragmentsUsage/Fragments.res) + { + "message": "Warning 22 [preprocessor]: Field \"allTodos\" has been deprecated. Reason: null", + "range": { + "end": { "character": 7, "line": 12 }, + "start": { "character": 5, "line": 10 } + } + } + + Warning 22 - Full_path(/home/pedro/Desktop/rescript-apollo-client/EXAMPLES/src/clientUsage/PromiseChaining.res) + { + "message": "Warning 22 [preprocessor]: Field \"allTodos\" has been deprecated. Reason: null", + "range": { + "end": { "character": 7, "line": 20 }, + "start": { "character": 5, "line": 16 } + } + } + + Warning 22 - Full_path(/home/pedro/Desktop/rescript-apollo-client/EXAMPLES/src/clientUsage/ClientBasics.res) + { + "message": "Warning 22 [preprocessor]: Field \"allTodos\" has been deprecated. Reason: null", + "range": { + "end": { "character": 7, "line": 21 }, + "start": { "character": 5, "line": 17 } + } + } + + Warning 22 - Full_path(/home/pedro/Desktop/rescript-apollo-client/EXAMPLES/src/docs/Docs.res) + { + "message": "Warning 22 [preprocessor]: Field \"allTodos\" has been deprecated. Reason: null", + "range": { + "end": { "character": 7, "line": 10 }, + "start": { "character": 5, "line": 6 } + } + } + + Warning 22 - Full_path(/home/pedro/Desktop/rescript-apollo-client/EXAMPLES/src/fragmentsUsage/Query_Fragments.res) + { + "message": "Warning 22 [preprocessor]: Field \"allTodos\" has been deprecated. Reason: null", + "range": { + "end": { "character": 7, "line": 8 }, + "start": { "character": 5, "line": 5 } + } + } + + Warning 22 - Full_path(/home/pedro/Desktop/rescript-apollo-client/EXAMPLES/src/hooksUsage/Mutation.res) + { + "message": "Warning 22 [preprocessor]: Field \"allTodos\" has been deprecated. Reason: null", + "range": { + "end": { "character": 7, "line": 21 }, + "start": { "character": 5, "line": 17 } + } + } + + Warning 22 - Full_path(/home/pedro/Desktop/rescript-apollo-client/EXAMPLES/src/hooksUsage/Query_Lazy.res) + { + "message": "Warning 22 [preprocessor]: Field \"allTodos\" has been deprecated. Reason: null", + "range": { + "end": { "character": 7, "line": 6 }, + "start": { "character": 5, "line": 2 } + } + } + + Warning 22 - Full_path(/home/pedro/Desktop/rescript-apollo-client/EXAMPLES/src/hooksUsage/Query_OverlySimple.res) + { + "message": "Warning 22 [preprocessor]: Field \"allTodos\" has been deprecated. Reason: null", + "range": { + "end": { "character": 5, "line": 6 }, + "start": { "character": 3, "line": 2 } + } + } + + Warning 22 - Full_path(/home/pedro/Desktop/rescript-apollo-client/EXAMPLES/src/hooksUsage/Query_SubscribeToMore.res) + { + "message": "Warning 22 [preprocessor]: Field \"allTodos\" has been deprecated. Reason: null", + "range": { + "end": { "character": 7, "line": 8 }, + "start": { "character": 5, "line": 4 } + } + } + + Warning 22 - Full_path(/home/pedro/Desktop/rescript-apollo-client/EXAMPLES/src/hooksUsage/Query_Typical.res) + { + "message": "Warning 22 [preprocessor]: Field \"allTodos\" has been deprecated. Reason: null", + "range": { + "end": { "character": 7, "line": 7 }, + "start": { "character": 5, "line": 3 } + } + } + |}]; + + (* https://github.com/rescript-lang/rescript-vscode/issues/86#issuecomment-786186698 *) + let example_log_4 = + {|#Start(1614285167013) + + Warning number 33 + /home/misha/projects/productionmason/web/auth/src/ErrorHandlingMiddleware.res:1:1-15 + + 1 │ open CommonBase + 2 │ + 3 │ external jsExnToExpressError: Js.Exn.t => Express.Error.t = "%identity" + + unused open CommonBase. + + + Warning number 27 + /home/misha/projects/productionmason/web/auth/src/config/Config.res:10:22-27 + + 8 │ external stringifyAnyWithSpace: ('a, @bs.as(json`null`) _, int) => str + ing = "stringify" + 9 │ + 10 │ let validateConfig = config => { + 11 │ let googleApplicationCredentialsPath = + 12 │ NodeJs.Process.env(NodeJs.Process.process)->Js.Dict.get("GOOGLE_AP + PLICATION_CREDENTIALS") + + unused variable config. + + + Warning number 34 + /home/misha/projects/productionmason/web/auth/src/express-handler/ExpressHandler.re:48:5-26 + + 46 ┆ module Input = { + 47 ┆ [@decco] + 48 ┆ type t = Request.input; + 49 ┆ }; + 50 ┆ + + unused type t. + + + Warning number 32 + /home/misha/projects/productionmason/web/auth/src/express-handler/ExpressHandler.re + + unused value t_encode. + + + We've found a bug for you! + /home/misha/projects/productionmason/web/auth/tests/Auth_Test.res:224:45 + + 222 ┆ ->AsyncResult.mapOk(x => { + 223 ┆ expect(x.data.users->Array.length)->toBeGreaterThan(0) + 224 ┆ expect(x.data.users->Array.getExn(0).last_name)->toBe(Some("Glenliv + et")) + 225 ┆ Ok() + 226 ┆ }) + + This has type: int + Somewhere wanted: array<'a> + + #Done(1614285167075)|} + in + + Parse.parse_log_content example_log_4 |> print_logs; + [%expect + {| + Warning 33 - Full_path(/home/misha/projects/productionmason/web/auth/src/ErrorHandlingMiddleware.res) + { + "message": "unused open CommonBase.", + "range": { + "end": { "character": 15, "line": 0 }, + "start": { "character": 0, "line": 0 } + } + } + + Warning 27 - Full_path(/home/misha/projects/productionmason/web/auth/src/config/Config.res) + { + "message": "unused variable config.", + "range": { + "end": { "character": 27, "line": 9 }, + "start": { "character": 21, "line": 9 } + } + } + + Warning 34 - Full_path(/home/misha/projects/productionmason/web/auth/src/express-handler/ExpressHandler.re) + { + "message": "unused type t.", + "range": { + "end": { "character": 26, "line": 47 }, + "start": { "character": 4, "line": 47 } + } + } + + Warning 32 - Full_path(/home/misha/projects/productionmason/web/auth/src/express-handler/ExpressHandler.re) + { "message": "unused value t_encode.", "range": "None" } + + Common_error - Full_path(/home/misha/projects/productionmason/web/auth/tests/Auth_Test.res) + { + "message": "This has type: int\nSomewhere wanted: array<'a>", + "range": { + "end": { "character": 45, "line": 223 }, + "start": { "character": 44, "line": 223 } + } + } + |}]; + + let example_log_5 = + {|#Start(1780595107359) + + Can't continue... Found a circular dependency in your code: + Demo (src/Demo.res) + → Other (src/Other.res) + → Demo (src/Demo.res) + Possible solutions: + - Extract shared code into a new module both depend on. + #Done(1780595107364) + |} + in + + Parse.parse_log_content example_log_5 |> print_logs; + [%expect + {| + Circular_dependency - Relative_path(src/Demo.res) + { + "message": "Can't continue... Found a circular dependency in your code:\nDemo (src/Demo.res)\n→ Other (src/Other.res)\n→ Demo (src/Demo.res)\nPossible solutions:\n- Extract shared code into a new module both depend on.", + "range": "None" + } + + Circular_dependency - Relative_path(src/Other.res) + { + "message": "Can't continue... Found a circular dependency in your code:\nDemo (src/Demo.res)\n→ Other (src/Other.res)\n→ Demo (src/Demo.res)\nPossible solutions:\n- Extract shared code into a new module both depend on.", + "range": "None" + } + |}]; + + let example_log_6 = + {|#Start(1780595245481) + FAILED: dependency cycle: src/Demo.cmj -> src/Other.cmj -> src/Demo.cmj. + #Done(1780595245488)|} + in + + Parse.parse_log_content example_log_6 |> print_logs; + [%expect + {| + Circular_dependency - Relative_path(src/Demo.res) + { + "message": "FAILED: dependency cycle: src/Demo.cmj -> src/Other.cmj -> src/Demo.cmj.", + "range": "None" + } + + Circular_dependency - Relative_path(src/Other.res) + { + "message": "FAILED: dependency cycle: src/Demo.cmj -> src/Other.cmj -> src/Demo.cmj.", + "range": "None" + } + |}]; + + let example_log_7 = + {|#Start(1780595410580) + + We've found a bug for you! + /tmp/my-rescript-app/src/Demo.res:1:9-15 + + 1 │ let a = Other.a + 2 │ + + The value a can't be found in Other + + FAILED: cannot make progress due to previous errors. + #Done(1780595410597)|} + in + + Parse.parse_log_content example_log_7 |> print_logs; + [%expect + {| + Common_error - Full_path(/tmp/my-rescript-app/src/Demo.res) + { + "message": "The value a can't be found in Other\nFAILED: cannot make progress due to previous errors.", + "range": { + "end": { "character": 15, "line": 0 }, + "start": { "character": 8, "line": 0 } + } + } + |}]; + + let example_log_8 = + {|#Start(1780603624843) + Error in lsp-test: + + Syntax error! + /home/pedro/Desktop/projects/lsp-test/src/ArrayUtils.res:1:41-2:0 + + 1 │ let empty = arr => Array.length(arr) === + 2 │ + + Did you forget to write an expression here? + + #Done(1780603624849)|} + in + Parse.parse_log_content example_log_8 |> print_logs; + [%expect + {| + Syntax_error - Full_path(/home/pedro/Desktop/projects/lsp-test/src/ArrayUtils.res) + { + "message": "Did you forget to write an expression here?", + "range": { + "end": { "character": 0, "line": 1 }, + "start": { "character": 40, "line": 0 } + } + } + |}]; + + let example_log_9 = + {|#Start(1780630901455) + + We've found a bug for you! + /home/pedro/Desktop/projects/lsp-test/src/ArrayUtils.res:1:41-43 + + 1 │ let empty = arr => Array.length(arr) == "2"; + 2 │ + + This has type: string + But it's being compared to something of type: int + + You can only compare things of the same type. + + You can convert string to int with Int.fromString. + + #Done(1780630901468)|} + in + + Parse.parse_log_content example_log_9 |> print_logs; + [%expect + {| + Common_error - Full_path(/home/pedro/Desktop/projects/lsp-test/src/ArrayUtils.res) + { + "message": "This has type: string\nBut it's being compared to something of type: int\nYou can only compare things of the same type.\nYou can convert string to int with Int.fromString.", + "range": { + "end": { "character": 43, "line": 0 }, + "start": { "character": 40, "line": 0 } + } + } + |}]; + + let example_log_10 = + {|#Start(1781383030890) + + Warning number 8 (configured as error) + /home/pedro/Desktop/projects/rescript-lang.org/apps/docs/app/DocsRoutes.res:6:3-9:3 + + 4 │ + 5 │ let f = (a: t) => { + 6 │ switch a { + 7 │ | A => () + 8 │ | B => () + 9 │ } + 10 │ } + 11 │ + + You forgot to handle a possible case here, for example: + | C + + + Warning number 37 + /home/pedro/Desktop/projects/rescript-lang.org/apps/docs/app/DocsRoutes.res:3:1-18 + + 1 │ open ReactRouter.Routes + 2 │ + 3 │ type t = A | B | C + 4 │ + 5 │ let f = (a: t) => { + + constructor A is never used to build values. + (However, this constructor appears in patterns.) + + + Warning number 37 + /home/pedro/Desktop/projects/rescript-lang.org/apps/docs/app/DocsRoutes.res:3:1-18 + + 1 │ open ReactRouter.Routes + 2 │ + 3 │ type t = A | B | C + 4 │ + 5 │ let f = (a: t) => { + + constructor B is never used to build values. + (However, this constructor appears in patterns.) + + + Warning number 37 + /home/pedro/Desktop/projects/rescript-lang.org/apps/docs/app/DocsRoutes.res:3:1-18 + + 1 │ open ReactRouter.Routes + 2 │ + 3 │ type t = A | B | C + 4 │ + 5 │ let f = (a: t) => { + + unused constructor C. + + + Warning number 32 + /home/pedro/Desktop/projects/rescript-lang.org/apps/docs/app/DocsRoutes.res:5:5 + + 3 │ type t = A | B | C + 4 │ + 5 │ let f = (a: t) => { + 6 │ switch a { + 7 │ | A => () + + unused value f. + + #Done(1781383030936)|} + in + Parse.parse_log_content example_log_10 |> print_logs; + [%expect + {| + Warning 8 (configured as error) - Full_path(/home/pedro/Desktop/projects/rescript-lang.org/apps/docs/app/DocsRoutes.res) + { + "message": "You forgot to handle a possible case here, for example:\n| C", + "range": { + "end": { "character": 3, "line": 8 }, + "start": { "character": 2, "line": 5 } + } + } + + Warning 37 - Full_path(/home/pedro/Desktop/projects/rescript-lang.org/apps/docs/app/DocsRoutes.res) + { + "message": "constructor A is never used to build values.\n(However, this constructor appears in patterns.)", + "range": { + "end": { "character": 18, "line": 2 }, + "start": { "character": 0, "line": 2 } + } + } + + Warning 37 - Full_path(/home/pedro/Desktop/projects/rescript-lang.org/apps/docs/app/DocsRoutes.res) + { + "message": "constructor B is never used to build values.\n(However, this constructor appears in patterns.)", + "range": { + "end": { "character": 18, "line": 2 }, + "start": { "character": 0, "line": 2 } + } + } + + Warning 37 - Full_path(/home/pedro/Desktop/projects/rescript-lang.org/apps/docs/app/DocsRoutes.res) + { + "message": "unused constructor C.", + "range": { + "end": { "character": 18, "line": 2 }, + "start": { "character": 0, "line": 2 } + } + } + + Warning 32 - Full_path(/home/pedro/Desktop/projects/rescript-lang.org/apps/docs/app/DocsRoutes.res) + { + "message": "unused value f.", + "range": { + "end": { "character": 5, "line": 4 }, + "start": { "character": 4, "line": 4 } + } + } + |}]; + + let example_log_10 = + {|#Start(1780630901455) + + We've found a bug for you! + tests/build_tests/super_errors/fixtures/access_record_field_on_option.res:12:15 + + 10 │ } + 11 │ + 12 │ let f = X.x.c.d + 13 │ + + You're trying to access the record field d, but the value you're trying to access it on is an option. + You need to unwrap the option first before accessing the record field. + + Possible solutions: + - Use Option.map to transform the option: xx->Option.map(field => field.d) + - Or use Option.getOr with a default: xx->Option.getOr(defaultRecord).d + + #Done(1781383030936)|} + in + + Parse.parse_log_content example_log_10 |> print_logs; + [%expect + {| + Common_error - Relative_path(tests/build_tests/super_errors/fixtures/access_record_field_on_option.res) + { + "message": "You're trying to access the record field d, but the value you're trying to access it on is an option.\nYou need to unwrap the option first before accessing the record field.\nPossible solutions:\n- Use Option.map to transform the option: xx->Option.map(field => field.d)\n- Or use Option.getOr with a default: xx->Option.getOr(defaultRecord).d", + "range": { + "end": { "character": 15, "line": 11 }, + "start": { "character": 14, "line": 11 } + } + } + |}]; + + let example_log_11 = + {|#Start(1780630901455) + + We've found a bug for you! + tests/build_tests/super_errors/fixtures/arity_mismatch.res:2:21-27 + + 1 │ let makeVar = (~f, ()) => 34 + 2 │ let makeVariables = makeVar(~f=f => f) + 3 │ + + This function call is incorrect. + The function has type: + (~f: 'a => 'a, unit) => int + + - The function takes 1 unlabelled argument, but is called with none + + + We've found a bug for you! + tests/build_tests/super_errors/fixtures/array_item_type_mismatch.res:1:16-22 + + 1 │ let x = [1, 2, "hello"] + 2 │ + + This array item has type: string + But this array is expected to have items of type: int + + Arrays can only contain items of the same type. + + Possible solutions: + - Convert all values in the array to the same type. + - Use a tuple, if your array is of fixed length. Tuples can mix types freely, and compiles to a JavaScript array. Example of a tuple: `let myTuple = (10, "hello", 15.5, true) + + You can convert string to int with Int.fromString. + + We've found a bug for you! + tests/build_tests/super_errors/fixtures/include_extension_constructors_mismatch.res:4:5-7:1 + + 2 │ type t = .. + 3 │ type t += E(int) + 4 │ } = { + 5 │ type t = .. + 6 │ type t += E(string) + 7 │ } + 8 │ + + Signature mismatch: + Modules do not match: + { + type t = .. + type t += E(string) + } + is not included in + { + type t = .. + type t += E(int) + } + Extension declarations do not match: + type t += E(string) + is not included in + type t += E(int) + tests/build_tests/super_errors/fixtures/include_extension_constructors_mismatch.res:3:13-18: + Expected declaration + tests/build_tests/super_errors/fixtures/include_extension_constructors_mismatch.res:6:13-21: + Actual declaration + + We've found a bug for you! + tests/build_tests/super_errors/fixtures/functor_apply_arg_mismatch.res:13:22-29 + + 11 │ } + 12 │ + 13 │ module Result = Make(Concrete) + 14 │ let _ = Result.bar + 15 │ + + Signature mismatch: + Modules do not match: { + let unrelated: string + } is not included in HasFoo + The value `foo' is required but not provided + tests/build_tests/super_errors/fixtures/functor_apply_arg_mismatch.res:2:3-14: + Expected declaration + + We've found a bug for you! + tests/build_tests/super_errors/fixtures/include_missing_field.res:4:5-6:1 + + 2 │ let x: int + 3 │ let y: string + 4 │ } = { + 5 │ let x = 1 + 6 │ } + 7 │ + + Signature mismatch: + Modules do not match: + { + let x: int + } + is not included in + { + let x: int + let y: string + } + The value `y' is required but not provided + tests/build_tests/super_errors/fixtures/include_missing_field.res:3:3-15: + Expected declaration + + We've found a bug for you! + tests/build_tests/super_errors/fixtures/include_modtype_infos_mismatch.res:5:5-9:1 + + 3 │ let x: int + 4 │ } + 5 │ } = { + 6 │ module type T = { + 7 │ let x: string + 8 │ } + 9 │ } + 10 │ + + Signature mismatch: + Modules do not match: + { + module type T = { + let x: string + } + } + is not included in + { + module type T = { + let x: int + } + } + Module type declarations do not match: + module type T = { + let x: string + } + does not match + module type T = { + let x: int + } + At position module type T = + Modules do not match: + { + let x: string + } + is not included in + { + let x: int + } + At position module type T = + Values do not match: let x: string is not included in let x: int + tests/build_tests/super_errors/fixtures/include_modtype_infos_mismatch.res:3:5-14: + Expected declaration + tests/build_tests/super_errors/fixtures/include_modtype_infos_mismatch.res:7:5-17: + Actual declaration + + #Done(1781383030936)|} + in + + Parse.parse_log_content example_log_11 |> print_logs; + [%expect + {| + Common_error - Relative_path(tests/build_tests/super_errors/fixtures/arity_mismatch.res) + { + "message": "This function call is incorrect.\nThe function has type:\n(~f: 'a => 'a, unit) => int\n- The function takes 1 unlabelled argument, but is called with none", + "range": { + "end": { "character": 27, "line": 1 }, + "start": { "character": 20, "line": 1 } + } + } + + Common_error - Relative_path(tests/build_tests/super_errors/fixtures/array_item_type_mismatch.res) + { + "message": "This array item has type: string\nBut this array is expected to have items of type: int\nArrays can only contain items of the same type.\nPossible solutions:\n- Convert all values in the array to the same type.\n- Use a tuple, if your array is of fixed length. Tuples can mix types freely, and compiles to a JavaScript array. Example of a tuple: `let myTuple = (10, \"hello\", 15.5, true)\nYou can convert string to int with Int.fromString.", + "range": { + "end": { "character": 22, "line": 0 }, + "start": { "character": 15, "line": 0 } + } + } + + Common_error - Relative_path(tests/build_tests/super_errors/fixtures/include_extension_constructors_mismatch.res) + { + "message": "Signature mismatch:\nModules do not match:\n{\ntype t = ..\ntype t += E(string)\n}\nis not included in\n{\ntype t = ..\ntype t += E(int)\n}\nExtension declarations do not match:\ntype t += E(string)\nis not included in\ntype t += E(int)\ntests/build_tests/super_errors/fixtures/include_extension_constructors_mismatch.res:3:13-18:\nExpected declaration\ntests/build_tests/super_errors/fixtures/include_extension_constructors_mismatch.res:6:13-21:\nActual declaration", + "range": { + "end": { "character": 1, "line": 6 }, + "start": { "character": 4, "line": 3 } + } + } + + Common_error - Relative_path(tests/build_tests/super_errors/fixtures/functor_apply_arg_mismatch.res) + { + "message": "Signature mismatch:\nModules do not match: {\nlet unrelated: string\n} is not included in HasFoo\nThe value `foo' is required but not provided\ntests/build_tests/super_errors/fixtures/functor_apply_arg_mismatch.res:2:3-14:\nExpected declaration", + "range": { + "end": { "character": 29, "line": 12 }, + "start": { "character": 21, "line": 12 } + } + } + + Common_error - Relative_path(tests/build_tests/super_errors/fixtures/include_missing_field.res) + { + "message": "Signature mismatch:\nModules do not match:\n{\nlet x: int\n}\nis not included in\n{\nlet x: int\nlet y: string\n}\nThe value `y' is required but not provided\ntests/build_tests/super_errors/fixtures/include_missing_field.res:3:3-15:\nExpected declaration", + "range": { + "end": { "character": 1, "line": 5 }, + "start": { "character": 4, "line": 3 } + } + } + + Common_error - Relative_path(tests/build_tests/super_errors/fixtures/include_modtype_infos_mismatch.res) + { + "message": "Signature mismatch:\nModules do not match:\n{\nmodule type T = {\nlet x: string\n}\n}\nis not included in\n{\nmodule type T = {\nlet x: int\n}\n}\nModule type declarations do not match:\nmodule type T = {\nlet x: string\n}\ndoes not match\nmodule type T = {\nlet x: int\n}\nAt position module type T = \nModules do not match:\n{\nlet x: string\n}\nis not included in\n{\nlet x: int\n}\nAt position module type T = \nValues do not match: let x: string is not included in let x: int\ntests/build_tests/super_errors/fixtures/include_modtype_infos_mismatch.res:3:5-14:\nExpected declaration\ntests/build_tests/super_errors/fixtures/include_modtype_infos_mismatch.res:7:5-17:\nActual declaration", + "range": { + "end": { "character": 1, "line": 8 }, + "start": { "character": 4, "line": 4 } + } + } + |}]; + + let example_log_12 = + {|#Start(1781743112207) + Error in build_warn_as_error: + + Warning number 110 (configured as error) + /home/pedro/Desktop/projects/rescript-compiler/tests/build_tests/build_warn_as_error/src/Demo.res:1:17-21 + + 1 │ let todo = _ => %todo + 2 │ + + Todo found. + + This code is not implemented yet and will crash at runtime. Make sure you implement this before running the code. + + #Done(1781743112217)|} + in + + Parse.parse_log_content example_log_12 |> print_logs; + [%expect + {| + Warning 110 (configured as error) - Full_path(/home/pedro/Desktop/projects/rescript-compiler/tests/build_tests/build_warn_as_error/src/Demo.res) + { + "message": "Todo found.\nThis code is not implemented yet and will crash at runtime. Make sure you implement this before running the code.", + "range": { + "end": { "character": 21, "line": 0 }, + "start": { "character": 16, "line": 0 } + } + } + |}]; + + let example_log_13 = + {|#Start(1781744629915) + + We've found a bug for you! + /home/pedro/Desktop/projects/rescript-compiler/tests/build_tests/gpr_978/src/gpr_978_module.res + + M is exported twice + + #Done(1781744629934)|} + in + + Parse.parse_log_content example_log_13 |> print_logs; + [%expect + {| + Common_error - Full_path(/home/pedro/Desktop/projects/rescript-compiler/tests/build_tests/gpr_978/src/gpr_978_module.res) + { "message": "M is exported twice", "range": "None" } + |}]; + + let example_log_14 = + {|#Start(1781910915195) + + We've found a bug for you! + /home/pedro/Desktop/projects/rescript-lang.org/apps/docs/scripts/LogAlgoliaEnvStatus.res:13:37-85 + + 11 ┆ switch Node.Process.argv[1] { + 12 ┆ | Some(entrypoint) => + 13 ┆ Node.URL.fileURLToPath(url) === Node.Path.resolve(Node.Process.cwd(), + ┆ entrypoint) + 14 ┆ | None => false + 15 ┆ } + + This has type: string + But it's being compared to something of type: int + + You can only compare things of the same type. + + You can convert string to int with Int.fromString. + + + We've found a bug for you! + /home/pedro/Desktop/projects/rescript-lang.org/apps/docs/scripts/gendocs.res:23:3-24:21 + + 21 │ let args = Process.argv->Array.slice(~start=2) + 22 │ let dirname = + 23 │ url + 24 │ ->URL.fileURLToPath + 25 │ ->Path.dirname + 26 │ + + This has type: int + But this function argument is expecting: string + + You can convert int to string with Int.toString. + + #Done(1781910915345)|} + in + + Parse.parse_log_content example_log_14 |> print_logs; + [%expect + {| + Common_error - Full_path(/home/pedro/Desktop/projects/rescript-lang.org/apps/docs/scripts/LogAlgoliaEnvStatus.res) + { + "message": "This has type: string\nBut it's being compared to something of type: int\nYou can only compare things of the same type.\nYou can convert string to int with Int.fromString.", + "range": { + "end": { "character": 85, "line": 12 }, + "start": { "character": 36, "line": 12 } + } + } + + Common_error - Full_path(/home/pedro/Desktop/projects/rescript-lang.org/apps/docs/scripts/gendocs.res) + { + "message": "This has type: int\nBut this function argument is expecting: string\nYou can convert int to string with Int.toString.", + "range": { + "end": { "character": 21, "line": 23 }, + "start": { "character": 2, "line": 22 } + } + } + |}] diff --git a/lsp/src/configuration.ml b/lsp/src/configuration.ml new file mode 100644 index 00000000000..fa39cadfcd7 --- /dev/null +++ b/lsp/src/configuration.ml @@ -0,0 +1,52 @@ +type hover = { + support_markdown_links: bool; [@key "supportMarkdownLinks"] [@default false] +} +[@@deriving yojson {strict = false}] + +type inlay_hints = { + enable: bool; [@default false] + max_length: int option; [@key "maxLength"] [@default Some 25] +} +[@@deriving yojson {strict = false}] + +type t = { + hover: hover; [@default {support_markdown_links = false}] + code_lens: bool; [@key "codeLens"] [@default false] + inlay_hints: inlay_hints; + [@key "inlayHints"] [@default {enable = false; max_length = Some 25}] +} +[@@deriving yojson {strict = false}] + +let default = + { + hover = {support_markdown_links = false}; + code_lens = false; + inlay_hints = {enable = false; max_length = Some 25}; + } + +let%expect_test "parse config" = + let parse_and_print_config json = + match of_yojson (Yojson.Safe.from_string json) with + | Ok c -> c |> to_yojson |> Yojson.Safe.pretty_to_string |> print_endline + | Error _ -> print_endline "Error" + in + + let example_config_json_1 = + {| + { + "askToStartBuild": false, + "inlayHints": { "enable": true, "maxLength": 25 }, + "codeLens": true, + "signatureHelp": { "enabled": true, "forConstructorPayloads": true }, + "incrementalTypechecking": { "enable": true, "acrossFiles": false }, + "cache": { "projectConfig": { "enable": true } }, + "binaryPath": null, + "platformPath": null, + "runtimePath": null, + "compileStatus": { "enable": true }, + "logLevel": "info" + } + |} + in + parse_and_print_config example_config_json_1; + [%expect {| { "codeLens": true, "inlayHints": { "enable": true } } |}] diff --git a/lsp/src/constants.ml b/lsp/src/constants.ml new file mode 100644 index 00000000000..1e3bda18a0f --- /dev/null +++ b/lsp/src/constants.ml @@ -0,0 +1,14 @@ +let ( /+ ) = Filename.concat + +let rescript_json = "rescript.json" +let bsconfig_json = "bsconfig.json" +let compiler_dir_partial_path = "lib" /+ "bs" +let compiler_ocaml_dir_partial_path = "lib" /+ "ocaml" +let compiler_log = ".compiler.log" +let sources_dirs = ".sourcedirs.json" +let compiler_log_partial_path = compiler_dir_partial_path /+ compiler_log +let compiler_info_partial_path = + compiler_dir_partial_path /+ "compiler-info.json" +let build_ninja_partial_path = compiler_dir_partial_path /+ "build.ninja" +let rewatch_lock_partial_path = "lib" /+ "rewatch.lock" +let rescript_lock_partial_path = "lib" /+ "rescript.lock" diff --git a/lsp/src/custom_requests.ml b/lsp/src/custom_requests.ml new file mode 100644 index 00000000000..65f1d7ce86c --- /dev/null +++ b/lsp/src/custom_requests.ml @@ -0,0 +1,116 @@ +open Lsp +open Lsp.Types + +let make_error ?(code = Jsonrpc.Response.Error.Code.InternalError) message = + Jsonrpc.Response.Error.make ~message ~code () + +module Open_compiled_file = struct + let meth = "textDocument/openCompiled" + + (* Custom request to open compiled file + + Request params: TextDocumentIdentifier + Return: TextDocumentIdentifier | ResponseError + *) + let on_request ~(params : Jsonrpc.Structured.t option) ~(state : State.t) = + let make ~(uri : Uri.t) ~(state : State.t) = + let compiled_uri = + Helpers.get_compiled_file ~uri + ~compiler_config:(State.compiler_config state) + ~fs:state.fs + ~workspace_root:(State.workspace_root state) + in + match compiled_uri with + | Some uri -> Ok uri + | None -> Error ("Failed to get compiled file for " ^ Uri.to_path uri) + in + match params with + | Some json -> ( + let doc = + json |> Jsonrpc.Structured.yojson_of_t + |> TextDocumentIdentifier.t_of_yojson + in + match make ~uri:doc.uri ~state with + | Ok uri -> + Ok + (TextDocumentIdentifier.create ~uri + |> TextDocumentIdentifier.yojson_of_t) + | Error message -> Error (make_error message)) + | _ -> + Error + (make_error ?code:(Some InvalidParams) + (Printf.sprintf + "Invalid params for request %s, expected a TextDocumentIdentifier" + meth)) +end + +module Create_interface_file = struct + let meth = "textDocument/createInterface" + + let make ~uri ~cmi_uri ~(state : State.t) = + match Document.kind_of_uri uri with + | Res -> ( + match + Analysis.Create_interface.command + ~source:(Document_store.get ~uri state.store |> Document.text) + ~cmi_file:(cmi_uri |> Uri.to_path) + with + | Ok content -> + let resi_file = Uri.to_path uri ^ "i" in + let () = + Fs.save ~append:false ~create:(`Or_truncate 0o644) ~fs:state.fs + resi_file content + in + Ok (Uri.of_path resi_file) + | Error e -> Error e) + | _ -> + Error + (Printf.sprintf + "Invalid file to create interface for %s. Expected a .res file" + (Uri.to_string uri)) + + (* Custom request to create a interface file. + + Request params: TextDocumentIdentifier + Return: TextDocumentIdentifier | ResponseError + *) + let on_request ~(params : Jsonrpc.Structured.t option) ~(state : State.t) = + match params with + | Some json -> + let doc = + json |> Jsonrpc.Structured.yojson_of_t + |> TextDocumentIdentifier.t_of_yojson + in + let uri = doc.uri in + let cmi_file = + Helpers.get_cmi_file ~uri ~fs:state.fs + ~compiler_config:(State.compiler_config state) + ~workspace_root:(State.workspace_root state) + in + let result = + match cmi_file with + | Some cmi_file -> + let response = + match make ~uri ~cmi_uri:(Uri.of_path cmi_file) ~state with + | Ok uri -> + Ok + (TextDocumentIdentifier.create ~uri + |> TextDocumentIdentifier.yojson_of_t) + | Error message -> Error (make_error message) + in + response + | None -> + Error + (make_error + (Printf.sprintf + "Failed to find cmi file for %s to create interface file" + (Uri.to_string uri))) + in + result + | _ -> + Error + (make_error ?code:(Some InvalidParams) + (Printf.sprintf + "Invalid params for request %s, expected a TextDocumentIdentifier" + meth)) +end diff --git a/lsp/src/diagnostics.ml b/lsp/src/diagnostics.ml new file mode 100644 index 00000000000..fd5948c59f8 --- /dev/null +++ b/lsp/src/diagnostics.ml @@ -0,0 +1,395 @@ +open Lsp +open Lsp.Types + +module Uri_map = Map.Make (Uri) + +type diagnostics = Diagnostic.t list Uri_map.t + +(* Diagnostics come from sources with different lifetimes: + - compiler diagnostics are workspace/build state; + - compiler syntax diagnostics are only trusted after a document is closed; + - syntax diagnostics for open documents must follow the unsaved editor buffer. + Keep them separate so one source can be refreshed or cleared without + accidentally dropping diagnostics owned by another source. *) +type t = { + (* Non-syntax build diagnostics parsed from .compiler.log, such as type + errors, warnings, and dependency cycles. *) + compiler: diagnostics; + (* Syntax diagnostics parsed from .compiler.log. These are only published for + closed documents because open documents use the editor buffer below. *) + compiler_syntax: diagnostics; + (* Syntax diagnostics produced from currently open in-memory documents. *) + syntax: diagnostics; + send: PublishDiagnosticsParams.t -> unit; +} + +let empty () = Uri_map.empty + +let make ~diagnostics ~send = + {compiler = diagnostics; compiler_syntax = empty (); syntax = empty (); send} + +let merge_diagnostics left right = + Uri_map.merge + (fun _ left right -> + match (left, right) with + | None, None -> None + | Some diagnostics, None | None, Some diagnostics -> Some diagnostics + | Some left, Some right -> Some (left @ right)) + left right + +let get_all t : diagnostics = + let compiler = merge_diagnostics t.compiler t.compiler_syntax in + merge_diagnostics t.syntax compiler + +let diagnostics ?(include_syntax = true) t = + if include_syntax then get_all t + else merge_diagnostics t.compiler t.compiler_syntax + +let replace_snapshot ~old ~latest = + Uri_map.merge + (fun _ existing incoming -> + match (existing, incoming) with + | None, None -> None + | Some _, None -> Some [] + | None, Some diagnostics | Some _, Some diagnostics -> Some diagnostics) + old latest + +let update_compiler ~(new_diagnostics : diagnostics) t = + {t with compiler = replace_snapshot ~old:t.compiler ~latest:new_diagnostics} + +let update_compiler_syntax ~(new_diagnostics : diagnostics) t = + { + t with + compiler_syntax = + replace_snapshot ~old:t.compiler_syntax ~latest:new_diagnostics; + } + +let update_syntax ~uri ~(new_diagnostics : Diagnostic.t list) t = + {t with syntax = Uri_map.add uri new_diagnostics t.syntax} + +let clear_syntax ~uri t = update_syntax ~uri ~new_diagnostics:[] t + +let%expect_test "get_all merges diagnostics from every source" = + let uri = Uri.of_path "/workspace/A.res" in + let range = + Range.create + ~start:(Position.create ~line:0 ~character:0) + ~end_:(Position.create ~line:0 ~character:1) + in + let diagnostic message = + Diagnostic.create ~range ~message:(`String message) () + in + let t = + { + compiler = Uri_map.singleton uri [diagnostic "compiler"]; + compiler_syntax = Uri_map.singleton uri [diagnostic "compiler syntax"]; + syntax = Uri_map.singleton uri [diagnostic "syntax"]; + send = (fun _ -> ()); + } + in + get_all t |> Uri_map.find uri + |> List.iter (fun (diagnostic : Diagnostic.t) -> + match diagnostic.message with + | `String message -> print_endline message + | `MarkupContent {value} -> print_endline value); + [%expect {| + syntax + compiler + compiler syntax |}] + +let send ?(include_syntax = true) ?(force_publish_uris = []) + ?(should_publish = fun _ -> true) t = + let diagnostics = diagnostics ~include_syntax t in + Uri_map.iter + (fun uri diagnostics -> + if should_publish uri then + t.send (PublishDiagnosticsParams.create ~uri ~diagnostics ())) + diagnostics; + force_publish_uris + |> List.iter (fun uri -> + (* A URI excluded from this push may still have diagnostics retained + from an earlier push. Publish an empty list when ownership changes + so the client does not combine that stale result with pull data. *) + if (not (should_publish uri)) || not (Uri_map.mem uri diagnostics) then + t.send (PublishDiagnosticsParams.create ~uri ~diagnostics:[] ())) + +let%expect_test "send can reserve an open document for pull diagnostics" = + let open_uri = Uri.of_path "/workspace/Open.res" in + let closed_uri = Uri.of_path "/workspace/Closed.res" in + let range = + Range.create + ~start:(Position.create ~line:0 ~character:0) + ~end_:(Position.create ~line:0 ~character:1) + in + let diagnostic message = + Diagnostic.create ~range ~message:(`String message) () + in + let print ({uri; diagnostics; _} : PublishDiagnosticsParams.t) = + Printf.printf "%s: %d\n" + (Filename.basename (Uri.to_path uri)) + (List.length diagnostics) + in + let t = + { + compiler = + Uri_map.empty + |> Uri_map.add open_uri [diagnostic "open compiler"] + |> Uri_map.add closed_uri [diagnostic "closed compiler"]; + compiler_syntax = Uri_map.empty; + syntax = Uri_map.singleton open_uri [diagnostic "open syntax"]; + send = print; + } + in + send ~include_syntax:false ~force_publish_uris:[open_uri] + ~should_publish:(fun uri -> Uri.compare uri open_uri <> 0) + t; + [%expect {| + Closed.res: 1 + Open.res: 0 |}] + +(* Convert parsed compiler-log entries into LSP diagnostics grouped by document + URI. Compiler logs may report paths either relative to the workspace root or + as absolute paths, so this function normalizes each entry into a DocumentUri + before publishing. + + Open-document syntax diagnostics come from the current editor buffer. Compiler + syntax diagnostics are only useful for closed documents, where the compiler + log is the freshest source we have. *) +let to_lsp_format ?(include_syntax = false) ?(include_non_syntax = true) + (workspace_root : DocumentUri.t) (doc_store : Document_store.t) diagnostics + = + let workspace_root_path = workspace_root |> DocumentUri.to_path in + + let range_of_entry uri = function + | Some range -> range + | None -> ( + match Document_store.get_opt ~uri doc_store with + | None -> + let shortest_possible_code = "let a=1" in + Document.range_of_text shortest_possible_code + | Some doc -> Document.range_of_text (Document.text doc)) + in + + let diagnostic_of_entry uri (entry : Compiler_log.Parse.diagnostic_entry) = + let message, severity = + match entry.kind with + | Warning {number; configured_as_error} -> + let default = "Warning " ^ string_of_int number in + if configured_as_error then + ( default ^ " (configured as error) - " ^ entry.message, + DiagnosticSeverity.Error ) + else (default ^ " - " ^ entry.message, DiagnosticSeverity.Warning) + | Unknown -> ("Unknown error - " ^ entry.message, DiagnosticSeverity.Error) + | _ -> (entry.message, DiagnosticSeverity.Error) + in + Diagnostic.create ~severity ~source:"ReScript" + ~range:(range_of_entry uri entry.range) + ~message:(`String message) () + in + + let diagnostics_sanitized = + diagnostics + |> List.filter_map (fun (entry : Compiler_log.Parse.diagnostic_entry) -> + let uri = + match entry.path with + | Compiler_log.Parse.Relative_path p -> + Uri.of_path (Filename.concat workspace_root_path p) + | Full_path p -> Uri.of_path p + in + + match entry.kind with + | Syntax_error -> + if + include_syntax + && Option.is_none (Document_store.get_opt ~uri doc_store) + then Some (uri, diagnostic_of_entry uri entry) + else None + | Circular_dependency -> + if not include_non_syntax then None + else Some (uri, diagnostic_of_entry uri entry) + | Warning _ | Common_error | Unknown -> + if not include_non_syntax then None + else Some (uri, diagnostic_of_entry uri entry)) + in + + diagnostics_sanitized + |> List.fold_left + (fun acc (uri, diagnostic) -> + Uri_map.update uri + (function + | None -> Some [diagnostic] + | Some diagnostics -> Some (diagnostic :: diagnostics)) + acc) + Uri_map.empty + +let has_non_syntax_diagnostics diagnostics = + diagnostics + |> List.exists + (fun (diagnostic_entry : Compiler_log.Parse.diagnostic_entry) -> + match diagnostic_entry.kind with + | Syntax_error -> false + | Warning _ | Circular_dependency | Common_error | Unknown -> true) + +let update_from_compiler_log ~workspace_root ~doc_store compiler_log t = + let compiler_syntax = + compiler_log + |> to_lsp_format ~include_syntax:true ~include_non_syntax:false + workspace_root doc_store + in + let t = update_compiler_syntax ~new_diagnostics:compiler_syntax t in + (* A syntax-only compiler log is an incomplete build snapshot: the compiler + stopped before type checking the rest of the workspace. Keep the previous + non-syntax compiler diagnostics until the next complete build snapshot or + successful empty log arrives. *) + if compiler_log = [] || has_non_syntax_diagnostics compiler_log then + let compiler = + compiler_log + |> to_lsp_format ~include_syntax:false ~include_non_syntax:true + workspace_root doc_store + in + update_compiler ~new_diagnostics:compiler t + else t + +let diagnostics_from_completed_logs contents = + if List.for_all Compiler_log.Parse.is_complete contents then + contents + |> List.map Compiler_log.Parse.parse_log_content + |> List.flatten |> Option.some + else None + +let collect_diagnostics_from_log_using_source_dirs workspace_root fs = + let ( /+ ) = Filename.concat in + let workspace_root_path = workspace_root |> Uri.to_path in + let path = + workspace_root_path /+ Constants.compiler_dir_partial_path + /+ Constants.sources_dirs + in + (* .sourcedirs.json is the source of truth for compiler-log locations. It is + written by the build system and includes every build root that can emit a + .compiler.log. Fall back to the root log only when that metadata is missing + or empty, so opening a document can still publish the last known diagnostics + from older or partial builds. *) + let build_roots = + Source_dirs.get_build_roots_from_file ~fs path |> Option.value ~default:[] + in + build_roots + |> List.filter_map (fun build_root -> + let compiler_log_path = + workspace_root_path /+ build_root /+ Constants.compiler_log + in + Fs.load ~fs compiler_log_path) + |> diagnostics_from_completed_logs + +let%expect_test "only completed compiler logs form a diagnostics snapshot" = + let print = function + | None -> print_endline "incomplete" + | Some diagnostics -> + Printf.printf "complete: %d diagnostics\n" (List.length diagnostics) + in + diagnostics_from_completed_logs ["#Start(1)\n"] |> print; + [%expect {| incomplete |}]; + + diagnostics_from_completed_logs ["#Start(1)\n#Done(2)\n"] |> print; + [%expect {| complete: 0 diagnostics |}]; + + diagnostics_from_completed_logs ["#Start(1)\n#Done(2)\n"; "#Start(3)\n"] + |> print; + [%expect {| incomplete |}] + +let%expect_test "compiler syntax diagnostics don't clear type diagnostics" = + let workspace_root = Uri.of_path "/workspace" in + let uri_a = Uri.of_path "/workspace/A.res" in + let uri_b = Uri.of_path "/workspace/B.res" in + let uri_c = Uri.of_path "/workspace/C.res" in + let range = + Range.create + ~start:(Position.create ~line:0 ~character:0) + ~end_:(Position.create ~line:0 ~character:1) + in + let diagnostic message = + Diagnostic.create ~range ~message:(`String message) () + in + let entry uri kind message = + let open Compiler_log.Parse in + {kind; path = Full_path (Uri.to_path uri); message; range = Some range} + in + let print t = + diagnostics t + |> Uri_map.iter (fun uri diagnostics -> + let messages = + diagnostics + |> List.map (fun (diagnostic : Diagnostic.t) -> + match diagnostic.message with + | `String message -> message + | `MarkupContent {value} -> value) + in + Printf.printf "%s: %s\n" + (Filename.basename (Uri.to_path uri)) + (match messages with + | [] -> "" + | messages -> String.concat ", " messages)) + in + let doc_store = Document_store.make () in + let t = make ~diagnostics:(empty ()) ~send:(fun _ -> ()) in + let t = + update_from_compiler_log ~workspace_root ~doc_store + [entry uri_b Common_error "type B"; entry uri_c Common_error "type C"] + t + in + print t; + [%expect {| + B.res: type B + C.res: type C |}]; + + let doc = + Document.make + (DidOpenTextDocumentParams.create + ~textDocument: + (TextDocumentItem.create ~languageId:"rescript" ~uri:uri_a + ~text:"let x =" ~version:1)) + in + ignore (Document_store.add doc_store ~doc); + let t = update_syntax ~uri:uri_a ~new_diagnostics:[diagnostic "syntax A"] t in + let t = + update_from_compiler_log ~workspace_root ~doc_store + [entry uri_a Syntax_error "compiler syntax A"] + t + in + print t; + [%expect {| + A.res: syntax A + B.res: type B + C.res: type C |}]; + + ignore (Document_store.remove doc_store ~uri:uri_a); + let t = clear_syntax ~uri:uri_a t in + let t = + update_from_compiler_log ~workspace_root ~doc_store + [entry uri_a Syntax_error "compiler syntax A"] + t + in + print t; + [%expect + {| + A.res: compiler syntax A + B.res: type B + C.res: type C |}]; + + let t = + update_from_compiler_log ~workspace_root ~doc_store + [entry uri_b Common_error "type B fixed later"] + t + in + print t; + [%expect + {| + A.res: + B.res: type B fixed later + C.res: |}]; + + let t = update_from_compiler_log ~workspace_root ~doc_store [] t in + print t; + [%expect {| + A.res: + B.res: + C.res: |}] diff --git a/lsp/src/document.ml b/lsp/src/document.ml new file mode 100644 index 00000000000..9c1fa71cb78 --- /dev/null +++ b/lsp/src/document.ml @@ -0,0 +1,104 @@ +open Lsp +open Lsp.Types + +type t = Text_document.t + +type kind = Analysis.Files.classified_file + +let kind_of_uri uri = + let p = Uri.to_path uri in + match p |> Filename.extension with + | ".res" -> Analysis.Files.Res + | ".resi" -> Analysis.Files.Resi + | other -> + Jsonrpc.Response.Error.raise + (Jsonrpc.Response.Error.make ~code:InvalidRequest + ~message:("unsupported file extension for " ^ p) + ~data:(`Assoc [("extension", `String other)]) + ()) + +let kind doc = doc |> Text_document.documentUri |> kind_of_uri + +let extension_of_kind (kind : kind) = + match kind with + | Res -> "res" + | Resi -> "resi" + | Other -> assert false + +let make (doc : DidOpenTextDocumentParams.t) = + let t_doc = Text_document.make ~position_encoding:`UTF8 doc in + t_doc + +let text t = Text_document.text t + +let uri t = Text_document.documentUri t + +let update_text ?version t changes = + match Text_document.apply_content_changes ?version t changes with + | exception Text_document.Invalid_utf _ -> + (* TODO: add log *) + t + | tdoc -> tdoc + +let range_of_text (text : string) : Range.t = + let text_length = String.length text in + let rec end_position offset line character = + if offset = text_length then Position.create ~line ~character + else + match String.get text offset with + | '\n' -> end_position (offset + 1) (line + 1) 0 + | _ -> end_position (offset + 1) line (character + 1) + in + Range.create + ~start:(Position.create ~line:0 ~character:0) + ~end_:(end_position 0 0 0) + +let%expect_test "range_of_text" = + let print text = + let range = range_of_text text in + Yojson.Safe.pretty_to_string + (`Assoc + [ + ("start", range.start |> Position.yojson_of_t); + ("end", range.end_ |> Position.yojson_of_t); + ]) + |> print_endline + in + print ""; + print "let a = 1"; + print "let a = 1\nlet b = 2"; + print "let a = 1\n"; + print "\xC3\xA9"; + print "ab"; + print "ab\ncd"; + [%expect + {| + { + "start": { "character": 0, "line": 0 }, + "end": { "character": 0, "line": 0 } + } + { + "start": { "character": 0, "line": 0 }, + "end": { "character": 9, "line": 0 } + } + { + "start": { "character": 0, "line": 0 }, + "end": { "character": 9, "line": 1 } + } + { + "start": { "character": 0, "line": 0 }, + "end": { "character": 0, "line": 1 } + } + { + "start": { "character": 0, "line": 0 }, + "end": { "character": 2, "line": 0 } + } + { + "start": { "character": 0, "line": 0 }, + "end": { "character": 2, "line": 0 } + } + { + "start": { "character": 0, "line": 0 }, + "end": { "character": 2, "line": 1 } + } + |}] diff --git a/lsp/src/document_store.ml b/lsp/src/document_store.ml new file mode 100644 index 00000000000..a362bc0df8b --- /dev/null +++ b/lsp/src/document_store.ml @@ -0,0 +1,52 @@ +open Lsp +type document = {text: string; version: int} + +type t = {documents: (Uri.t, Document.t) Hashtbl.t} + +let make () = {documents = Hashtbl.create 50} + +let raise ~message = + Jsonrpc.Response.Error.raise + (Jsonrpc.Response.Error.make ~code:InvalidRequest ~message ()) + +let add t ~doc = + let uri = Document.uri doc in + (match Hashtbl.mem t.documents uri with + | false -> Hashtbl.add t.documents uri doc + | true -> + raise + ~message: + (Printf.sprintf "Document store already has %s to open" + (Uri.to_string uri))); + t + +let update t ~doc = + let uri = Document.uri doc in + (match Hashtbl.find_opt t.documents uri with + | None -> + raise + ~message: + (Printf.sprintf "Document store not found %s to update" + (Uri.to_string uri)) + | Some _ -> Hashtbl.replace t.documents uri doc); + t + +let remove t ~uri = + (match Hashtbl.mem t.documents uri with + | true -> Hashtbl.remove t.documents uri + | false -> + raise + ~message: + (Printf.sprintf "Document store not found %s to remove" + (Uri.to_string uri))); + t + +let get_opt t ~uri = Hashtbl.find_opt t.documents uri + +let get t ~uri = + match get_opt t ~uri with + | Some doc -> doc + | None -> + raise + ~message: + (Printf.sprintf "Document store not found %s to get" (Uri.to_string uri)) diff --git a/lsp/src/dune b/lsp/src/dune new file mode 100644 index 00000000000..f270aab36b0 --- /dev/null +++ b/lsp/src/dune @@ -0,0 +1,8 @@ +(library + (name rescript_language_server) + (libraries lsp eio analysis str ppx_deriving_yojson.runtime) + (inline_tests) + (preprocess + (pps ppx_deriving_yojson ppx_expect)) + (flags + (:standard -w +a-4-9-30-40-41-42-48-70))) diff --git a/lsp/src/execute_commands.ml b/lsp/src/execute_commands.ml new file mode 100644 index 00000000000..a635f790ba7 --- /dev/null +++ b/lsp/src/execute_commands.ml @@ -0,0 +1,117 @@ +open Lsp +open Lsp.Types + +let request_show_document ~uri ~takeFocus (server : State.t Server.t) = + let state = Server.state server in + let client_support_window_show_document = + match (State.params state).capabilities.window with + | Some {showDocument = Some {support}} -> support = true + | _ -> false + in + if client_support_window_show_document then ( + Server.request + (Server_request.ShowDocumentRequest + (ShowDocumentParams.create ~takeFocus ~uri ())) + server; + Ok None) + else + let message = + match (State.params state).clientInfo with + | Some {name; version} -> + Printf.sprintf + "The client %s (version %s) dont support window/showDocument request" + name + (Option.value version ~default:"unknown") + | None -> "The client dont support window/showDocument request" + in + Error (Printf.sprintf "Failed to open %s. %s" (Uri.to_path uri) message) + +module Open_compiled = struct + let name = "rescript/openCompiled" + let execute ~(arguments : Yojson.Safe.t list option) + (server : State.t Server.t) = + match arguments with + | Some [`String uri] -> + request_show_document ~uri:(Uri.of_string uri) ~takeFocus:true server + | _ -> + Error + (Printf.sprintf + "Invalid arguments for workspace/executeCommand %s. Expected a list \ + of string: [uri]" + name) +end + +module Create_interface = struct + let name = "rescript/createInterface" + let execute ~(arguments : Yojson.Safe.t list option) + (server : State.t Server.t) = + match arguments with + | Some [`String uri; `String cmi_uri] -> ( + match + Custom_requests.Create_interface_file.make ~uri:(Uri.of_string uri) + ~cmi_uri:(Uri.of_string cmi_uri) ~state:(Server.state server) + with + | Ok uri -> request_show_document ~uri ~takeFocus:true server + | Error message -> + Error + (Printf.sprintf "Failed to create interface file for %s and %s: %s" + uri cmi_uri message)) + | _ -> + Error + (Printf.sprintf + "Invalid arguments for workspace/executeCommand %s. Expected a list \ + of string: [uri, cmi_uri]" + name) +end + +module Switch_implementation_interface = struct + let name = "rescript/switchImplementationInterface" + let execute ~(arguments : Yojson.Safe.t list option) + (server : State.t Server.t) = + match arguments with + | Some [`String uri] -> + request_show_document ~uri:(Uri.of_string uri) ~takeFocus:true server + | _ -> + Error + (Printf.sprintf + "Invalid arguments for workspace/executeCommand %s. Expected a list \ + of string: [uri]" + name) +end + +module Dump_server_state = struct + let name = "rescript/dumpServerState" + let execute ~(arguments : Yojson.Safe.t list option) + (server : State.t Server.t) = + let json = + State.to_yojson (Server.state server) |> Yojson.Safe.pretty_to_string + in + let response = `Assoc [("content", `String json)] in + Ok (Some response) +end [@warning "-27"] + +module Dump_cmt = struct + let name = "rescript/dumpCmt" + let execute ~(arguments : Yojson.Safe.t list option) + (server : State.t Server.t) = + match arguments with + | Some [`String uri] -> ( + let analysis_state = server |> Server.state |> State.analysis_state in + match Helpers.load_full (Uri.of_string uri) analysis_state with + | Some full -> + let cmt_content = Analysis.Cmt_viewer.dump ~full in + let response = `Assoc [("content", `String cmt_content)] in + Ok (Some response) + | None -> + Error + (Printf.sprintf + "Failed to dump cmt for %s, we are unable to read the metadata of \ + cmt file" + uri)) + | _ -> + Error + (Printf.sprintf + "Invalid arguments for workspace/executeCommand %s. Expected a list \ + of string: [uri]" + name) +end diff --git a/lsp/src/formatter.ml b/lsp/src/formatter.ml new file mode 100644 index 00000000000..90eaa714bd9 --- /dev/null +++ b/lsp/src/formatter.ml @@ -0,0 +1,69 @@ +open Lsp + +let run ~uri ~(state : State.t) = + let doc = Document_store.get ~uri state.store in + let source = Document.text doc in + let kind_file = Document.kind doc in + + let read_all_from_channel channel = + let buffer = Buffer.create 4096 in + let bytes = Bytes.create 4096 in + let rec loop () = + match input channel bytes 0 (Bytes.length bytes) with + | 0 -> Buffer.contents buffer + | read -> + Buffer.add_subbytes buffer bytes 0 read; + loop () + in + loop () + in + + let process_status_to_string = function + | Unix.WEXITED code -> Printf.sprintf "exited with code %d" code + | Unix.WSIGNALED signal -> Printf.sprintf "killed by signal %d" signal + | Unix.WSTOPPED signal -> Printf.sprintf "stopped by signal %d" signal + in + + let executable = + let executable_name = "rescript" in + let root_path = State.workspace_root state |> Uri.to_path in + let ( /+ ) = Filename.concat in + root_path /+ "node_modules" /+ ".bin" /+ executable_name + in + let extension_name = Document.extension_of_kind kind_file in + let stdin, stdout = + Unix.open_process_args executable + [|executable; "format"; "--stdin"; "." ^ extension_name|] + in + let close_process_noerr () = + try ignore (Unix.close_process (stdin, stdout)) with _ -> () + in + + let document_has_syntax_error = + let engine = Res_driver.parsing_engine in + match kind_file with + | Res -> (engine.parse_implementation_from_source ~source).invalid + | Resi -> (engine.parse_interface_from_source ~source).invalid + | _ -> assert false + in + + match document_has_syntax_error with + | false -> ( + try + output_string stdout source; + close_out stdout; + let formatted = read_all_from_channel stdin in + match Unix.close_process (stdin, stdout) with + | Unix.WEXITED 0 -> Ok (Diff.edit ~from:source ~to_:formatted) + | status -> + Error + (Printf.sprintf "%s %s" executable (process_status_to_string status)) + with exn -> + close_out_noerr stdout; + close_in_noerr stdin; + close_process_noerr (); + Error (Printexc.to_string exn)) + | true -> + Error + (Printf.sprintf "Failed to format %s, document has syntax error" + (Uri.to_path uri)) diff --git a/lsp/src/fs.ml b/lsp/src/fs.ml new file mode 100644 index 00000000000..fda93591bf0 --- /dev/null +++ b/lsp/src/fs.ml @@ -0,0 +1,53 @@ +type fs = Eio.Fs.dir_ty Eio.Path.t + +let append = Eio.Path.( / ) + +let to_eio_path ~(fs : fs) (path : string) = append fs path + +let protect f = + match f () with + | value -> Some value + | exception _ -> None + +let load ~fs path = protect (fun () -> Eio.Path.load (to_eio_path ~fs path)) + +let save ?append ~create ~fs path content = + Eio.Path.save ?append ~create (to_eio_path ~fs path) content + +let stat ?(follow = true) ~fs path = + protect (fun () -> Eio.Path.stat ~follow (to_eio_path ~fs path)) + +let kind ?(follow = true) ~fs path = + match protect (fun () -> Eio.Path.kind ~follow (to_eio_path ~fs path)) with + | Some `Not_found | None -> None + | Some kind -> Some kind + +let exists ?(follow = true) ~fs path = + match kind ~follow ~fs path with + | Some _ -> true + | None -> false + +let is_file ~fs path = + protect (fun () -> Eio.Path.is_file (to_eio_path ~fs path)) + +let is_directory ~fs path = + protect (fun () -> Eio.Path.is_directory (to_eio_path ~fs path)) + +let read_dir ~fs path = + protect (fun () -> Eio.Path.read_dir (to_eio_path ~fs path)) + +let mkdir ~perm ~fs path = + protect (fun () -> Eio.Path.mkdir ~perm (to_eio_path ~fs path)) + +let mkdirs ?exists_ok ~perm ~fs path = + protect (fun () -> Eio.Path.mkdirs ?exists_ok ~perm (to_eio_path ~fs path)) + +let read_link ~fs path = + protect (fun () -> Eio.Path.read_link (to_eio_path ~fs path)) + +let unlink ~fs path = protect (fun () -> Eio.Path.unlink (to_eio_path ~fs path)) + +let rmdir ~fs path = protect (fun () -> Eio.Path.rmdir (to_eio_path ~fs path)) + +let rmtree ?missing_ok ~fs path = + protect (fun () -> Eio.Path.rmtree ?missing_ok (to_eio_path ~fs path)) diff --git a/lsp/src/helpers.ml b/lsp/src/helpers.ml new file mode 100644 index 00000000000..32dcda52157 --- /dev/null +++ b/lsp/src/helpers.ml @@ -0,0 +1,233 @@ +open Lsp +open Lsp.Types + +let workspace_root_uri_of_initialize_params (params : InitializeParams.t) = + match (params.workspaceFolders, params.rootUri, params.rootPath) with + | Some (Some workspace_folders), Some root_uri, None -> + let root = + match workspace_folders with + | [] -> root_uri + | ws :: _ -> ws.uri + in + root + | _, Some root_uri, _ -> root_uri + | _, _, Some (Some root_path) -> root_path |> Uri.of_path + | _ -> + failwith + ("Failed to find workspace root uri of initialize params. Received: " + ^ Yojson.Safe.pretty_to_string (InitializeParams.yojson_of_t params)) + +(* Return the most specific root from [entries] that contains [path]. + + Matching is done on path boundaries, so [/repo/packages/app] matches + [/repo/packages/app/src/A.res], but it does not match + [/repo/packages/application/src/A.res]. + + If multiple roots match, the longest root wins. This matters in monorepos + where a file can be under both the workspace root and a nested package root. + + Examples: + - [best_root_match ~path:"/repo/packages/app/src/A.res" + ["/repo"; "/repo/packages/app"]] + returns [Some "/repo/packages/app"]. + - [best_root_match ~path:"/repo/packages/application/src/A.res" + ["/repo/packages/app"]] + returns [None]. *) +let best_root_match ~path entries = + let path_matches_root ~path ~root = + let is_sep = function + | '/' | '\\' -> true + | _ -> false + in + let root_len = String.length root in + let path_len = String.length path in + root_len > 0 + && (path = root + || path_len > root_len + && String.starts_with ~prefix:root path + && (is_sep root.[root_len - 1] || is_sep path.[root_len])) + in + entries + |> List.fold_left + (fun best root -> + if path_matches_root ~path ~root then + match best with + | None -> Some root + | Some best_root -> + if String.length root > String.length best_root then Some root + else best + else best) + None + +let relative_to ~root path = + let root = if String.ends_with ~suffix:"/" root then root else root ^ "/" in + + if String.starts_with ~prefix:root path then + let root_len = String.length root in + String.sub path root_len (String.length path - root_len) + else path + +let to_camel_case (text : string) : string = + let len = String.length text in + let buffer = Buffer.create len in + + let is_separator = function + | ' ' | '\t' | '\n' | '\r' | '-' -> true + | _ -> false + in + + let rec loop i capitalize_next = + if i >= len then Buffer.contents buffer + else + let c = text.[i] in + + if is_separator c then loop (i + 1) true + else if capitalize_next then ( + Buffer.add_char buffer (Char.uppercase_ascii c); + loop (i + 1) false) + else ( + Buffer.add_char buffer c; + loop (i + 1) false) + in + + loop 0 true + +let get_cmi_file ~(uri : Uri.t) ~(compiler_config : Compiler_config.t) + ~(fs : Fs.fs) ~workspace_root = + let config_roots = + compiler_config |> Compiler_config.Uri_map.to_seq + |> Seq.map (fun (uri, config) -> (uri |> Uri.to_path, config)) + |> List.of_seq |> List.map fst + in + + let config = + match + config_roots |> best_root_match ~path:(workspace_root |> Uri.to_path) + with + | Some root -> + Compiler_config.Uri_map.find_opt (Uri.of_path root) compiler_config + | None -> None + in + match config with + | Some config -> + let namespace = + match config.namespace with + | Some (Namespace_bool true) -> config.name |> to_camel_case + | Some (Namespace_string name) -> name |> to_camel_case + | _ -> "" + in + let suffix_to_append = + if String.length namespace > 0 then "-" ^ namespace else "" + in + let path = Uri.to_path uri in + let resi_file = + match best_root_match ~path config_roots with + | Some package_root_path -> + let cmi_file = + let open Filename in + let ( /+ ) = concat in + let relative_path = relative_to ~root:package_root_path path in + let sources_dir = dirname relative_path in + let cmi = + let res_file = basename relative_path ^ suffix_to_append in + remove_extension res_file ^ ".cmi" + in + package_root_path /+ "lib" /+ "bs" /+ sources_dir /+ cmi + in + let result = + match Fs.exists ~follow:false ~fs cmi_file with + | true -> Some cmi_file + | false -> None + in + result + | None -> None + in + resi_file + | None -> None + +let get_compiled_file ~(uri : Uri.t) ~(compiler_config : Compiler_config.t) + ~(fs : Fs.fs) ~workspace_root = + let config_roots = + compiler_config |> Compiler_config.Uri_map.to_seq + |> Seq.map (fun (uri, config) -> (uri |> Uri.to_path, config)) + |> List.of_seq |> List.map fst + in + + let config = + match + config_roots |> best_root_match ~path:(workspace_root |> Uri.to_path) + with + | Some root -> + Compiler_config.Uri_map.find_opt (Uri.of_path root) compiler_config + | None -> None + in + match config with + | Some config -> + let {Compiler_config.suffix; folder; in_source} = + Compiler_config.get_output_config config + in + + let js_file_path = + let path = Uri.to_path uri in + + let file_path = + let open Filename in + let ( /+ ) = concat in + if in_source then + let filename = basename path in + let js_file = remove_extension filename ^ suffix in + Some (dirname path /+ js_file) + else + match best_root_match ~path config_roots with + | Some package_root_path -> + (* + Some example with sources + package_root_path: /home/pedro/Desktop/projects/rescript-lang.org/apps/guide + path: /home/pedro/Desktop/projects/rescript-lang.org/apps/guide/app/GuideHome.res + compiled js: /home/pedro/Desktop/projects/rescript-lang.org/apps/guide/lib/es6/app/GuideHome.jsx + *) + (* app/GuideHome.res *) + let relative_path = relative_to ~root:package_root_path path in + let sources_dir = dirname relative_path in + let js_file = + let res_file = basename relative_path in + remove_extension res_file ^ suffix + in + Some (package_root_path /+ "lib" /+ folder /+ sources_dir /+ js_file) + | None -> None + in + match file_path with + | Some file_path when Fs.exists ~fs ~follow:false file_path -> + Some (file_path |> Uri.of_path) + | _ -> None + in + + js_file_path + | None -> None + +let load_full uri (analysis_state : Analysis.Shared_types.state) = + let package_for_path ~path = + let roots = + analysis_state.packages_by_root |> Hashtbl.to_seq_keys |> List.of_seq + in + match best_root_match ~path roots with + | Some root -> Hashtbl.find_opt analysis_state.packages_by_root root + | None -> None + in + + let path = uri |> Uri.to_path in + match package_for_path ~path with + | Some package -> ( + let module_name = + Analysis.Build_system.namespaced_name package.namespace + (Analysis.Find_files.get_name path) + in + match Analysis.Cmt.full_for_incremental_cmt ~package ~module_name ~uri with + | Some cmt_info -> Some cmt_info + | None -> ( + match Hashtbl.find_opt package.paths_for_module module_name with + | Some paths -> + let cmt = Analysis.Shared_types.get_cmt_path ~uri paths in + Analysis.Cmt.full_for_cmt ~module_name ~package ~uri cmt + | None -> None)) + | None -> None diff --git a/lsp/src/rescript_language_server.ml b/lsp/src/rescript_language_server.ml new file mode 100644 index 00000000000..b0577ad6e74 --- /dev/null +++ b/lsp/src/rescript_language_server.ml @@ -0,0 +1,735 @@ +open Lsp +open Lsp.Types + +let initialization () = + let textDocumentSync = + `TextDocumentSyncOptions + (TextDocumentSyncOptions.create ~openClose:true + ~change:TextDocumentSyncKind.Incremental ~willSave:false + ~save:(`SaveOptions (SaveOptions.create ~includeText:false ())) + ~willSaveWaitUntil:false ()) + in + let completionProvider = + CompletionOptions.create + ~triggerCharacters:["."; ">"; "@"; "~"; "\""; "="; "("] + ~resolveProvider:true () + in + let codeLensProvider = CodeLensOptions.create ~resolveProvider:false () in + let signatureHelpProvider = + SignatureHelpOptions.create ~triggerCharacters:["("] + ~retriggerCharacters:["="; ","] () + in + let inlayHintProvider = + `InlayHintOptions (InlayHintOptions.create ~resolveProvider:false ()) + in + let renameProvider = + `RenameOptions (RenameOptions.create ~prepareProvider:true ()) + in + (* TODO: We support this? *) + let workspace = + let workspaceFolders = + WorkspaceFoldersServerCapabilities.create ~supported:true + ~changeNotifications:(`Bool true) () + in + ServerCapabilities.create_workspace ~workspaceFolders () + in + let semanticTokensProvider = + let legend = + SemanticTokensLegend.create ~tokenModifiers:[] + ~tokenTypes: + [ + "operator"; + "variable"; + "type"; + (* emit jsx-tag < and > in
as modifier *) + "modifier"; + "namespace"; + "enumMember"; + "property"; + (* emit jsxlowercase, div in
as interface *) + "interface"; + ] + in + let full = `Full (SemanticTokensOptions.create_full ~delta:false ()) in + `SemanticTokensOptions (SemanticTokensOptions.create ~legend ~full ()) + in + let codeActionProvider = + `CodeActionOptions (CodeActionOptions.create ~resolveProvider:false ()) + in + let executeCommandProvider = + ExecuteCommandOptions.create + ~commands: + [ + Execute_commands.Open_compiled.name; + Execute_commands.Switch_implementation_interface.name; + Execute_commands.Dump_server_state.name; + Execute_commands.Dump_cmt.name; + ] + () + in + (* TODO: Revisit interFileDependencies when pull diagnostics can report + dependency-aware invalidation. For now compiler-log diagnostics are + refreshed as full snapshots, so advertising false is more accurate. *) + let diagnosticProvider = + `DiagnosticOptions + (DiagnosticOptions.create ~workspaceDiagnostics:false + ~identifier:"rescript" ~interFileDependencies:false ()) + in + let capabilities = + ServerCapabilities.create ~textDocumentSync ~completionProvider + ~codeLensProvider ~hoverProvider:(`Bool true) ~signatureHelpProvider + ~renameProvider ~workspace ~semanticTokensProvider ~inlayHintProvider + ~definitionProvider:(`Bool true) ~typeDefinitionProvider:(`Bool true) + ~codeActionProvider ~documentSymbolProvider:(`Bool true) + ~referencesProvider:(`Bool true) ~documentFormattingProvider:(`Bool true) + ~executeCommandProvider ~diagnosticProvider () + in + let serverInfo = + InitializeResult.create_serverInfo ~name:"rescript-language-server" () + in + InitializeResult.create ~capabilities ~serverInfo () + +let get_updated_diagnostics_from_log (state : State.t) diagnostics = + let workspace_root = State.workspace_root state in + match + Diagnostics.collect_diagnostics_from_log_using_source_dirs workspace_root + state.fs + with + | None -> None + | Some compiler_log -> + Some + (Diagnostics.update_from_compiler_log ~workspace_root + ~doc_store:state.store compiler_log diagnostics) + +let get_latest_completed_diagnostics state diagnostics = + get_updated_diagnostics_from_log state diagnostics + |> Option.value ~default:diagnostics + +let uses_pull_diagnostics (state : State.t) = + match (State.params state).capabilities.textDocument with + | Some {diagnostic = Some _} -> true + | _ -> false + +(* Clients keep push diagnostics (textDocument/publishDiagnostics) and pull + diagnostics (textDocument/diagnostic) as separate results. Sending the same + compiler diagnostic through both mechanisms therefore displays it twice. + For clients that support pull diagnostics, open documents are owned by pull + and only closed documents receive pushed compiler diagnostics. Clients + without pull support continue to receive the complete pushed snapshot. *) +let publish_diagnostics ?(force_publish_uris = []) (state : State.t) diagnostics + = + if uses_pull_diagnostics state then + Diagnostics.send ~include_syntax:false ~force_publish_uris + ~should_publish:(fun uri -> + Option.is_none (Document_store.get_opt state.store ~uri)) + diagnostics + else Diagnostics.send diagnostics + +(* This intentionally mutates [analysis_state] in place. The analysis layer keeps + package discovery tables as mutable hash tables so later requests can resolve + files and modules without rebuilding package metadata. That makes this + function unsafe in the usual functional sense: callers must provide a fresh + per-server analysis state or accept that repeated calls append/overwrite + package roots as a side effect. *) +let discover_subpackages_and_populate ~workspace_root + ~(analysis_state : Analysis.Shared_types.state) ~server = + let root_package = + let root_path = workspace_root |> Uri.to_path in + match Analysis.Packages.new_bs_package ~root_path with + | Some p -> Some p + | None -> + (* TODO: Decide how the server should recover when the workspace has not + been built yet, for example right after cloning a repository. Package + discovery depends on generated build metadata, so most analysis + features cannot work until that metadata exists. + Options to evaluate: + - Keep the server running and show a window message. In this case user should restart the server after build. + - Stop the server after explaining that a build is required. + - Re-initialize `analysis_state` after DidChangeWatchedFiles reports a + new `.compiler.log` change. + *) + let message = + Printf.sprintf + "Failed to initialize the context for the project at %s. Try \ + building the project then restart the server" + root_path + in + Server.show_message_notification ~kind:MessageType.Error message server; + None + in + + let resolve_node_modules_paths = + match root_package with + | Some {dependencies} -> + let deps_paths, deps_errors = + List.partition_map + (fun dep_name -> + match + Analysis.Module_resolution.resolve_node_module_path + ~start_path:(workspace_root |> Uri.to_path) + dep_name + with + | Some value -> Either.Left value + | None -> Either.Right dep_name + | exception Failure err -> Either.Right err) + dependencies + in + if List.length deps_errors > 0 then + Server.show_message_notification ~kind:MessageType.Error + (Printf.sprintf "Failed to resolve dependencies. %s" + (String.concat ", " deps_errors)) + server; + Some deps_paths + | None -> None + in + + (match root_package with + | Some root_package -> + Hashtbl.add analysis_state.root_for_uri workspace_root + root_package.root_path; + Hashtbl.add analysis_state.packages_by_root root_package.root_path + root_package + | None -> ()); + + (match resolve_node_modules_paths with + | Some node_modules_paths -> + node_modules_paths + |> List.iter (fun node_module_path -> + let uri = Uri.of_path node_module_path in + match + Analysis.Packages.new_bs_package ~root_path:node_module_path + with + | Some package -> + Hashtbl.add analysis_state.root_for_uri uri package.root_path; + Hashtbl.add analysis_state.packages_by_root package.root_path + package + | None -> ()); + () + | None -> ()) + |> ignore + +let on_initialize (params : InitializeParams.t) (server : State.t Server.t) = + let state = Server.state server in + + let diagnostics = + Diagnostics.make ~diagnostics:(Diagnostics.empty ()) + ~send:(fun publish_diagnostics_params -> + Server.notification + (Server_notification.PublishDiagnostics publish_diagnostics_params) + server) + in + + let analysis_state = Analysis.Shared_types.create_state () in + + let workspace_root = Helpers.workspace_root_uri_of_initialize_params params in + + discover_subpackages_and_populate ~workspace_root ~analysis_state ~server; + + let compiler_config = + analysis_state.packages_by_root |> Hashtbl.to_seq + |> Seq.filter_map (fun (root_path, _) -> + match Compiler_config.parse ~root:root_path ~fs:state.fs with + | Ok config -> Some (Uri.of_path root_path, config) + | Error _ -> + (* NOTE: Surface malformed package configs to the client. We ignore + them for now so initialization can continue with any remaining + valid packages. *) + None) + |> Compiler_config.Uri_map.of_seq + in + + let state = + State.initialize state ~params ~diagnostics ~analysis_state ~compiler_config + in + let initialization_info = initialization () in + (initialization_info, state) + +let on_request (Client_request.E request) (server : State.t Server.t) = + let load_full uri (state : State.t) = + match state.status with + | Initialized _ -> Helpers.load_full uri (State.analysis_state state) + | Uninitialized -> None + in + + let ok value = Ok (Client_request.yojson_of_result request value) in + + let error ?(code = Jsonrpc.Response.Error.Code.InternalError) message = + Error (Jsonrpc.Response.Error.make ~message ~code ()) + in + + let state = Server.state server in + + match request with + | Client_request.Initialize params -> + let initialization_info, state = on_initialize params server in + (ok initialization_info, state) + | TextDocumentHover {position; textDocument = {uri}} -> + let doc = Document_store.get ~uri state.store in + let source = doc |> Document.text in + let full = load_full uri state in + let resp = + Analysis.Commands.hover + ~state:(State.analysis_state state) + ~source ~kind_file:(Document.kind doc) + ~pos:(position.line, position.character) + ~debug:false + ~supports_markdown_links: + state.configuration.hover.support_markdown_links ~full + in + (ok resp, state) + | TextDocumentDiagnostic {textDocument = {uri}} -> + let diagnostics = State.diagnostics state |> Diagnostics.get_all in + let items = + Diagnostics.Uri_map.find_opt uri diagnostics |> Option.value ~default:[] + in + let resp = + `RelatedFullDocumentDiagnosticReport + (RelatedFullDocumentDiagnosticReport.create ~items ()) + in + (ok resp, state) + | TextDocumentCompletion {textDocument = {uri}; position} -> + let doc = Document_store.get ~uri state.store in + let source = doc |> Document.text in + let full = load_full uri state in + + let resp = + Analysis.Commands.completion + ~state:(State.analysis_state state) + ~debug:false ~source ~kind_file:(Document.kind doc) + ~pos:(position.line, position.character) + ~full + in + (ok (Some (`List resp)), state) + | CompletionItemResolve item -> + let resp = + match (item.documentation, item.data) with + (* + documentation === null && item.data != null + See https://github.com/rescript-lang/rescript-vscode/blob/2bc69d29ed92e19b14054952bafe9d4af7bd4c4b/server/src/server.ts#L958-L970 + *) + | None, Some (`Assoc fields) -> ( + let file_path = List.assoc_opt "filePath" fields in + let module_path = List.assoc_opt "modulePath" fields in + match (file_path, module_path) with + | Some (`String file_path), Some (`String module_path) -> + let full = load_full (Uri.of_path file_path) state in + let documentation = + Analysis.Commands.completion_resolve + ~state:(State.analysis_state state) + ~full ~module_path + in + Some {item with documentation} + | _ -> None) + | _ -> None + in + (ok (resp |> Option.value ~default:item), state) + | SignatureHelp {textDocument = {uri}; position} -> + let doc = Document_store.get ~uri state.store in + let source = doc |> Document.text in + let full = load_full uri state in + let resp = + match + Analysis.Commands.signature_help + ~state:(State.analysis_state state) + ~source ~kind_file:(Document.kind doc) + ~pos:(position.line, position.character) + ~full ~allow_for_constructor_payloads:true ~debug:false + with + | Some s -> s + | None -> SignatureHelp.create ~signatures:[] () + in + (ok resp, state) + | TextDocumentDefinition {textDocument = {uri}; position} -> + let full = load_full uri state in + let resp = + match + Analysis.Commands.definition + ~state:(State.analysis_state state) + ~full + ~pos:(position.line, position.character) + ~debug:false + with + | Some loc -> Some (`Location [loc]) + | None -> None + in + (ok resp, state) + | TextDocumentTypeDefinition {textDocument = {uri}; position} -> + let full = load_full uri state in + let resp = + match + Analysis.Commands.type_definition + ~state:(State.analysis_state state) + ~full + ~pos:(position.line, position.character) + ~debug:false + with + | Some loc -> Some (`Location [loc]) + | None -> None + in + (ok resp, state) + | TextDocumentReferences {textDocument = {uri}; position} -> + let full = load_full uri state in + let resp = + Analysis.Commands.references + ~state:(State.analysis_state state) + ~full + ~pos:(position.line, position.character) + ~debug:false + in + (ok (Some resp), state) + | DocumentSymbol {textDocument = {uri}} -> + let doc = Document_store.get ~uri state.store in + let source = doc |> Document.text in + let resp = + Analysis.Document_symbol.get_symbols ~source + ~kind_file:(Document.kind doc) + in + (ok (Some (`DocumentSymbol resp)), state) + | CodeAction + {textDocument = {uri}; range = {start; end_}; context = {diagnostics}} -> + let full = load_full uri state in + let doc = Document_store.get ~uri state.store in + let source = doc |> Document.text in + let code_actions_from_compiler_log = + Code_actions.From_diagnostics.get ~doc ~diagnostics + in + let code_actions_from_analysis = + Analysis.Xform.extract_code_actions + ~state:(State.analysis_state state) + ~path:(Uri.to_path uri) + ~start_pos:(start.line, start.character) + ~end_pos:(end_.line, end_.character) + ~source ~kind_file:(Document.kind doc) ~full ~debug:false + in + let other_actions = + let client_support_window_show_document = + match (State.params state).capabilities.window with + | Some {showDocument = Some {support}} -> support = true + | _ -> false + in + + let open_compiled_file = + if client_support_window_show_document then + Code_actions.Open_compiled_file.make ~doc ~state + else [] + in + let create_interface_file = + Code_actions.Create_interface_file.make ~doc ~state + in + let switch_implementation_interface_file = + if client_support_window_show_document then + Code_actions.Switch_implementation_interface_file.make ~doc ~state + else [] + in + + open_compiled_file @ create_interface_file + @ switch_implementation_interface_file + in + let resp = + code_actions_from_compiler_log @ code_actions_from_analysis + @ other_actions + |> List.map (fun ca -> `CodeAction ca) + in + (ok (Some resp), state) + | TextDocumentCodeLens {textDocument = {uri}} -> + if state.configuration.code_lens then + let doc = Document_store.get ~uri state.store in + let source = doc |> Document.text in + let full = load_full uri state in + let resp = + Analysis.Hint.code_lens ~source ~kind_file:(Document.kind doc) ~full + ~debug:false + in + (ok (resp |> Option.value ~default:[]), state) + else (ok [], state) + | InlayHint {textDocument = {uri}; range = {start; end_}} -> + if state.configuration.inlay_hints.enable then + let doc = Document_store.get ~uri state.store in + let source = doc |> Document.text in + let full = load_full uri state in + let resp = + Analysis.Hint.inlay + ~state:(State.analysis_state state) + ~source ~kind_file:(Document.kind doc) ~full + ~pos:(start.line, end_.line) + ~max_length:state.configuration.inlay_hints.max_length ~debug:false + in + (ok resp, state) + else (ok None, state) + | SemanticTokensFull {textDocument = {uri}} -> + let doc = Document_store.get ~uri state.store in + let source = doc |> Document.text in + let resp = + Analysis.Semantic_tokens.semantic_tokens ~source + ~kind_file:(Document.kind doc) + in + (ok (Some resp), state) + | TextDocumentRename {textDocument = {uri}; position; newName} -> + let full = load_full uri state in + let resp = + match + Analysis.Commands.rename + ~state:(State.analysis_state state) + ~full + ~pos:(position.line, position.character) + ~new_name:newName ~debug:false + with + | Some we -> we + | None -> WorkspaceEdit.create () + in + (ok resp, state) + | TextDocumentPrepareRename {textDocument = {uri}; position} -> + let full = load_full uri state in + let resp = + match + Analysis.Commands.prepare_rename ~full + ~pos:(position.line, position.character) + ~debug:false + with + | Some {range} -> Some range + | None -> None + in + (ok resp, state) + | TextDocumentFormatting {textDocument = {uri}} -> ( + match Formatter.run ~uri ~state with + | Ok text_edit -> (ok (Some text_edit), state) + | Error message -> (error message, state)) + | ExecuteCommand {command; arguments} -> ( + let open Execute_commands in + match + List.assoc_opt command + [ + (Open_compiled.name, Open_compiled.execute); + (Create_interface.name, Create_interface.execute); + ( Switch_implementation_interface.name, + Switch_implementation_interface.execute ); + (Dump_server_state.name, Dump_server_state.execute); + (Dump_cmt.name, Dump_cmt.execute); + ] + with + | Some execute -> ( + match execute ~arguments server with + | Ok None -> (ok `Null, state) + | Ok (Some response) -> (ok response, state) + | Error message -> (error message, state)) + | None -> + ( error + (Printf.sprintf + "Unknown command %s for workspace/executeCommand request" command), + state )) + | Shutdown -> (ok (), state) + | DebugTextDocumentGet _ | DebugEcho _ | WorkspaceSymbol _ + | CodeActionResolve _ | TextDocumentColor _ | TextDocumentColorPresentation _ + | TextDocumentCodeLensResolve _ | TextDocumentHighlight _ + | TextDocumentFoldingRange _ | TextDocumentLinkResolve _ | TextDocumentLink _ + | WillSaveWaitUntilTextDocument _ | TextDocumentRangeFormatting _ + | TextDocumentOnTypeFormatting _ | SelectionRange _ + | TextDocumentImplementation _ | SemanticTokensDelta _ | TextDocumentMoniker _ + | TextDocumentPrepareCallHierarchy _ | CallHierarchyIncomingCalls _ + | CallHierarchyOutgoingCalls _ | SemanticTokensRange _ | LinkedEditingRange _ + | WillCreateFiles _ | WillRenameFiles _ | WillDeleteFiles _ + | InlayHintResolve _ | TextDocumentInlineCompletion _ + | TextDocumentInlineValue _ | WorkspaceSymbolResolve _ | WorkspaceDiagnostic _ + | TextDocumentRangesFormatting _ | TextDocumentPrepareTypeHierarchy _ + | TypeHierarchySupertypes _ | TypeHierarchySubtypes _ + | TextDocumentDeclaration _ -> + (error "Request not supported yet!", state) + | UnknownRequest {meth; params} -> ( + let open Custom_requests in + match + List.assoc_opt meth + [ + (Create_interface_file.meth, Create_interface_file.on_request); + (Open_compiled_file.meth, Open_compiled_file.on_request); + ] + with + | Some on_request -> (on_request ~params ~state, state) + | None -> + ( error ?code:(Some Jsonrpc.Response.Error.Code.InvalidRequest) + (Printf.sprintf "Unknown request %s" meth), + state )) + +let on_notification notification (server : State.t Server.t) = + let state = Server.state server in + + match notification with + | Client_notification.TextDocumentDidOpen did_open_params -> + let doc = Document.make did_open_params in + let store = Document_store.add ~doc state.store in + + let diagnostics = + State.diagnostics state + |> get_latest_completed_diagnostics state + |> Diagnostics.update_syntax ~uri:(Document.uri doc) + ~new_diagnostics: + (Analysis.Diagnostics.document_syntax ~source:(Document.text doc) + ~kind_file:(Document.kind doc)) + in + let state = {state with store} in + diagnostics + |> publish_diagnostics ~force_publish_uris:[Document.uri doc] state; + state |> State.update_diagnostics diagnostics + | TextDocumentDidChange {contentChanges; textDocument = {uri; version}} -> + let previous_doc = Document_store.get ~uri state.store in + let new_doc = Document.update_text ~version previous_doc contentChanges in + + let store = Document_store.update ~doc:new_doc state.store in + + let diagnostics = + State.diagnostics state + |> get_latest_completed_diagnostics state + |> Diagnostics.update_syntax ~uri + ~new_diagnostics: + (Analysis.Diagnostics.document_syntax + ~source:(Document.text new_doc) + ~kind_file:(Document.kind new_doc)) + in + + let state = {state with store} in + diagnostics |> publish_diagnostics ~force_publish_uris:[uri] state; + state |> State.update_diagnostics diagnostics + | TextDocumentDidClose {textDocument = {uri; _}} -> + let store = Document_store.remove ~uri state.store in + let state = {state with store} in + let diagnostics = + State.diagnostics state + |> Diagnostics.clear_syntax ~uri + |> get_latest_completed_diagnostics state + in + diagnostics |> publish_diagnostics state; + state |> State.update_diagnostics diagnostics + | Initialized -> + (* Register dynamic file watchers for compiler log files. + ReScript writes one .compiler.log per build root. In monorepos, + .sourcedirs.json contains the build_root entries for each subpackage, + so use it to watch every generated compiler log and refresh diagnostics + when any of them changes. *) + let watchers = + [WatchKind.Create; Change; Delete] + |> List.map (fun kind -> + FileSystemWatcher.create + ~kind + (* NOTE: Clients dont send notification `workspace/didChangeWatchedFiles` + when pattern is a relative path `lib/bs/.compiler.log` of full path + `{workspace_root}/lib/bs/.compiler.log`. The glob should start with `**` + *) + ~globPattern:(`Pattern ("**/lib/bs/" ^ Constants.compiler_log)) + ()) + in + + let registerOptions = + DidChangeWatchedFilesRegistrationOptions.create ~watchers + |> DidChangeWatchedFilesRegistrationOptions.yojson_of_t + in + let registration = + Registration.create ~id:"rescript_file_watchers" + ~method_:"workspace/didChangeWatchedFiles" ~registerOptions () + in + let params = RegistrationParams.create ~registrations:[registration] in + Server.request (Server_request.ClientRegisterCapability params) server; + + state + | DidChangeWatchedFiles _ -> ( + (* Do not limit diagnostics to the path reported by + DidChangeWatchedFilesParams. In monorepos, a build in one subpackage + can change diagnostics that should be shown for files in another + subpackage. Re-read every compiler log listed in .sourcedirs.json so + stale errors are cleared and cross-package diagnostics stay in sync. *) + Server.notification + (Server_notification.UnknownNotification + (Jsonrpc.Notification.create ~method_:"rescript/compilationFinished" ())) + server; + match State.diagnostics state |> get_updated_diagnostics_from_log state with + | None -> state + | Some diagnostics -> + diagnostics |> publish_diagnostics state; + + (match (State.params state).capabilities.workspace with + | Some {diagnostics = Some {refreshSupport = Some true}} + when uses_pull_diagnostics state -> + Server.request Server_request.WorkspaceDiagnosticRefresh server + | _ -> ()); + + (match (State.params state).capabilities.workspace with + | Some {codeLens = Some {refreshSupport = Some true}} -> + Server.request Server_request.CodeLensRefresh server + | _ -> ()); + + state |> State.update_diagnostics diagnostics) + | ChangeConfiguration _ -> + (* workspace/didChangeConfiguration only signals that settings may have + changed. The LSP configuration model is pull-based, so the server should + ignore this notification payload and fetch the scoped settings it needs + with workspace/configuration. + + See https://github.com/microsoft/language-server-protocol/issues/567#issuecomment-448538082 + *) + Server.request + (Server_request.WorkspaceConfiguration + (ConfigurationParams.create + (* NOTE: The client decides how to map that section to its own configuration store. + Behavior I observed using `rescript.settings`: + - Neovim send `null` + - Zed send `null` + - VSCode sends the correct data layout + The best approach was to increase the scope to `rescript` and write a small middleware in VSCode extension. + *) + ~items:[ConfigurationItem.create ~section:"rescript" ()])) + server; + state + | Exit -> state + | ChangeWorkspaceFolders _ | CancelRequest _ | DidSaveTextDocument _ + | DidCreateFiles _ | DidDeleteFiles _ | DidRenameFiles _ + | WillSaveTextDocument _ | WorkDoneProgressCancel _ | WorkDoneProgress _ + | NotebookDocumentDidOpen _ | NotebookDocumentDidChange _ + | NotebookDocumentDidSave _ | NotebookDocumentDidClose _ | SetTrace _ -> + state + | UnknownNotification {method_} -> + Server.log_message_notification ~kind:MessageType.Error + ("Unknown notication " ^ method_) + server; + state + +(* TODO: Revisit this *) +let on_response + (Server.Response_result (request, result) : Server.response_result) + (server : State.t Server.t) = + let request_error_message = function + | Server.Response_error error -> error.message + | Server.Decode_error exn -> Printexc.to_string exn + in + + let state = Server.state server in + + match (request, result) with + | Server_request.WorkspaceConfiguration _, result -> ( + match result with + | Ok [settings] -> ( + match Configuration.of_yojson settings with + | Ok configuration -> {state with configuration} + | Error _ -> + Server.log_message_notification ~kind:MessageType.Error + ("Failed to parse rescript settings: " + ^ Yojson.Safe.pretty_to_string settings) + server; + state) + | Ok _ -> + Server.log_message_notification ~kind:MessageType.Error + "Invalid rescript settings. The server not received a list with one \ + element" + server; + state + | Error err -> + Server.log_message_notification ~kind:MessageType.Error + ("Error on response of workspace/configuration request: " + ^ request_error_message err) + server; + state) + | _ -> state + +let listen ~input ~output ~fs = + let state = + State.make ~store:(Document_store.make ()) + ~configuration:Configuration.default ~fs + in + Server.listen ~input ~output ~on_request ~on_notification ~on_response ~state diff --git a/lsp/src/rescript_language_server.mli b/lsp/src/rescript_language_server.mli new file mode 100644 index 00000000000..bb8b50ba84d --- /dev/null +++ b/lsp/src/rescript_language_server.mli @@ -0,0 +1,5 @@ +val listen : + input:[> Eio__Flow.source_ty] Eio.Resource.t -> + output:[> Eio__Flow.sink_ty] Eio.Resource.t -> + fs:Eio.Fs.dir_ty Eio.Path.t -> + unit diff --git a/lsp/src/server.ml b/lsp/src/server.ml new file mode 100644 index 00000000000..c92d6bb7056 --- /dev/null +++ b/lsp/src/server.ml @@ -0,0 +1,287 @@ +module Io : sig + type 'a t + + val return : 'a -> 'a t + val raise : exn -> 'a t + val await : 'a t -> 'a + val async : (sw:Eio.Switch.t -> ('a, exn) result) -> 'a t + + module O : sig + val ( let+ ) : 'a t -> ('a -> 'b) -> 'b t + val ( let* ) : 'a t -> ('a -> 'b t) -> 'b t + end +end = struct + type 'a t = sw:Eio.Switch.t -> ('a, exn) result Eio.Promise.t + + let await t = Eio.Switch.run @@ fun sw -> Eio.Promise.await_exn (t ~sw) + let return value ~sw:_ = Eio.Promise.create_resolved (Ok value) + let error desc ~sw:_ = Eio.Promise.create_resolved (Error desc) + + let async f ~sw = + let promise, resolver = Eio.Promise.create () in + ( Eio.Fiber.fork ~sw @@ fun () -> + try + let result = f ~sw in + Eio.Promise.resolve resolver result + with exn -> Eio.Promise.resolve resolver @@ Error exn ); + promise + + let bind t f = + async @@ fun ~sw -> + match Eio.Promise.await (t ~sw) with + | Ok value -> Eio.Promise.await @@ f value ~sw + | Error desc -> Error desc + + let raise = error + + module O = struct + let ( let+ ) x f = bind x @@ fun value -> return @@ f value + let ( let* ) = bind + end +end + +module Chan : sig + type input + type output + + val of_source : [> Eio__Flow.source_ty] Eio.Resource.t -> input + val with_sink : [> Eio__Flow.sink_ty] Eio.Resource.t -> (output -> 'a) -> 'a + + val read_line : input -> string option Io.t + val read_exactly : input -> int -> string option Io.t + val write : output -> string list -> unit Io.t +end = struct + type input = {mutex: Eio.Mutex.t; buf: Eio.Buf_read.t} + type output = {mutex: Eio.Mutex.t; buf: Eio.Buf_write.t} + + let initial_size = 1024 + let max_size = 1024 * 1024 + + let of_source source : input = + let mutex = Eio.Mutex.create () in + let buf = Eio.Buf_read.of_flow ~initial_size ~max_size source in + {mutex; buf} + + let with_sink sink f = + let mutex = Eio.Mutex.create () in + Eio.Buf_write.with_flow ~initial_size sink @@ fun buf -> f {mutex; buf} + + let read_line (input : input) = + Io.async @@ fun ~sw:_ -> + Eio.Mutex.use_rw ~protect:true input.mutex @@ fun () -> + if Eio.Buf_read.eof_seen input.buf then Ok None + else + match Eio.Buf_read.line input.buf with + | line -> Ok (Some line) + | exception End_of_file -> Ok None + + let read_exactly (input : input) size = + Io.async @@ fun ~sw:_ -> + Eio.Mutex.use_rw ~protect:true input.mutex @@ fun () -> + if Eio.Buf_read.eof_seen input.buf then Ok None + else + match Eio.Buf_read.take size input.buf with + | data -> Ok (Some data) + | exception End_of_file -> Ok None + + let write (output : output) (str : string list) = + Io.async @@ fun ~sw:_ -> + Eio.Mutex.use_rw ~protect:true output.mutex @@ fun () -> + Ok (List.iter (Eio.Buf_write.string output.buf) str) +end + +module Lsp_Io = Lsp.Io.Make (Io) (Chan) + +let notification_of_jsonrpc notification = + match Lsp.Client_notification.of_jsonrpc notification with + | Ok notification -> notification + | Error error -> raise (Lsp.Io.Error error) + +module Request_id = struct + type t = Jsonrpc.Id.t + + let equal = Jsonrpc.Id.equal + let hash = Jsonrpc.Id.hash +end + +module Request_id_table = Hashtbl.Make (Request_id) + +type request_error = + | Response_error of Jsonrpc.Response.Error.t + | Decode_error of exn + +type response_result = + | Response_result : + 'a Lsp.Server_request.t * ('a, request_error) result + -> response_result + +type pending_request = Pending : 'a Lsp.Server_request.t -> pending_request + +type request_context = { + mutable next_id: int; + pending: pending_request Request_id_table.t; +} + +type 'a t = {channel: Chan.output; state: 'a; request_context: request_context} + +let state t = t.state + +let respond server response = + Io.await @@ Lsp_Io.write server.channel @@ Response response + +let notification notification server = + let notification = Lsp.Server_notification.to_jsonrpc notification in + Io.await @@ Lsp_Io.write server.channel @@ Notification notification + +let request request server = + let id = `Int server.request_context.next_id in + server.request_context.next_id <- server.request_context.next_id + 1; + Request_id_table.add server.request_context.pending id (Pending request); + let request = Lsp.Server_request.to_jsonrpc_request request ~id in + try Io.await @@ Lsp_Io.write server.channel @@ Request request + with exn -> + Request_id_table.remove server.request_context.pending id; + raise exn + +let handle_response (response : Jsonrpc.Response.t) server = + match + Request_id_table.find_opt server.request_context.pending response.id + with + | None -> None + | Some (Pending request) -> ( + Request_id_table.remove server.request_context.pending response.id; + match response.result with + | Ok json -> + let result = + match Lsp.Server_request.response_of_json request json with + | response -> Ok response + | exception exn -> Error (Decode_error exn) + in + Some (Response_result (request, result)) + | Error err -> + let result = Error (Response_error err) in + Some (Response_result (request, result))) + +let log_message_notification ?(kind = Lsp.Types.MessageType.Debug) message + server = + notification + (Lsp.Server_notification.LogMessage + (Lsp.Types.LogMessageParams.create ~type_:kind ~message)) + server + +let show_message_notification ?(kind = Lsp.Types.MessageType.Info) message + server = + notification + (Lsp.Server_notification.ShowMessage + (Lsp.Types.ShowMessageParams.create ~type_:kind ~message)) + server + +type lifecycle = Awaiting_initialize | Running | Shutdown_requested + +let error_response ~id ~code ~message = + let err = Jsonrpc.Response.Error.make ~code ~message () in + Jsonrpc.Response.{id; result = Error err} + +let is_initialize_request (request : Jsonrpc.Request.t) = + request.method_ = "initialize" + +let is_shutdown_request (request : Jsonrpc.Request.t) = + request.method_ = "shutdown" + +let is_exit_notification (notification : Jsonrpc.Notification.t) = + notification.method_ = "exit" + +let exit_from_lifecycle lifecycle = + let exit_code = + match lifecycle with + | Shutdown_requested -> 0 + | _ -> 1 + in + exit exit_code + +let rec input_loop ~input ~state with_ = + match Io.await @@ Lsp_Io.read input with + | Some packet -> + let state = with_ state packet in + input_loop ~input ~state with_ + | exception _ -> failwith "Server.input_loop" + | None -> () + +let listen ~input ~output ~on_request ~on_notification ~on_response ~state = + let lifecycle = ref Awaiting_initialize in + let handle_request server request = + match !lifecycle with + | Awaiting_initialize when not (is_initialize_request request) -> + respond server + (error_response ~id:request.id + ~code:Jsonrpc.Response.Error.Code.ServerNotInitialized + ~message:"Server has not received an initialize request"); + server.state + | Running when is_initialize_request request -> + respond server + (error_response ~id:request.id + ~code:Jsonrpc.Response.Error.Code.InvalidRequest + ~message:"Server has already been initialized"); + server.state + | Shutdown_requested -> + respond server + (error_response ~id:request.id + ~code:Jsonrpc.Response.Error.Code.InvalidRequest + ~message:"Server has already received a shutdown request"); + server.state + | Awaiting_initialize | Running -> + let response, state = + match Lsp.Client_request.of_jsonrpc request with + | Error message -> + let code = Jsonrpc.Response.Error.Code.InvalidParams in + let err = Jsonrpc.Response.Error.make ~code ~message () in + (Jsonrpc.Response.{id = request.id; result = Error err}, state) + | Ok packed -> + let result, state = on_request packed server in + (Jsonrpc.Response.{id = request.id; result}, state) + in + respond server response; + (match response.result with + | Ok _ when is_initialize_request request -> lifecycle := Running + | Ok _ when is_shutdown_request request -> lifecycle := Shutdown_requested + | Ok _ | Error _ -> ()); + state + in + let handle_notification server notification = + if is_exit_notification notification then exit_from_lifecycle !lifecycle + else + match !lifecycle with + | Awaiting_initialize | Shutdown_requested -> server.state + | Running -> on_notification (notification_of_jsonrpc notification) server + in + let input = Chan.of_source input in + Chan.with_sink output (fun channel -> + let request_context = + {next_id = 1; pending = Request_id_table.create 16} + in + input_loop ~input ~state (fun state packet -> + let server = {channel; state; request_context} in + match packet with + | Notification notification -> handle_notification server notification + | Request request -> handle_request server request + | Batch_call calls -> + List.fold_left + (fun state call -> + let server = {channel; state; request_context} in + match call with + | `Request request -> handle_request server request + | `Notification notification -> + handle_notification server notification) + state calls + | Response response -> ( + match handle_response response server with + | Some response -> on_response response server + | None -> state) + | Batch_response responses -> + List.fold_left + (fun state response -> + let server = {channel; state; request_context} in + match handle_response response server with + | Some response -> on_response response server + | None -> state) + state responses)) diff --git a/lsp/src/source_dirs.ml b/lsp/src/source_dirs.ml new file mode 100644 index 00000000000..227ff9470c5 --- /dev/null +++ b/lsp/src/source_dirs.ml @@ -0,0 +1,80 @@ +type cmt_scan_item = {build_root: string} [@@deriving yojson {strict = false}] + +type source_dirs = {cmt_scan: cmt_scan_item list} +[@@deriving yojson {strict = false}] + +let get_build_roots_from_json json = + match source_dirs_of_yojson json with + | Ok {cmt_scan} -> Some (List.map (fun {build_root} -> build_root) cmt_scan) + | Error _ -> None + +let get_build_roots_from_file ~fs path = + match Fs.load ~fs path with + | Some content -> ( + match Yojson.Safe.from_string content with + | json -> get_build_roots_from_json json + | exception _ -> None) + | None -> None + +let%expect_test "get_build_roots" = + let print_build_roots result = + match result with + | None -> () + | Some l -> List.iter print_endline l + in + let json_1 = + Yojson.Safe.from_string + {| +{ + "cmt_scan": [ + { + "build_root": "path/to/lib/bs" + } + ] +} + |} + in + + json_1 |> get_build_roots_from_json |> print_build_roots; + [%expect {| path/to/lib/bs |}]; + + let json_2 = + Yojson.Safe.from_string + {| +{ +"cmt_scan": [ + { + "build_root": "path/to/lib/bs" + }, + { + "build_root": "path2/to/lib/bs" + } +] +} + |} + in + + json_2 |> get_build_roots_from_json |> print_build_roots; + [%expect {| + path/to/lib/bs + path2/to/lib/bs + |}]; + + let json_3 = + Yojson.Safe.from_string + {| +{ +"cmt_scan": [ +{ + "build_root": [] +}, +{ + "build_root": {} +} +] +} +|} + in + + json_3 |> get_build_roots_from_json |> print_build_roots; + [%expect {| |}] diff --git a/lsp/src/state.ml b/lsp/src/state.ml new file mode 100644 index 00000000000..44bc054c77d --- /dev/null +++ b/lsp/src/state.ml @@ -0,0 +1,117 @@ +open Lsp.Types + +type status = + | Uninitialized + | Initialized of { + params: InitializeParams.t; + diagnostics: Diagnostics.t; + analysis_state: Analysis.Shared_types.state; + compiler_config: Compiler_config.t; + } + +type t = { + status: status; + store: Document_store.t; + configuration: Configuration.t; + fs: Eio.Fs.dir_ty Eio.Path.t; +} + +let make ~store ~configuration ~fs = + {status = Uninitialized; store; configuration; fs} + +let initialize t ~params ~diagnostics ~analysis_state ~compiler_config = + { + t with + status = Initialized {params; diagnostics; analysis_state; compiler_config}; + } + +let diagnostics t = + match t.status with + | Uninitialized -> assert false + | Initialized init -> init.diagnostics + +let params t = + match t.status with + | Uninitialized -> assert false + | Initialized init -> init.params + +(* TODO: Consider storing initialized state in a separate record so updates do + not need to reconstruct the full Initialized payload by hand. *) +let update_diagnostics diagnostics t = + match t.status with + | Uninitialized -> assert false + | Initialized {params; analysis_state; compiler_config} -> + { + t with + status = + Initialized {params; analysis_state; diagnostics; compiler_config}; + } + +let analysis_state t = + match t.status with + | Uninitialized -> assert false + | Initialized {analysis_state} -> analysis_state + +let compiler_config t = + match t.status with + | Uninitialized -> assert false + | Initialized {compiler_config} -> compiler_config + +let workspace_root t = + match t.status with + | Uninitialized -> assert false + | Initialized init -> + Helpers.workspace_root_uri_of_initialize_params init.params + +let to_yojson (t : t) : Yojson.Safe.t = + let document_store_to_yojson (store : Document_store.t) = + store.documents |> Hashtbl.to_seq + |> Seq.map (fun (uri, doc) -> + ( Lsp.Uri.to_string uri, + `Assoc + [ + ("version", `Int (Lsp.Text_document.version doc)); + ( "text_length", + `Int (doc |> Lsp.Text_document.text |> String.length) ); + ] )) + |> List.of_seq + |> fun fields -> `Assoc fields + in + + let diagnostics_to_yojson (diagnostics : Diagnostics.t) = + diagnostics |> Diagnostics.get_all |> Diagnostics.Uri_map.to_seq + |> Seq.map (fun (uri, diagnostics) -> + ( Lsp.Uri.to_string uri, + `List (List.map Diagnostic.yojson_of_t diagnostics) )) + |> List.of_seq + |> fun fields -> `Assoc fields + in + + let compiler_config_to_yojson (compiler_config : Compiler_config.t) = + compiler_config |> Compiler_config.Uri_map.to_seq + |> Seq.map (fun (uri, config) -> + (Lsp.Uri.to_string uri, Compiler_config.Parse.to_yojson config)) + |> List.of_seq + |> fun fields -> `Assoc fields + in + + let status_to_yojson = function + | Uninitialized -> `Assoc [("kind", `String "Uninitialized")] + | Initialized {params; diagnostics; compiler_config} -> + `Assoc + [ + ("kind", `String "Initialized"); + ("diagnostics", diagnostics_to_yojson diagnostics); + ("compiler_config", compiler_config_to_yojson compiler_config); + ("params", InitializeParams.yojson_of_t params); + ] + in + + `Assoc + [ + ("status", status_to_yojson t.status); + ("store", document_store_to_yojson t.store); + ("configuration", Configuration.to_yojson t.configuration); + ( "analysis_state", + Analysis.Shared_types.state_to_yojson (analysis_state t) ); + ] diff --git a/package.json b/package.json index 09612a20c37..1626fbff011 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ "tests/tests", "tests/tools_tests", "tests/commonjs_tests", + "tests/lsp_tests/**", "scripts/res" ], "packageManager": "yarn@4.18.1", diff --git a/rescript-language-server.opam b/rescript-language-server.opam new file mode 100644 index 00000000000..e10bf88261e --- /dev/null +++ b/rescript-language-server.opam @@ -0,0 +1,33 @@ +# This file is generated by dune, edit dune-project instead +opam-version: "2.0" +synopsis: "ReScript LSP" +maintainer: ["Hongbo Zhang " "Cristiano Calcagno"] +authors: ["Hongbo Zhang "] +license: "LGPL-3.0-or-later" +homepage: "https://github.com/rescript-lang/rescript-compiler" +bug-reports: "https://github.com/rescript-lang/rescript-compiler/issues" +depends: [ + "ocaml" {>= "5.0.0"} + "lsp" {>= "1.22.0"} + "eio" {>= "1.3"} + "eio_main" {>= "1.3"} + "analysis" + "dune" {>= "3.17"} + "ppx_deriving_yojson" + "ppx_expect" {with-test & = "v0.17.3"} + "odoc" {with-doc} +] +build: [ + ["dune" "subst"] {dev} + [ + "dune" + "build" + "-p" + name + "-j" + jobs + "@install" + "@runtest" {with-test} + "@doc" {with-doc} + ] +] diff --git a/rewatch-ocaml/README.md b/rewatch-ocaml/README.md index c1a625100f5..2cb1dad0a85 100644 --- a/rewatch-ocaml/README.md +++ b/rewatch-ocaml/README.md @@ -1,7 +1,7 @@ # OCaml rewatch This directory contains the OCaml implementation of ReScript's build system and -CLI (`rescript build`, `watch`, `clean`, `format`, and `compiler-args`). It +CLI (`rescript build`, `watch`, `clean`, `format`, `compiler-args`, and `lsp`). It ships as `rescript` in the `@rescript/` packages. The Rust implementation in [`../rewatch`](../rewatch) ships next to it as `rescript-rust`, and both are tested with the shared integration suite in @@ -85,6 +85,12 @@ under the process owners described below. These are intentional: +- `rescript lsp --stdio` runs the `rescript_language_server` + library in-process through `Eio_main.run`. It needs no separate server binary + or project in the current directory. Stdio is reserved for the protocol, + and the server owns its LSP lifecycle. The Rust CLI does not expose this + command. `--stdio` is required; `--quiet` and `--verbose` are not supported + for this subcommand. `lsp_command_tests.ml` covers the embedded server. - `rescript.json` maps that the build system interprets reject duplicate keys instead of using the last one. - A `namespace-entry` without a namespace is an error. diff --git a/rewatch-ocaml/cli.ml b/rewatch-ocaml/cli.ml index e5b148af3d9..2acc645cbcd 100644 --- a/rewatch-ocaml/cli.ml +++ b/rewatch-ocaml/cli.ml @@ -4,6 +4,7 @@ type command = | Watch of build_options | Format of format_input | Compiler_args of {verbosity: int; path: string} + | Lsp and build_options = { verbosity: int; @@ -207,6 +208,14 @@ let compiler_args_term = let+ verbosity = verbosity and+ path in Compiler_args {verbosity; path} +let lsp_term = + Arg.( + required + & vflag None + [ + (Some Lsp, info ["stdio"] ~doc:"Use standard input/output (required)."); + ]) + let exits = [ Cmd.Exit.info 0 ~doc:"on success."; @@ -260,6 +269,11 @@ let root = (command_info "help" "Print this message or command help.") help_term in + let lsp = + Cmd.make + (command_info "lsp" "Start the ReScript language server over stdio.") + lsp_term + in let info = let man = [ @@ -277,7 +291,7 @@ let root = ~doc:"Fast, Simple, Fully Typed JavaScript from the Future" ~man in Cmd.group info ~default:(build_term ~watch:false) - [build; watch; clean; format; compiler_args; help] + [build; watch; clean; format; compiler_args; lsp; help] type evaluation = Run of command | Exit of int @@ -321,7 +335,9 @@ let normalize_argv argv = argument = "--version" || short_cluster_contains 'V' argument in let is_command = function - | "build" | "watch" | "clean" | "format" | "compiler-args" | "help" -> true + | "build" | "watch" | "clean" | "format" | "compiler-args" | "lsp" | "help" + -> + true | _ -> false in let rec normalize_display_options = function diff --git a/rewatch-ocaml/cli.mli b/rewatch-ocaml/cli.mli index ceb134500d5..50920871927 100644 --- a/rewatch-ocaml/cli.mli +++ b/rewatch-ocaml/cli.mli @@ -4,6 +4,7 @@ type command = | Watch of build_options | Format of format_input | Compiler_args of {verbosity: int; path: string} + | Lsp and build_options = { verbosity: int; diff --git a/rewatch-ocaml/dune b/rewatch-ocaml/dune index d0d3cc15251..743a83bb26c 100644 --- a/rewatch-ocaml/dune +++ b/rewatch-ocaml/dune @@ -106,4 +106,4 @@ (modules rescript_ocaml) (flags (:standard -open Rewatch_ocaml_lib)) - (libraries rewatch_ocaml_lib)) + (libraries rewatch_ocaml_lib rescript_language_server eio_main)) diff --git a/rewatch-ocaml/rescript_ocaml.ml b/rewatch-ocaml/rescript_ocaml.ml index 0c7b37f3f5f..b382bbaefef 100644 --- a/rewatch-ocaml/rescript_ocaml.ml +++ b/rewatch-ocaml/rescript_ocaml.ml @@ -56,11 +56,22 @@ let run_command ~poll = function Format.run_files ~poll ~verbosity ~check paths | Cli.Compiler_args {verbosity; path} -> print_endline (Compiler_args_command.run ~verbosity path) + | Cli.Lsp -> + Eio_main.run (fun env -> + let fs = Eio.Stdenv.fs env in + let input = Eio.Stdenv.stdin env in + let output = Eio.Stdenv.stdout env in + Rescript_language_server.listen ~input ~output ~fs) | Cli.Clean {verbosity; folder; prod} -> Clean.run ~poll ~verbosity ~folder ~prod let run = function | Cli.Watch _ as command -> run_command ~poll:(fun () -> ()) command + | Cli.Lsp as command -> + (* The Eio server does not poll the build's termination callback. Keep the + standalone server's signal behavior instead of swallowing termination + signals in the build-only handlers. *) + run_command ~poll:(fun () -> ()) command | command -> with_termination_handlers (fun ~poll -> run_command ~poll command) diff --git a/tests/analysis_tests/tests/not_compiled/expected/Diagnostics.res.txt b/tests/analysis_tests/tests/not_compiled/expected/Diagnostics.res.txt index c595153b8e9..b1745d8c9ff 100644 --- a/tests/analysis_tests/tests/not_compiled/expected/Diagnostics.res.txt +++ b/tests/analysis_tests/tests/not_compiled/expected/Diagnostics.res.txt @@ -5,7 +5,8 @@ "end": { "character": 6, "line": 2 }, "start": { "character": 4, "line": 2 } }, - "severity": 1 + "severity": 1, + "source": "ReScript" }, { "message": "This let-binding misses an expression", @@ -13,7 +14,8 @@ "end": { "character": 11, "line": 1 }, "start": { "character": 9, "line": 1 } }, - "severity": 1 + "severity": 1, + "source": "ReScript" }, { "message": "I was expecting a name for this let-binding. Example: `let message = \"hello\"`", @@ -21,7 +23,8 @@ "end": { "character": 5, "line": 0 }, "start": { "character": 4, "line": 0 } }, - "severity": 1 + "severity": 1, + "source": "ReScript" } ] diff --git a/tests/analysis_tests/tests/src/expected/Completion.res.txt b/tests/analysis_tests/tests/src/expected/Completion.res.txt index b8890fa6829..ad314a633fb 100644 --- a/tests/analysis_tests/tests/src/expected/Completion.res.txt +++ b/tests/analysis_tests/tests/src/expected/Completion.res.txt @@ -2470,7 +2470,7 @@ Hover src/Completion.res 349:14 { "contents": { "kind": "markdown", - "value": "```rescript\nJsxDOM.domProps\n```\n\n---\n\n```\n \n```\n```rescript\ntype JsxDOM.domProps = {\n key?: string,\n children?: Jsx.element,\n ref?: domRef,\n allow?: string,\n ariaCurrent?: [\n | #date\n | #\"false\"\n | #location\n | #page\n | #step\n | #time\n | #\"true\"\n ],\n ariaDetails?: string,\n ariaDisabled?: bool,\n ariaHidden?: bool,\n ariaInvalid?: [#\"false\" | #grammar | #spelling | #\"true\"],\n ariaKeyshortcuts?: string,\n ariaLabel?: string,\n ariaRoledescription?: string,\n ariaAutocomplete?: [#both | #inline | #list | #none],\n ariaChecked?: [#\"false\" | #mixed | #\"true\"],\n ariaExpanded?: bool,\n ariaHaspopup?: [\n | #dialog\n | #\"false\"\n | #grid\n | #listbox\n | #menu\n | #tree\n | #\"true\"\n ],\n ariaLevel?: int,\n ariaModal?: bool,\n ariaMultiline?: bool,\n ariaMultiselectable?: bool,\n ariaOrientation?: [#horizontal | #undefined | #vertical],\n ariaPlaceholder?: string,\n ariaPressed?: [#\"false\" | #mixed | #\"true\"],\n ariaReadonly?: bool,\n ariaRequired?: bool,\n ariaSelected?: bool,\n ariaSort?: string,\n ariaValuemax?: float,\n ariaValuemin?: float,\n ariaValuenow?: float,\n ariaValuetext?: string,\n ariaAtomic?: bool,\n ariaBusy?: bool,\n ariaLive?: [#assertive | #off | #polite | #rude],\n ariaRelevant?: string,\n ariaDropeffect?: [\n | #copy\n | #execute\n | #link\n | #move\n | #none\n | #popup\n ],\n ariaGrabbed?: bool,\n ariaActivedescendant?: string,\n ariaColcount?: int,\n ariaColindex?: int,\n ariaColspan?: int,\n ariaControls?: string,\n ariaDescribedby?: string,\n ariaErrormessage?: string,\n ariaFlowto?: string,\n ariaLabelledby?: string,\n ariaOwns?: string,\n ariaPosinset?: int,\n ariaRowcount?: int,\n ariaRowindex?: int,\n ariaRowspan?: int,\n ariaSetsize?: int,\n defaultChecked?: bool,\n defaultValue?: string,\n accessKey?: string,\n capture?: [#environment | #user],\n className?: string,\n contentEditable?: bool,\n contextMenu?: string,\n dataTestId?: string,\n dir?: string,\n draggable?: bool,\n hidden?: bool,\n id?: string,\n inert?: bool,\n lang?: string,\n popover?: popover,\n popoverTarget?: string,\n popoverTargetAction?: popoverTargetAction,\n role?: string,\n style?: style,\n spellCheck?: bool,\n tabIndex?: int,\n title?: string,\n itemID?: string,\n itemProp?: string,\n itemRef?: string,\n itemScope?: bool,\n itemType?: string,\n accept?: string,\n acceptCharset?: string,\n action?: string,\n allowFullScreen?: bool,\n alt?: string,\n as_?: string,\n async?: bool,\n autoComplete?: string,\n autoCapitalize?: string,\n autoFocus?: bool,\n autoPlay?: bool,\n challenge?: string,\n charSet?: string,\n checked?: bool,\n cite?: string,\n crossOrigin?: string,\n cols?: int,\n colSpan?: int,\n content?: string,\n controls?: bool,\n coords?: string,\n data?: string,\n dateTime?: string,\n default?: bool,\n defer?: bool,\n disabled?: bool,\n download?: string,\n encType?: string,\n form?: string,\n formAction?: string,\n formTarget?: string,\n formMethod?: string,\n frameBorder?: int,\n headers?: string,\n height?: string,\n high?: int,\n href?: string,\n hrefLang?: string,\n htmlFor?: string,\n httpEquiv?: string,\n icon?: string,\n inputMode?: string,\n integrity?: string,\n keyType?: string,\n kind?: string,\n label?: string,\n list?: string,\n loading?: [#eager | #lazy],\n loop?: bool,\n low?: int,\n manifest?: string,\n max?: string,\n maxLength?: int,\n media?: string,\n mediaGroup?: string,\n method?: string,\n min?: string,\n minLength?: int,\n multiple?: bool,\n muted?: bool,\n name?: string,\n nonce?: string,\n noValidate?: bool,\n open_?: bool,\n optimum?: int,\n pattern?: string,\n placeholder?: string,\n playsInline?: bool,\n poster?: string,\n preload?: string,\n radioGroup?: string,\n readOnly?: bool,\n rel?: string,\n required?: bool,\n reversed?: bool,\n rows?: int,\n rowSpan?: int,\n sandbox?: string,\n scope?: string,\n scoped?: bool,\n scrolling?: string,\n selected?: bool,\n shape?: string,\n size?: int,\n sizes?: string,\n span?: int,\n src?: string,\n srcDoc?: string,\n srcLang?: string,\n srcSet?: string,\n start?: int,\n step?: float,\n summary?: string,\n target?: string,\n type_?: string,\n useMap?: string,\n value?: string,\n width?: string,\n wrap?: string,\n onCopy?: JsxEvent.Clipboard.t => unit,\n onCut?: JsxEvent.Clipboard.t => unit,\n onPaste?: JsxEvent.Clipboard.t => unit,\n onCompositionEnd?: JsxEvent.Composition.t => unit,\n onCompositionStart?: JsxEvent.Composition.t => unit,\n onCompositionUpdate?: JsxEvent.Composition.t => unit,\n onKeyDown?: JsxEvent.Keyboard.t => unit,\n onKeyPress?: JsxEvent.Keyboard.t => unit,\n onKeyUp?: JsxEvent.Keyboard.t => unit,\n onFocus?: JsxEvent.Focus.t => unit,\n onBlur?: JsxEvent.Focus.t => unit,\n onBeforeInput?: JsxEvent.Form.t => unit,\n onChange?: JsxEvent.Form.t => unit,\n onInput?: JsxEvent.Form.t => unit,\n onReset?: JsxEvent.Form.t => unit,\n onSubmit?: JsxEvent.Form.t => unit,\n onInvalid?: JsxEvent.Form.t => unit,\n onClick?: JsxEvent.Mouse.t => unit,\n onContextMenu?: JsxEvent.Mouse.t => unit,\n onDoubleClick?: JsxEvent.Mouse.t => unit,\n onDrag?: JsxEvent.Mouse.t => unit,\n onDragEnd?: JsxEvent.Mouse.t => unit,\n onDragEnter?: JsxEvent.Mouse.t => unit,\n onDragExit?: JsxEvent.Mouse.t => unit,\n onDragLeave?: JsxEvent.Mouse.t => unit,\n onDragOver?: JsxEvent.Mouse.t => unit,\n onDragStart?: JsxEvent.Mouse.t => unit,\n onDrop?: JsxEvent.Mouse.t => unit,\n onMouseDown?: JsxEvent.Mouse.t => unit,\n onMouseEnter?: JsxEvent.Mouse.t => unit,\n onMouseLeave?: JsxEvent.Mouse.t => unit,\n onMouseMove?: JsxEvent.Mouse.t => unit,\n onMouseOut?: JsxEvent.Mouse.t => unit,\n onMouseOver?: JsxEvent.Mouse.t => unit,\n onMouseUp?: JsxEvent.Mouse.t => unit,\n onSelect?: JsxEvent.Selection.t => unit,\n onTouchCancel?: JsxEvent.Touch.t => unit,\n onTouchEnd?: JsxEvent.Touch.t => unit,\n onTouchMove?: JsxEvent.Touch.t => unit,\n onTouchStart?: JsxEvent.Touch.t => unit,\n onPointerOver?: JsxEvent.Pointer.t => unit,\n onPointerEnter?: JsxEvent.Pointer.t => unit,\n onPointerDown?: JsxEvent.Pointer.t => unit,\n onPointerMove?: JsxEvent.Pointer.t => unit,\n onPointerUp?: JsxEvent.Pointer.t => unit,\n onPointerCancel?: JsxEvent.Pointer.t => unit,\n onPointerOut?: JsxEvent.Pointer.t => unit,\n onPointerLeave?: JsxEvent.Pointer.t => unit,\n onGotPointerCapture?: JsxEvent.Pointer.t => unit,\n onLostPointerCapture?: JsxEvent.Pointer.t => unit,\n onScroll?: JsxEvent.UI.t => unit,\n onWheel?: JsxEvent.Wheel.t => unit,\n onAbort?: JsxEvent.Media.t => unit,\n onCanPlay?: JsxEvent.Media.t => unit,\n onCanPlayThrough?: JsxEvent.Media.t => unit,\n onDurationChange?: JsxEvent.Media.t => unit,\n onEmptied?: JsxEvent.Media.t => unit,\n onEncrypted?: JsxEvent.Media.t => unit,\n onEnded?: JsxEvent.Media.t => unit,\n onError?: JsxEvent.Media.t => unit,\n onLoadedData?: JsxEvent.Media.t => unit,\n onLoadedMetadata?: JsxEvent.Media.t => unit,\n onLoadStart?: JsxEvent.Media.t => unit,\n onPause?: JsxEvent.Media.t => unit,\n onPlay?: JsxEvent.Media.t => unit,\n onPlaying?: JsxEvent.Media.t => unit,\n onProgress?: JsxEvent.Media.t => unit,\n onRateChange?: JsxEvent.Media.t => unit,\n onSeeked?: JsxEvent.Media.t => unit,\n onSeeking?: JsxEvent.Media.t => unit,\n onStalled?: JsxEvent.Media.t => unit,\n onSuspend?: JsxEvent.Media.t => unit,\n onTimeUpdate?: JsxEvent.Media.t => unit,\n onVolumeChange?: JsxEvent.Media.t => unit,\n onWaiting?: JsxEvent.Media.t => unit,\n onLoad?: JsxEvent.Image.t => unit,\n onAnimationStart?: JsxEvent.Animation.t => unit,\n onAnimationEnd?: JsxEvent.Animation.t => unit,\n onAnimationIteration?: JsxEvent.Animation.t => unit,\n onTransitionEnd?: JsxEvent.Transition.t => unit,\n accentHeight?: string,\n accumulate?: string,\n additive?: string,\n alignmentBaseline?: string,\n allowReorder?: string,\n alphabetic?: string,\n amplitude?: string,\n arabicForm?: string,\n ascent?: string,\n attributeName?: string,\n attributeType?: string,\n autoReverse?: string,\n azimuth?: string,\n baseFrequency?: string,\n baseProfile?: string,\n baselineShift?: string,\n bbox?: string,\n begin?: string,\n begin_?: string,\n bias?: string,\n by?: string,\n calcMode?: string,\n capHeight?: string,\n clip?: string,\n clipPath?: string,\n clipPathUnits?: string,\n clipRule?: string,\n colorInterpolation?: string,\n colorInterpolationFilters?: string,\n colorProfile?: string,\n colorRendering?: string,\n contentScriptType?: string,\n contentStyleType?: string,\n cursor?: string,\n cx?: string,\n cy?: string,\n d?: string,\n decelerate?: string,\n descent?: string,\n diffuseConstant?: string,\n direction?: string,\n display?: string,\n divisor?: string,\n dominantBaseline?: string,\n dur?: string,\n dx?: string,\n dy?: string,\n edgeMode?: string,\n elevation?: string,\n enableBackground?: string,\n end?: string,\n end_?: string,\n exponent?: string,\n externalResourcesRequired?: string,\n fill?: string,\n fillOpacity?: string,\n fillRule?: string,\n filter?: string,\n filterRes?: string,\n filterUnits?: string,\n floodColor?: string,\n floodOpacity?: string,\n focusable?: string,\n fontFamily?: string,\n fontSize?: string,\n fontSizeAdjust?: string,\n fontStretch?: string,\n fontStyle?: string,\n fontVariant?: string,\n fontWeight?: string,\n fomat?: string,\n from?: string,\n fx?: string,\n fy?: string,\n g1?: string,\n g2?: string,\n glyphName?: string,\n glyphOrientationHorizontal?: string,\n glyphOrientationVertical?: string,\n glyphRef?: string,\n gradientTransform?: string,\n gradientUnits?: string,\n hanging?: string,\n horizAdvX?: string,\n horizOriginX?: string,\n ideographic?: string,\n imageRendering?: string,\n in_?: string,\n in2?: string,\n intercept?: string,\n k?: string,\n k1?: string,\n k2?: string,\n k3?: string,\n k4?: string,\n kernelMatrix?: string,\n kernelUnitLength?: string,\n kerning?: string,\n keyPoints?: string,\n keySplines?: string,\n keyTimes?: string,\n lengthAdjust?: string,\n letterSpacing?: string,\n lightingColor?: string,\n limitingConeAngle?: string,\n local?: string,\n markerEnd?: string,\n markerHeight?: string,\n markerMid?: string,\n markerStart?: string,\n markerUnits?: string,\n markerWidth?: string,\n mask?: string,\n maskContentUnits?: string,\n maskUnits?: string,\n mathematical?: string,\n mode?: string,\n numOctaves?: string,\n offset?: string,\n opacity?: string,\n operator?: string,\n order?: string,\n orient?: string,\n orientation?: string,\n origin?: string,\n overflow?: string,\n overflowX?: string,\n overflowY?: string,\n overlinePosition?: string,\n overlineThickness?: string,\n paintOrder?: string,\n panose1?: string,\n pathLength?: string,\n patternContentUnits?: string,\n patternTransform?: string,\n patternUnits?: string,\n pointerEvents?: string,\n points?: string,\n pointsAtX?: string,\n pointsAtY?: string,\n pointsAtZ?: string,\n preserveAlpha?: string,\n preserveAspectRatio?: string,\n primitiveUnits?: string,\n r?: string,\n radius?: string,\n referrerPolicy?: string,\n refX?: string,\n refY?: string,\n renderingIntent?: string,\n repeatCount?: string,\n repeatDur?: string,\n requiredExtensions?: string,\n requiredFeatures?: string,\n restart?: string,\n result?: string,\n rotate?: string,\n rx?: string,\n ry?: string,\n scale?: string,\n seed?: string,\n shapeRendering?: string,\n slope?: string,\n slot?: string,\n spacing?: string,\n specularConstant?: string,\n specularExponent?: string,\n speed?: string,\n spreadMethod?: string,\n startOffset?: string,\n stdDeviation?: string,\n stemh?: string,\n stemv?: string,\n stitchTiles?: string,\n stopColor?: string,\n stopOpacity?: string,\n strikethroughPosition?: string,\n strikethroughThickness?: string,\n string?: string,\n stroke?: string,\n strokeDasharray?: string,\n strokeDashoffset?: string,\n strokeLinecap?: string,\n strokeLinejoin?: string,\n strokeMiterlimit?: string,\n strokeOpacity?: string,\n strokeWidth?: string,\n surfaceScale?: string,\n systemLanguage?: string,\n tableValues?: string,\n targetX?: string,\n targetY?: string,\n textAnchor?: string,\n textDecoration?: string,\n textLength?: string,\n textRendering?: string,\n to?: string,\n to_?: string,\n transform?: string,\n u1?: string,\n u2?: string,\n underlinePosition?: string,\n underlineThickness?: string,\n unicode?: string,\n unicodeBidi?: string,\n unicodeRange?: string,\n unitsPerEm?: string,\n vAlphabetic?: string,\n vHanging?: string,\n vIdeographic?: string,\n vMathematical?: string,\n values?: string,\n vectorEffect?: string,\n version?: string,\n vertAdvX?: string,\n vertAdvY?: string,\n vertOriginX?: string,\n vertOriginY?: string,\n viewBox?: string,\n viewTarget?: string,\n visibility?: string,\n widths?: string,\n wordSpacing?: string,\n writingMode?: string,\n x?: string,\n x1?: string,\n x2?: string,\n xChannelSelector?: string,\n xHeight?: string,\n xlinkActuate?: string,\n xlinkArcrole?: string,\n xlinkHref?: string,\n xlinkRole?: string,\n xlinkShow?: string,\n xlinkTitle?: string,\n xlinkType?: string,\n xmlns?: string,\n xmlnsXlink?: string,\n xmlBase?: string,\n xmlLang?: string,\n xmlSpace?: string,\n y?: string,\n y1?: string,\n y2?: string,\n yChannelSelector?: string,\n z?: string,\n zoomAndPan?: string,\n about?: string,\n datatype?: string,\n inlist?: string,\n prefix?: string,\n property?: string,\n resource?: string,\n typeof?: string,\n vocab?: string,\n dangerouslySetInnerHTML?: {\"__html\": string},\n suppressContentEditableWarning?: bool,\n}\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxDOM.res%22%2C20%2C0%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype Jsx.element\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22Jsx.res%22%2C7%2C0%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype domRef\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxDOM.res%22%2C7%2C0%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype popover =\n | @as(\"auto\") Auto\n | @as(\"manual\") Manual\n | @as(\"hint\") Hint\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxDOM.res%22%2C11%2C0%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype popoverTargetAction =\n | @as(\"toggle\") Toggle\n | @as(\"show\") Show\n | @as(\"hide\") Hide\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxDOM.res%22%2C15%2C0%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype style = JsxDOMStyle.t\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxDOM.res%22%2C6%2C0%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Clipboard.t = synthetic\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxEvent.res%22%2C77%2C2%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Composition.t = synthetic\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxEvent.res%22%2C87%2C2%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Keyboard.t = synthetic\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxEvent.res%22%2C96%2C2%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Focus.t = synthetic\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxEvent.res%22%2C118%2C2%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Form.t = synthetic\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxEvent.res%22%2C128%2C2%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Mouse.t = synthetic\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxEvent.res%22%2C135%2C2%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Selection.t = synthetic\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxEvent.res%22%2C212%2C2%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Touch.t = synthetic\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxEvent.res%22%2C219%2C2%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Pointer.t = synthetic\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxEvent.res%22%2C164%2C2%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.UI.t = synthetic\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxEvent.res%22%2C242%2C2%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Wheel.t = synthetic\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxEvent.res%22%2C253%2C2%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Media.t = synthetic\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxEvent.res%22%2C265%2C2%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Image.t = synthetic\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxEvent.res%22%2C272%2C2%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Animation.t = synthetic\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxEvent.res%22%2C279%2C2%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Transition.t = synthetic\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxEvent.res%22%2C290%2C2%5D)\n" + "value": "```rescript\nJsxDOM.domProps\n```\n\n---\n\n```\n \n```\n```rescript\ntype JsxDOM.domProps = {\n key?: string,\n children?: Jsx.element,\n ref?: domRef,\n allow?: string,\n ariaCurrent?: [\n | #date\n | #\"false\"\n | #location\n | #page\n | #step\n | #time\n | #\"true\"\n ],\n ariaDetails?: string,\n ariaDisabled?: bool,\n ariaHidden?: bool,\n ariaInvalid?: [#\"false\" | #grammar | #spelling | #\"true\"],\n ariaKeyshortcuts?: string,\n ariaLabel?: string,\n ariaRoledescription?: string,\n ariaAutocomplete?: [#both | #inline | #list | #none],\n ariaChecked?: [#\"false\" | #mixed | #\"true\"],\n ariaExpanded?: bool,\n ariaHaspopup?: [\n | #dialog\n | #\"false\"\n | #grid\n | #listbox\n | #menu\n | #tree\n | #\"true\"\n ],\n ariaLevel?: int,\n ariaModal?: bool,\n ariaMultiline?: bool,\n ariaMultiselectable?: bool,\n ariaOrientation?: [#horizontal | #undefined | #vertical],\n ariaPlaceholder?: string,\n ariaPressed?: [#\"false\" | #mixed | #\"true\"],\n ariaReadonly?: bool,\n ariaRequired?: bool,\n ariaSelected?: bool,\n ariaSort?: string,\n ariaValuemax?: float,\n ariaValuemin?: float,\n ariaValuenow?: float,\n ariaValuetext?: string,\n ariaAtomic?: bool,\n ariaBusy?: bool,\n ariaLive?: [#assertive | #off | #polite | #rude],\n ariaRelevant?: string,\n ariaDropeffect?: [\n | #copy\n | #execute\n | #link\n | #move\n | #none\n | #popup\n ],\n ariaGrabbed?: bool,\n ariaActivedescendant?: string,\n ariaColcount?: int,\n ariaColindex?: int,\n ariaColspan?: int,\n ariaControls?: string,\n ariaDescribedby?: string,\n ariaErrormessage?: string,\n ariaFlowto?: string,\n ariaLabelledby?: string,\n ariaOwns?: string,\n ariaPosinset?: int,\n ariaRowcount?: int,\n ariaRowindex?: int,\n ariaRowspan?: int,\n ariaSetsize?: int,\n defaultChecked?: bool,\n defaultValue?: string,\n accessKey?: string,\n capture?: [#environment | #user],\n className?: string,\n contentEditable?: bool,\n contextMenu?: string,\n dataTestId?: string,\n dir?: string,\n draggable?: bool,\n hidden?: bool,\n id?: string,\n inert?: bool,\n lang?: string,\n popover?: popover,\n popoverTarget?: string,\n popoverTargetAction?: popoverTargetAction,\n role?: string,\n style?: style,\n spellCheck?: bool,\n tabIndex?: int,\n title?: string,\n itemID?: string,\n itemProp?: string,\n itemRef?: string,\n itemScope?: bool,\n itemType?: string,\n accept?: string,\n acceptCharset?: string,\n action?: string,\n allowFullScreen?: bool,\n alt?: string,\n as_?: string,\n async?: bool,\n autoComplete?: string,\n autoCapitalize?: string,\n autoFocus?: bool,\n autoPlay?: bool,\n challenge?: string,\n charSet?: string,\n checked?: bool,\n cite?: string,\n crossOrigin?: string,\n cols?: int,\n colSpan?: int,\n content?: string,\n controls?: bool,\n coords?: string,\n data?: string,\n dateTime?: string,\n default?: bool,\n defer?: bool,\n disabled?: bool,\n download?: string,\n encType?: string,\n form?: string,\n formAction?: string,\n formTarget?: string,\n formMethod?: string,\n frameBorder?: int,\n headers?: string,\n height?: string,\n high?: int,\n href?: string,\n hrefLang?: string,\n htmlFor?: string,\n httpEquiv?: string,\n icon?: string,\n inputMode?: string,\n integrity?: string,\n keyType?: string,\n kind?: string,\n label?: string,\n list?: string,\n loading?: [#eager | #lazy],\n loop?: bool,\n low?: int,\n manifest?: string,\n max?: string,\n maxLength?: int,\n media?: string,\n mediaGroup?: string,\n method?: string,\n min?: string,\n minLength?: int,\n multiple?: bool,\n muted?: bool,\n name?: string,\n nonce?: string,\n noValidate?: bool,\n open_?: bool,\n optimum?: int,\n pattern?: string,\n placeholder?: string,\n playsInline?: bool,\n poster?: string,\n preload?: string,\n radioGroup?: string,\n readOnly?: bool,\n rel?: string,\n required?: bool,\n reversed?: bool,\n rows?: int,\n rowSpan?: int,\n sandbox?: string,\n scope?: string,\n scoped?: bool,\n scrolling?: string,\n selected?: bool,\n shape?: string,\n size?: int,\n sizes?: string,\n span?: int,\n src?: string,\n srcDoc?: string,\n srcLang?: string,\n srcSet?: string,\n start?: int,\n step?: float,\n summary?: string,\n target?: string,\n type_?: string,\n useMap?: string,\n value?: string,\n width?: string,\n wrap?: string,\n onCopy?: JsxEvent.Clipboard.t => unit,\n onCut?: JsxEvent.Clipboard.t => unit,\n onPaste?: JsxEvent.Clipboard.t => unit,\n onCompositionEnd?: JsxEvent.Composition.t => unit,\n onCompositionStart?: JsxEvent.Composition.t => unit,\n onCompositionUpdate?: JsxEvent.Composition.t => unit,\n onKeyDown?: JsxEvent.Keyboard.t => unit,\n onKeyPress?: JsxEvent.Keyboard.t => unit,\n onKeyUp?: JsxEvent.Keyboard.t => unit,\n onFocus?: JsxEvent.Focus.t => unit,\n onBlur?: JsxEvent.Focus.t => unit,\n onBeforeInput?: JsxEvent.Form.t => unit,\n onChange?: JsxEvent.Form.t => unit,\n onInput?: JsxEvent.Form.t => unit,\n onReset?: JsxEvent.Form.t => unit,\n onSubmit?: JsxEvent.Form.t => unit,\n onInvalid?: JsxEvent.Form.t => unit,\n onClick?: JsxEvent.Mouse.t => unit,\n onContextMenu?: JsxEvent.Mouse.t => unit,\n onDoubleClick?: JsxEvent.Mouse.t => unit,\n onDrag?: JsxEvent.Mouse.t => unit,\n onDragEnd?: JsxEvent.Mouse.t => unit,\n onDragEnter?: JsxEvent.Mouse.t => unit,\n onDragExit?: JsxEvent.Mouse.t => unit,\n onDragLeave?: JsxEvent.Mouse.t => unit,\n onDragOver?: JsxEvent.Mouse.t => unit,\n onDragStart?: JsxEvent.Mouse.t => unit,\n onDrop?: JsxEvent.Mouse.t => unit,\n onMouseDown?: JsxEvent.Mouse.t => unit,\n onMouseEnter?: JsxEvent.Mouse.t => unit,\n onMouseLeave?: JsxEvent.Mouse.t => unit,\n onMouseMove?: JsxEvent.Mouse.t => unit,\n onMouseOut?: JsxEvent.Mouse.t => unit,\n onMouseOver?: JsxEvent.Mouse.t => unit,\n onMouseUp?: JsxEvent.Mouse.t => unit,\n onSelect?: JsxEvent.Selection.t => unit,\n onTouchCancel?: JsxEvent.Touch.t => unit,\n onTouchEnd?: JsxEvent.Touch.t => unit,\n onTouchMove?: JsxEvent.Touch.t => unit,\n onTouchStart?: JsxEvent.Touch.t => unit,\n onPointerOver?: JsxEvent.Pointer.t => unit,\n onPointerEnter?: JsxEvent.Pointer.t => unit,\n onPointerDown?: JsxEvent.Pointer.t => unit,\n onPointerMove?: JsxEvent.Pointer.t => unit,\n onPointerUp?: JsxEvent.Pointer.t => unit,\n onPointerCancel?: JsxEvent.Pointer.t => unit,\n onPointerOut?: JsxEvent.Pointer.t => unit,\n onPointerLeave?: JsxEvent.Pointer.t => unit,\n onGotPointerCapture?: JsxEvent.Pointer.t => unit,\n onLostPointerCapture?: JsxEvent.Pointer.t => unit,\n onScroll?: JsxEvent.UI.t => unit,\n onWheel?: JsxEvent.Wheel.t => unit,\n onAbort?: JsxEvent.Media.t => unit,\n onCanPlay?: JsxEvent.Media.t => unit,\n onCanPlayThrough?: JsxEvent.Media.t => unit,\n onDurationChange?: JsxEvent.Media.t => unit,\n onEmptied?: JsxEvent.Media.t => unit,\n onEncrypted?: JsxEvent.Media.t => unit,\n onEnded?: JsxEvent.Media.t => unit,\n onError?: JsxEvent.Media.t => unit,\n onLoadedData?: JsxEvent.Media.t => unit,\n onLoadedMetadata?: JsxEvent.Media.t => unit,\n onLoadStart?: JsxEvent.Media.t => unit,\n onPause?: JsxEvent.Media.t => unit,\n onPlay?: JsxEvent.Media.t => unit,\n onPlaying?: JsxEvent.Media.t => unit,\n onProgress?: JsxEvent.Media.t => unit,\n onRateChange?: JsxEvent.Media.t => unit,\n onSeeked?: JsxEvent.Media.t => unit,\n onSeeking?: JsxEvent.Media.t => unit,\n onStalled?: JsxEvent.Media.t => unit,\n onSuspend?: JsxEvent.Media.t => unit,\n onTimeUpdate?: JsxEvent.Media.t => unit,\n onVolumeChange?: JsxEvent.Media.t => unit,\n onWaiting?: JsxEvent.Media.t => unit,\n onLoad?: JsxEvent.Image.t => unit,\n onAnimationStart?: JsxEvent.Animation.t => unit,\n onAnimationEnd?: JsxEvent.Animation.t => unit,\n onAnimationIteration?: JsxEvent.Animation.t => unit,\n onTransitionEnd?: JsxEvent.Transition.t => unit,\n accentHeight?: string,\n accumulate?: string,\n additive?: string,\n alignmentBaseline?: string,\n allowReorder?: string,\n alphabetic?: string,\n amplitude?: string,\n arabicForm?: string,\n ascent?: string,\n attributeName?: string,\n attributeType?: string,\n autoReverse?: string,\n azimuth?: string,\n baseFrequency?: string,\n baseProfile?: string,\n baselineShift?: string,\n bbox?: string,\n begin?: string,\n begin_?: string,\n bias?: string,\n by?: string,\n calcMode?: string,\n capHeight?: string,\n clip?: string,\n clipPath?: string,\n clipPathUnits?: string,\n clipRule?: string,\n colorInterpolation?: string,\n colorInterpolationFilters?: string,\n colorProfile?: string,\n colorRendering?: string,\n contentScriptType?: string,\n contentStyleType?: string,\n cursor?: string,\n cx?: string,\n cy?: string,\n d?: string,\n decelerate?: string,\n descent?: string,\n diffuseConstant?: string,\n direction?: string,\n display?: string,\n divisor?: string,\n dominantBaseline?: string,\n dur?: string,\n dx?: string,\n dy?: string,\n edgeMode?: string,\n elevation?: string,\n enableBackground?: string,\n end?: string,\n end_?: string,\n exponent?: string,\n externalResourcesRequired?: string,\n fill?: string,\n fillOpacity?: string,\n fillRule?: string,\n filter?: string,\n filterRes?: string,\n filterUnits?: string,\n floodColor?: string,\n floodOpacity?: string,\n focusable?: string,\n fontFamily?: string,\n fontSize?: string,\n fontSizeAdjust?: string,\n fontStretch?: string,\n fontStyle?: string,\n fontVariant?: string,\n fontWeight?: string,\n fomat?: string,\n from?: string,\n fx?: string,\n fy?: string,\n g1?: string,\n g2?: string,\n glyphName?: string,\n glyphOrientationHorizontal?: string,\n glyphOrientationVertical?: string,\n glyphRef?: string,\n gradientTransform?: string,\n gradientUnits?: string,\n hanging?: string,\n horizAdvX?: string,\n horizOriginX?: string,\n ideographic?: string,\n imageRendering?: string,\n in_?: string,\n in2?: string,\n intercept?: string,\n k?: string,\n k1?: string,\n k2?: string,\n k3?: string,\n k4?: string,\n kernelMatrix?: string,\n kernelUnitLength?: string,\n kerning?: string,\n keyPoints?: string,\n keySplines?: string,\n keyTimes?: string,\n lengthAdjust?: string,\n letterSpacing?: string,\n lightingColor?: string,\n limitingConeAngle?: string,\n local?: string,\n markerEnd?: string,\n markerHeight?: string,\n markerMid?: string,\n markerStart?: string,\n markerUnits?: string,\n markerWidth?: string,\n mask?: string,\n maskContentUnits?: string,\n maskUnits?: string,\n mathematical?: string,\n mode?: string,\n numOctaves?: string,\n offset?: string,\n opacity?: string,\n operator?: string,\n order?: string,\n orient?: string,\n orientation?: string,\n origin?: string,\n overflow?: string,\n overflowX?: string,\n overflowY?: string,\n overlinePosition?: string,\n overlineThickness?: string,\n paintOrder?: string,\n panose1?: string,\n pathLength?: string,\n patternContentUnits?: string,\n patternTransform?: string,\n patternUnits?: string,\n pointerEvents?: string,\n points?: string,\n pointsAtX?: string,\n pointsAtY?: string,\n pointsAtZ?: string,\n preserveAlpha?: string,\n preserveAspectRatio?: string,\n primitiveUnits?: string,\n r?: string,\n radius?: string,\n referrerPolicy?: string,\n refX?: string,\n refY?: string,\n renderingIntent?: string,\n repeatCount?: string,\n repeatDur?: string,\n requiredExtensions?: string,\n requiredFeatures?: string,\n restart?: string,\n result?: string,\n rotate?: string,\n rx?: string,\n ry?: string,\n scale?: string,\n seed?: string,\n shapeRendering?: string,\n slope?: string,\n slot?: string,\n spacing?: string,\n specularConstant?: string,\n specularExponent?: string,\n speed?: string,\n spreadMethod?: string,\n startOffset?: string,\n stdDeviation?: string,\n stemh?: string,\n stemv?: string,\n stitchTiles?: string,\n stopColor?: string,\n stopOpacity?: string,\n strikethroughPosition?: string,\n strikethroughThickness?: string,\n string?: string,\n stroke?: string,\n strokeDasharray?: string,\n strokeDashoffset?: string,\n strokeLinecap?: string,\n strokeLinejoin?: string,\n strokeMiterlimit?: string,\n strokeOpacity?: string,\n strokeWidth?: string,\n surfaceScale?: string,\n systemLanguage?: string,\n tableValues?: string,\n targetX?: string,\n targetY?: string,\n textAnchor?: string,\n textDecoration?: string,\n textLength?: string,\n textRendering?: string,\n to?: string,\n to_?: string,\n transform?: string,\n u1?: string,\n u2?: string,\n underlinePosition?: string,\n underlineThickness?: string,\n unicode?: string,\n unicodeBidi?: string,\n unicodeRange?: string,\n unitsPerEm?: string,\n vAlphabetic?: string,\n vHanging?: string,\n vIdeographic?: string,\n vMathematical?: string,\n values?: string,\n vectorEffect?: string,\n version?: string,\n vertAdvX?: string,\n vertAdvY?: string,\n vertOriginX?: string,\n vertOriginY?: string,\n viewBox?: string,\n viewTarget?: string,\n visibility?: string,\n widths?: string,\n wordSpacing?: string,\n writingMode?: string,\n x?: string,\n x1?: string,\n x2?: string,\n xChannelSelector?: string,\n xHeight?: string,\n xlinkActuate?: string,\n xlinkArcrole?: string,\n xlinkHref?: string,\n xlinkRole?: string,\n xlinkShow?: string,\n xlinkTitle?: string,\n xlinkType?: string,\n xmlns?: string,\n xmlnsXlink?: string,\n xmlBase?: string,\n xmlLang?: string,\n xmlSpace?: string,\n y?: string,\n y1?: string,\n y2?: string,\n yChannelSelector?: string,\n z?: string,\n zoomAndPan?: string,\n about?: string,\n datatype?: string,\n inlist?: string,\n prefix?: string,\n property?: string,\n resource?: string,\n typeof?: string,\n vocab?: string,\n dangerouslySetInnerHTML?: {\"__html\": string},\n suppressContentEditableWarning?: bool,\n}\n```\nGo to: [Type definition](JsxDOM.res#L21,1)\n\n\n---\n\n```\n \n```\n```rescript\ntype Jsx.element\n```\nGo to: [Type definition](Jsx.res#L8,1)\n\n\n---\n\n```\n \n```\n```rescript\ntype domRef\n```\nGo to: [Type definition](JsxDOM.res#L8,1)\n\n\n---\n\n```\n \n```\n```rescript\ntype popover =\n | @as(\"auto\") Auto\n | @as(\"manual\") Manual\n | @as(\"hint\") Hint\n```\nGo to: [Type definition](JsxDOM.res#L12,1)\n\n\n---\n\n```\n \n```\n```rescript\ntype popoverTargetAction =\n | @as(\"toggle\") Toggle\n | @as(\"show\") Show\n | @as(\"hide\") Hide\n```\nGo to: [Type definition](JsxDOM.res#L16,1)\n\n\n---\n\n```\n \n```\n```rescript\ntype style = JsxDOMStyle.t\n```\nGo to: [Type definition](JsxDOM.res#L7,1)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Clipboard.t = synthetic\n```\nGo to: [Type definition](JsxEvent.res#L78,3)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Composition.t = synthetic\n```\nGo to: [Type definition](JsxEvent.res#L88,3)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Keyboard.t = synthetic\n```\nGo to: [Type definition](JsxEvent.res#L97,3)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Focus.t = synthetic\n```\nGo to: [Type definition](JsxEvent.res#L119,3)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Form.t = synthetic\n```\nGo to: [Type definition](JsxEvent.res#L129,3)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Mouse.t = synthetic\n```\nGo to: [Type definition](JsxEvent.res#L136,3)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Selection.t = synthetic\n```\nGo to: [Type definition](JsxEvent.res#L213,3)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Touch.t = synthetic\n```\nGo to: [Type definition](JsxEvent.res#L220,3)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Pointer.t = synthetic\n```\nGo to: [Type definition](JsxEvent.res#L165,3)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.UI.t = synthetic\n```\nGo to: [Type definition](JsxEvent.res#L243,3)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Wheel.t = synthetic\n```\nGo to: [Type definition](JsxEvent.res#L254,3)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Media.t = synthetic\n```\nGo to: [Type definition](JsxEvent.res#L266,3)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Image.t = synthetic\n```\nGo to: [Type definition](JsxEvent.res#L273,3)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Animation.t = synthetic\n```\nGo to: [Type definition](JsxEvent.res#L280,3)\n\n\n---\n\n```\n \n```\n```rescript\ntype JsxEvent.Transition.t = synthetic\n```\nGo to: [Type definition](JsxEvent.res#L291,3)\n" } } diff --git a/tests/analysis_tests/tests/src/expected/Fragment.res.txt b/tests/analysis_tests/tests/src/expected/Fragment.res.txt index 09ba7d34d15..f53f4d7b7d9 100644 --- a/tests/analysis_tests/tests/src/expected/Fragment.res.txt +++ b/tests/analysis_tests/tests/src/expected/Fragment.res.txt @@ -2,7 +2,7 @@ Hover src/Fragment.res 6:19 { "contents": { "kind": "markdown", - "value": "```rescript\nReact.component>\n```\n\n---\n\n```\n \n```\n```rescript\ntype React.component<'props> = Jsx.component<'props>\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22React.res%22%2C13%2C0%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype SectionHeader.props<'children> = {children: 'children}\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22Fragment.res%22%2C1%2C2%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype React.element = Jsx.element\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22React.res%22%2C0%2C0%5D)\n" + "value": "```rescript\nReact.component>\n```\n\n---\n\n```\n \n```\n```rescript\ntype React.component<'props> = Jsx.component<'props>\n```\nGo to: [Type definition](React.res#L14,1)\n\n\n---\n\n```\n \n```\n```rescript\ntype SectionHeader.props<'children> = {children: 'children}\n```\nGo to: [Type definition](Fragment.res#L2,3)\n\n\n---\n\n```\n \n```\n```rescript\ntype React.element = Jsx.element\n```\nGo to: [Type definition](React.res#L1,1)\n" } } @@ -10,7 +10,7 @@ Hover src/Fragment.res 9:56 { "contents": { "kind": "markdown", - "value": "```rescript\nReact.component\n```\n\n---\n\n```\n \n```\n```rescript\ntype React.component<'props> = Jsx.component<'props>\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22React.res%22%2C13%2C0%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype React.fragmentProps = {children?: element}\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22React.res%22%2C42%2C0%5D)\n" + "value": "```rescript\nReact.component\n```\n\n---\n\n```\n \n```\n```rescript\ntype React.component<'props> = Jsx.component<'props>\n```\nGo to: [Type definition](React.res#L14,1)\n\n\n---\n\n```\n \n```\n```rescript\ntype React.fragmentProps = {children?: element}\n```\nGo to: [Type definition](React.res#L43,1)\n" } } diff --git a/tests/analysis_tests/tests/src/expected/Hover.res.txt b/tests/analysis_tests/tests/src/expected/Hover.res.txt index a7a50635e5a..3c21617e7be 100644 --- a/tests/analysis_tests/tests/src/expected/Hover.res.txt +++ b/tests/analysis_tests/tests/src/expected/Hover.res.txt @@ -126,7 +126,7 @@ Hover src/Hover.res 116:16 { "contents": { "kind": "markdown", - "value": "```rescript\nAA.cond<([< #str(string)] as 'a)> => AA.cond<'a>\n```\n\n---\n\n```\n \n```\n```rescript\ntype AA.cond<'a> = 'a\n constraint 'a = [< #str(string)]\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22Hover.res%22%2C110%2C2%5D)\n" + "value": "```rescript\nAA.cond<([< #str(string)] as 'a)> => AA.cond<'a>\n```\n\n---\n\n```\n \n```\n```rescript\ntype AA.cond<'a> = 'a\n constraint 'a = [< #str(string)]\n```\nGo to: [Type definition](Hover.res#L111,3)\n" } } @@ -134,7 +134,7 @@ Hover src/Hover.res 119:25 { "contents": { "kind": "markdown", - "value": "```rescript\nAA.cond<([< #str(string)] as 'a)> => AA.cond<'a>\n```\n\n---\n\n```\n \n```\n```rescript\ntype AA.cond<'a> = 'a\n constraint 'a = [< #str(string)]\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22Hover.res%22%2C110%2C2%5D)\n" + "value": "```rescript\nAA.cond<([< #str(string)] as 'a)> => AA.cond<'a>\n```\n\n---\n\n```\n \n```\n```rescript\ntype AA.cond<'a> = 'a\n constraint 'a = [< #str(string)]\n```\nGo to: [Type definition](Hover.res#L111,3)\n" } } @@ -203,7 +203,7 @@ Hover src/Hover.res 165:23 { "contents": { "kind": "markdown", - "value": "```rescript\nfoo\n```\n\n---\n\n```\n \n```\n```rescript\ntype foo<'a> = {content: 'a, zzz: string}\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22Hover.res%22%2C161%2C2%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype bar = {age: int}\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22Hover.res%22%2C162%2C2%5D)\n" + "value": "```rescript\nfoo\n```\n\n---\n\n```\n \n```\n```rescript\ntype foo<'a> = {content: 'a, zzz: string}\n```\nGo to: [Type definition](Hover.res#L162,3)\n\n\n---\n\n```\n \n```\n```rescript\ntype bar = {age: int}\n```\nGo to: [Type definition](Hover.res#L163,3)\n" } } @@ -211,7 +211,7 @@ Hover src/Hover.res 167:22 { "contents": { "kind": "markdown", - "value": "```rescript\nfoobar\n```\n\n---\n\n```\n \n```\n```rescript\ntype foobar = foo\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22Hover.res%22%2C163%2C2%5D)\n" + "value": "```rescript\nfoobar\n```\n\n---\n\n```\n \n```\n```rescript\ntype foobar = foo\n```\nGo to: [Type definition](Hover.res#L164,3)\n" } } @@ -387,7 +387,7 @@ Hover src/Hover.res 197:4 { "contents": { "kind": "markdown", - "value": "```rescript\nCompV4.props => React.element\n```\n\n---\n\n```\n \n```\n```rescript\ntype CompV4.props<'n, 's> = {n?: 'n, s: 's}\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22Hover.res%22%2C190%2C2%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype React.element = Jsx.element\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22React.res%22%2C0%2C0%5D)\n" + "value": "```rescript\nCompV4.props => React.element\n```\n\n---\n\n```\n \n```\n```rescript\ntype CompV4.props<'n, 's> = {n?: 'n, s: 's}\n```\nGo to: [Type definition](Hover.res#L191,3)\n\n\n---\n\n```\n \n```\n```rescript\ntype React.element = Jsx.element\n```\nGo to: [Type definition](React.res#L1,1)\n" } } @@ -395,7 +395,7 @@ Hover src/Hover.res 202:16 { "contents": { "kind": "markdown", - "value": "```rescript\nuseR\n```\n\n---\n\n```\n \n```\n```rescript\ntype useR = {x: int, y: list>>}\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22Hover.res%22%2C200%2C0%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype r<'a> = {i: 'a, f: float}\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22Hover.res%22%2C101%2C0%5D)\n" + "value": "```rescript\nuseR\n```\n\n---\n\n```\n \n```\n```rescript\ntype useR = {x: int, y: list>>}\n```\nGo to: [Type definition](Hover.res#L201,1)\n\n\n---\n\n```\n \n```\n```rescript\ntype r<'a> = {i: 'a, f: float}\n```\nGo to: [Type definition](Hover.res#L102,1)\n" } } @@ -413,7 +413,7 @@ Resolved opens 1 Stdlib { "contents": { "kind": "markdown", - "value": "```rescript\nuseR\n```\n\n---\n\n```\n \n```\n```rescript\ntype useR = {x: int, y: list>>}\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22Hover.res%22%2C200%2C0%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype r<'a> = {i: 'a, f: float}\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22Hover.res%22%2C101%2C0%5D)\n" + "value": "```rescript\nuseR\n```\n\n---\n\n```\n \n```\n```rescript\ntype useR = {x: int, y: list>>}\n```\nGo to: [Type definition](Hover.res#L201,1)\n\n\n---\n\n```\n \n```\n```rescript\ntype r<'a> = {i: 'a, f: float}\n```\nGo to: [Type definition](Hover.res#L102,1)\n" } } @@ -470,7 +470,7 @@ Hover src/Hover.res 253:20 { "contents": { "kind": "markdown", - "value": "```rescript\nvariant\nCoolVariant\n```\n---\n Cool variant! \n\n---\n\n```\n \n```\n```rescript\ntype variant = CoolVariant | OtherCoolVariant\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22Hover.res%22%2C251%2C0%5D)\n" + "value": "```rescript\nvariant\nCoolVariant\n```\n---\n Cool variant! \n\n---\n\n```\n \n```\n```rescript\ntype variant = CoolVariant | OtherCoolVariant\n```\nGo to: [Type definition](Hover.res#L252,1)\n" } } @@ -478,7 +478,7 @@ Hover src/Hover.res 258:22 { "contents": { "kind": "markdown", - "value": "```rescript\npayloadVariants\nInlineRecord({field1: int, field2: bool})\n```\n\n---\n\n```\n \n```\n```rescript\ntype payloadVariants =\n | InlineRecord({field1: int, field2: bool})\n | Args(int, bool)\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22Hover.res%22%2C256%2C0%5D)\n" + "value": "```rescript\npayloadVariants\nInlineRecord({field1: int, field2: bool})\n```\n\n---\n\n```\n \n```\n```rescript\ntype payloadVariants =\n | InlineRecord({field1: int, field2: bool})\n | Args(int, bool)\n```\nGo to: [Type definition](Hover.res#L257,1)\n" } } @@ -486,7 +486,7 @@ Hover src/Hover.res 261:23 { "contents": { "kind": "markdown", - "value": "```rescript\npayloadVariants\nArgs(int, bool)\n```\n\n---\n\n```\n \n```\n```rescript\ntype payloadVariants =\n | InlineRecord({field1: int, field2: bool})\n | Args(int, bool)\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22Hover.res%22%2C256%2C0%5D)\n" + "value": "```rescript\npayloadVariants\nArgs(int, bool)\n```\n\n---\n\n```\n \n```\n```rescript\ntype payloadVariants =\n | InlineRecord({field1: int, field2: bool})\n | Args(int, bool)\n```\nGo to: [Type definition](Hover.res#L257,1)\n" } } @@ -494,7 +494,7 @@ Hover src/Hover.res 268:42 { "contents": { "kind": "markdown", - "value": "```rescript\nRecursiveVariants.t\nAction1(int)\n```\n\n---\n\n```\n \n```\n```rescript\ntype RecursiveVariants.t =\n | Action1(int)\n | Action2(float)\n | Batch(array)\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22Hover.res%22%2C265%2C2%5D)\n" + "value": "```rescript\nRecursiveVariants.t\nAction1(int)\n```\n\n---\n\n```\n \n```\n```rescript\ntype RecursiveVariants.t =\n | Action1(int)\n | Action2(float)\n | Batch(array)\n```\nGo to: [Type definition](Hover.res#L266,3)\n" } } @@ -540,7 +540,7 @@ Hover src/Hover.res 281:6 { "contents": { "kind": "markdown", - "value": "```rescript\ntype aliased = variant\n```\n\n---\n\n```\n \n```\n```rescript\ntype variant = CoolVariant | OtherCoolVariant\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22Hover.res%22%2C251%2C0%5D)\n" + "value": "```rescript\ntype aliased = variant\n```\n\n---\n\n```\n \n```\n```rescript\ntype variant = CoolVariant | OtherCoolVariant\n```\nGo to: [Type definition](Hover.res#L252,1)\n" } } diff --git a/tests/analysis_tests/tests/src/expected/Jsx2.res.txt b/tests/analysis_tests/tests/src/expected/Jsx2.res.txt index 5e53e0ee572..57e0fa331e5 100644 --- a/tests/analysis_tests/tests/src/expected/Jsx2.res.txt +++ b/tests/analysis_tests/tests/src/expected/Jsx2.res.txt @@ -396,7 +396,7 @@ Hover src/Jsx2.res 162:12 { "contents": { "kind": "markdown", - "value": "```rescript\nComp.props\n```\n\n---\n\n```\n \n```\n```rescript\ntype Comp.props<'age> = {age: 'age}\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22Jsx2.res%22%2C157%2C2%5D)\n" + "value": "```rescript\nComp.props\n```\n\n---\n\n```\n \n```\n```rescript\ntype Comp.props<'age> = {age: 'age}\n```\nGo to: [Type definition](Jsx2.res#L158,3)\n" } } @@ -404,7 +404,7 @@ Hover src/Jsx2.res 167:16 { "contents": { "kind": "markdown", - "value": "```rescript\nComp.props\n```\n\n---\n\n```\n \n```\n```rescript\ntype Comp.props<'age> = {age: 'age}\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22Jsx2.res%22%2C157%2C2%5D)\n" + "value": "```rescript\nComp.props\n```\n\n---\n\n```\n \n```\n```rescript\ntype Comp.props<'age> = {age: 'age}\n```\nGo to: [Type definition](Jsx2.res#L158,3)\n" } } diff --git a/tests/analysis_tests/tests/src/expected/Jsx2.resi.txt b/tests/analysis_tests/tests/src/expected/Jsx2.resi.txt index b3741224a73..e631709fe89 100644 --- a/tests/analysis_tests/tests/src/expected/Jsx2.resi.txt +++ b/tests/analysis_tests/tests/src/expected/Jsx2.resi.txt @@ -2,7 +2,7 @@ Hover src/Jsx2.resi 1:4 { "contents": { "kind": "markdown", - "value": "```rescript\nprops\n```\n\n---\n\n```\n \n```\n```rescript\ntype props<'first> = {first: 'first}\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22Jsx2.resi%22%2C0%2C0%5D)\n" + "value": "```rescript\nprops\n```\n\n---\n\n```\n \n```\n```rescript\ntype props<'first> = {first: 'first}\n```\nGo to: [Type definition](Jsx2.resi#L1,1)\n" } } diff --git a/tests/analysis_tests/tests/src/expected/JsxV4.res.txt b/tests/analysis_tests/tests/src/expected/JsxV4.res.txt index 4ca5ee15219..1ef31160fbf 100644 --- a/tests/analysis_tests/tests/src/expected/JsxV4.res.txt +++ b/tests/analysis_tests/tests/src/expected/JsxV4.res.txt @@ -20,7 +20,7 @@ Hover src/JsxV4.res 14:9 { "contents": { "kind": "markdown", - "value": "```rescript\nReact.component>\n```\n---\n Doc Comment For M4 \n\n---\n\n```\n \n```\n```rescript\ntype React.component<'props> = Jsx.component<'props>\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22React.res%22%2C13%2C0%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype M4.props<'first, 'fun, 'second> = {\n first: 'first,\n fun?: 'fun,\n second?: 'second,\n}\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22JsxV4.res%22%2C3%2C2%5D)\n" + "value": "```rescript\nReact.component>\n```\n---\n Doc Comment For M4 \n\n---\n\n```\n \n```\n```rescript\ntype React.component<'props> = Jsx.component<'props>\n```\nGo to: [Type definition](React.res#L14,1)\n\n\n---\n\n```\n \n```\n```rescript\ntype M4.props<'first, 'fun, 'second> = {\n first: 'first,\n fun?: 'fun,\n second?: 'second,\n}\n```\nGo to: [Type definition](JsxV4.res#L4,3)\n" } } diff --git a/tests/analysis_tests/tests/src/expected/NestedRecords.res.txt b/tests/analysis_tests/tests/src/expected/NestedRecords.res.txt index b7d17f3f7dc..f025e27054b 100644 --- a/tests/analysis_tests/tests/src/expected/NestedRecords.res.txt +++ b/tests/analysis_tests/tests/src/expected/NestedRecords.res.txt @@ -12,7 +12,7 @@ Resolved opens 1 Stdlib { "contents": { "kind": "markdown", - "value": "```rescript\noptions\n```\n\n---\n\n```\n \n```\n```rescript\ntype options = {\n extra: {name: string, superExtra: {age: int}},\n}\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22NestedRecords.res%22%2C1%2C0%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype options.extra = {name: string, superExtra: {age: int}}\n```\n" + "value": "```rescript\noptions\n```\n\n---\n\n```\n \n```\n```rescript\ntype options = {\n extra: {name: string, superExtra: {age: int}},\n}\n```\nGo to: [Type definition](NestedRecords.res#L2,1)\n\n\n---\n\n```\n \n```\n```rescript\ntype options.extra = {name: string, superExtra: {age: int}}\n```\n" } } diff --git a/tests/analysis_tests/tests/src/expected/NestedRecordsHover.res.txt b/tests/analysis_tests/tests/src/expected/NestedRecordsHover.res.txt index 04b0c039ae7..97af189b76e 100644 --- a/tests/analysis_tests/tests/src/expected/NestedRecordsHover.res.txt +++ b/tests/analysis_tests/tests/src/expected/NestedRecordsHover.res.txt @@ -2,7 +2,7 @@ Hover src/NestedRecordsHover.res 8:7 { "contents": { "kind": "markdown", - "value": "```rescript\noptions\n```\n\n---\n\n```\n \n```\n```rescript\ntype options = {\n extra?: {\n name: string,\n superExtra?: {age: int},\n otherExtra: option<\n {\n test: bool,\n anotherInlined: {record: bool},\n },\n >,\n },\n}\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22NestedRecordsHover.res%22%2C0%2C0%5D)\n\n\n---\n\n```\n \n```\n```rescript\ntype options.extra = {\n name: string,\n superExtra?: {age: int},\n otherExtra: option<\n {\n test: bool,\n anotherInlined: {record: bool},\n },\n >,\n}\n```\n" + "value": "```rescript\noptions\n```\n\n---\n\n```\n \n```\n```rescript\ntype options = {\n extra?: {\n name: string,\n superExtra?: {age: int},\n otherExtra: option<\n {\n test: bool,\n anotherInlined: {record: bool},\n },\n >,\n },\n}\n```\nGo to: [Type definition](NestedRecordsHover.res#L1,1)\n\n\n---\n\n```\n \n```\n```rescript\ntype options.extra = {\n name: string,\n superExtra?: {age: int},\n otherExtra: option<\n {\n test: bool,\n anotherInlined: {record: bool},\n },\n >,\n}\n```\n" } } diff --git a/tests/analysis_tests/tests/src/expected/SignatureHelp.res.txt b/tests/analysis_tests/tests/src/expected/SignatureHelp.res.txt index 2a1992d916b..f0f733e88cb 100644 --- a/tests/analysis_tests/tests/src/expected/SignatureHelp.res.txt +++ b/tests/analysis_tests/tests/src/expected/SignatureHelp.res.txt @@ -35,7 +35,7 @@ extracted params: { "documentation": { "kind": "markdown", - "value": "```rescript\ntype someVariant = One | Two | Three\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22SignatureHelp.res%22%2C0%2C0%5D)" + "value": "```rescript\ntype someVariant = One | Two | Three\n```\nGo to: [Type definition](SignatureHelp.res#L1,1)" }, "label": [ 54, 71 ] }, @@ -85,7 +85,7 @@ extracted params: { "documentation": { "kind": "markdown", - "value": "```rescript\ntype someVariant = One | Two | Three\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22SignatureHelp.res%22%2C0%2C0%5D)" + "value": "```rescript\ntype someVariant = One | Two | Three\n```\nGo to: [Type definition](SignatureHelp.res#L1,1)" }, "label": [ 54, 71 ] }, @@ -135,7 +135,7 @@ extracted params: { "documentation": { "kind": "markdown", - "value": "```rescript\ntype someVariant = One | Two | Three\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22SignatureHelp.res%22%2C0%2C0%5D)" + "value": "```rescript\ntype someVariant = One | Two | Three\n```\nGo to: [Type definition](SignatureHelp.res#L1,1)" }, "label": [ 54, 71 ] }, @@ -185,7 +185,7 @@ extracted params: { "documentation": { "kind": "markdown", - "value": "```rescript\ntype someVariant = One | Two | Three\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22SignatureHelp.res%22%2C0%2C0%5D)" + "value": "```rescript\ntype someVariant = One | Two | Three\n```\nGo to: [Type definition](SignatureHelp.res#L1,1)" }, "label": [ 54, 71 ] }, @@ -235,7 +235,7 @@ extracted params: { "documentation": { "kind": "markdown", - "value": "```rescript\ntype someVariant = One | Two | Three\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22SignatureHelp.res%22%2C0%2C0%5D)" + "value": "```rescript\ntype someVariant = One | Two | Three\n```\nGo to: [Type definition](SignatureHelp.res#L1,1)" }, "label": [ 54, 71 ] }, @@ -285,7 +285,7 @@ extracted params: { "documentation": { "kind": "markdown", - "value": "```rescript\ntype someVariant = One | Two | Three\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22SignatureHelp.res%22%2C0%2C0%5D)" + "value": "```rescript\ntype someVariant = One | Two | Three\n```\nGo to: [Type definition](SignatureHelp.res#L1,1)" }, "label": [ 54, 71 ] }, @@ -702,7 +702,7 @@ extracted params: { "documentation": { "kind": "markdown", - "value": "```rescript\ntype someVariant = One | Two | Three\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22SignatureHelp.res%22%2C0%2C0%5D)" + "value": "```rescript\ntype someVariant = One | Two | Three\n```\nGo to: [Type definition](SignatureHelp.res#L1,1)" }, "label": [ 54, 71 ] }, @@ -821,7 +821,7 @@ Signature help src/SignatureHelp.res 94:15 { "documentation": { "kind": "markdown", - "value": "```rescript\ntype mySpecialThing = string\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22SignatureHelp.res%22%2C78%2C0%5D)" + "value": "```rescript\ntype mySpecialThing = string\n```\nGo to: [Type definition](SignatureHelp.res#L79,1)" }, "label": [ 4, 18 ] } @@ -843,7 +843,7 @@ Signature help src/SignatureHelp.res 97:19 { "documentation": { "kind": "markdown", - "value": "```rescript\ntype mySpecialThing = string\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22SignatureHelp.res%22%2C78%2C0%5D)" + "value": "```rescript\ntype mySpecialThing = string\n```\nGo to: [Type definition](SignatureHelp.res#L79,1)" }, "label": [ 6, 20 ] }, @@ -869,7 +869,7 @@ Signature help src/SignatureHelp.res 100:24 { "documentation": { "kind": "markdown", - "value": "```rescript\ntype mySpecialThing = string\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22SignatureHelp.res%22%2C78%2C0%5D)" + "value": "```rescript\ntype mySpecialThing = string\n```\nGo to: [Type definition](SignatureHelp.res#L79,1)" }, "label": [ 6, 20 ] }, @@ -958,14 +958,14 @@ extracted params: { "documentation": { "kind": "markdown", - "value": "```rescript\ntype x = {age?: int, name?: string}\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22SignatureHelp.res%22%2C117%2C0%5D)" + "value": "```rescript\ntype x = {age?: int, name?: string}\n```\nGo to: [Type definition](SignatureHelp.res#L118,1)" }, "label": [ 1, 2 ] }, { "documentation": { "kind": "markdown", - "value": "```rescript\ntype tt = One\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22SignatureHelp.res%22%2C123%2C0%5D)" + "value": "```rescript\ntype tt = One\n```\nGo to: [Type definition](SignatureHelp.res#L124,1)" }, "label": [ 4, 6 ] } @@ -990,14 +990,14 @@ extracted params: { "documentation": { "kind": "markdown", - "value": "```rescript\ntype x = {age?: int, name?: string}\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22SignatureHelp.res%22%2C117%2C0%5D)" + "value": "```rescript\ntype x = {age?: int, name?: string}\n```\nGo to: [Type definition](SignatureHelp.res#L118,1)" }, "label": [ 1, 2 ] }, { "documentation": { "kind": "markdown", - "value": "```rescript\ntype tt = One\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22SignatureHelp.res%22%2C123%2C0%5D)" + "value": "```rescript\ntype tt = One\n```\nGo to: [Type definition](SignatureHelp.res#L124,1)" }, "label": [ 4, 6 ] } @@ -1081,7 +1081,7 @@ Signature help src/SignatureHelp.res 143:7 { "documentation": { "kind": "markdown", - "value": "```rescript\ntype mySpecialThing = string\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22SignatureHelp.res%22%2C78%2C0%5D)" + "value": "```rescript\ntype mySpecialThing = string\n```\nGo to: [Type definition](SignatureHelp.res#L79,1)" }, "label": [ 4, 18 ] } @@ -1103,7 +1103,7 @@ Signature help src/SignatureHelp.res 145:9 { "documentation": { "kind": "markdown", - "value": "```rescript\ntype mySpecialThing = string\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22SignatureHelp.res%22%2C78%2C0%5D)" + "value": "```rescript\ntype mySpecialThing = string\n```\nGo to: [Type definition](SignatureHelp.res#L79,1)" }, "label": [ 6, 20 ] }, @@ -1129,7 +1129,7 @@ Signature help src/SignatureHelp.res 147:12 { "documentation": { "kind": "markdown", - "value": "```rescript\ntype mySpecialThing = string\n```\nGo to: [Type definition](command:rescript-vscode.go_to_location?%5B%22SignatureHelp.res%22%2C78%2C0%5D)" + "value": "```rescript\ntype mySpecialThing = string\n```\nGo to: [Type definition](SignatureHelp.res#L79,1)" }, "label": [ 6, 20 ] }, diff --git a/tests/build_tests/cli_help/input.js b/tests/build_tests/cli_help/input.js index d538968cfd8..70d5a508118 100755 --- a/tests/build_tests/cli_help/input.js +++ b/tests/build_tests/cli_help/input.js @@ -37,6 +37,9 @@ const cliHelp = " help [OPTION]… [COMMAND]\n" + " Print this message or command help.\n" + "\n" + + " lsp --stdio [OPTION]…\n" + + " Start the ReScript language server over stdio.\n" + + "\n" + " watch [OPTION]… [FOLDER]\n" + " Build, then start a watcher.\n" + "\n" + @@ -336,6 +339,25 @@ async function test(params, expected) { assert.equal(out.status, expected.status); } +// Missing transport is a usage error, not a running server or a help success. +await test(["lsp"], { + stdout: "", + stderr: + "Usage: rescript lsp [--help] --stdio [OPTION]…\n" + + "rescript: required option --stdio is missing\n", + status: 2, +}); + +for (const flag of ["--quiet", "--verbose"]) { + await test(["lsp", "--stdio", flag], { + stdout: "", + stderr: + "Usage: rescript lsp [--help] --stdio [OPTION]…\n" + + `rescript: unknown option ${flag}\n`, + status: 2, + }); +} + // Shows build help with --help arg await test(["build", "--help"], { stdout: buildHelp, diff --git a/tests/dune b/tests/dune index a61afee01a6..926cab03084 100644 --- a/tests/dune +++ b/tests/dune @@ -1 +1,6 @@ -(dirs ounit_tests rewatch_ounit_tests syntax_benchmarks syntax_tests) +(dirs + ounit_tests + rewatch_ounit_tests + syntax_benchmarks + syntax_tests + lsp_tests) diff --git a/tests/lsp_tests/README.md b/tests/lsp_tests/README.md new file mode 100644 index 00000000000..6ef8e1640c4 --- /dev/null +++ b/tests/lsp_tests/README.md @@ -0,0 +1,65 @@ +# LSP Snapshot Tests + +These OCaml `ppx_expect` tests run the language server over stdio. They adapt +representative cases from `tests/analysis_tests`; the original analysis suite +remains in place and has additional coverage. + +Each feature has its own module, with one named `let%expect_test` per scenario. +Source snippets contain no cursor markers. Positions are explicit, zero-based +LSP positions, recalculated after removing marker lines and uncommenting code. + +| Analysis marker | Test module | LSP request | +| --- | --- | --- | +| `def` | `definition.ml` | `textDocument/definition` | +| `com` | `completion.ml`, `completion_jsx.ml` | `textDocument/completion` | +| `cre` | `completion_resolve.ml` | `completionItem/resolve` | +| `doc` | `document_symbol.ml` | `textDocument/documentSymbol` | +| `sem`, `hig` | `semantic_tokens.ml` | `textDocument/semanticTokens/full` | +| `hov` | `hover.ml`, `hover_jsx.ml` | `textDocument/hover` | +| `she` | `signature_help.ml` | `textDocument/signatureHelp` | +| `int` | `create_interface.ml` | `textDocument/createInterface` (custom) | +| `ref` | `references.ml` | `textDocument/references` | +| `pre` | `prepare_rename.ml` | `textDocument/prepareRename` | +| `ren` | `rename.ml` | `textDocument/rename` | +| `typ` | `type_definition.ml` | `textDocument/typeDefinition` | +| `xfm` | `code_action.ml` | `textDocument/codeAction` | +| `hin` | `inlay_hint.ml` | `textDocument/inlayHint` | +| `dia` | `document_syntax.ml` | `textDocument/diagnostic` | +| `cle` | `code_lens.ml` | `textDocument/codeLens` | + +`Test.with_project` creates an isolated temporary project, optionally builds it, +opens `Test.res`, and cleans up after the server exits. Additional `.res` and +`.resi` files can be supplied through `~files`. Use `Test.change_document` to +exercise unsaved buffers without changing the compiled source, and +`Test.configure` to answer the server's configuration request. + +Use `~jsx:true` to build with a temporary copy of the local React bindings. +Use `~capabilities` to exercise push-only clients as well as the default client +with pull diagnostics. `diagnostics.ml` tests both actual recompilation and +explicit compiler-log phases delivered through `workspace/didChangeWatchedFiles`. +The client captures every diagnostic publication and uses a request round trip +as a barrier, so these tests do not rely on sleeps or an external file watcher. + +`Test.snapshot` serializes typed LSP responses, replacing the temporary root +with `/workspace` while preserving relative paths, ranges and response order. +The interface tests also snapshot the generated file contents. + +Some snapshots characterize current limitations rather than desired behavior: +file rename destinations are double encoded; module rename omits JSX tag uses; +component references repeat the declaration range; and a missing constructor +argument produces no signature help. These cases have TODO comments in their +tests. Path normalization preserves the malformed URI shape instead of hiding it. + +Build the local toolchain and runtime with `opam exec -- make lib`, then run: + +```sh +opam exec -- dune runtest tests/lsp_tests +``` + +Review snapshot differences before accepting them for a specific module: + +```sh +opam exec -- dune promotion diff tests/lsp_tests/definition.ml +opam exec -- dune promote tests/lsp_tests/definition.ml +opam exec -- dune runtest tests/lsp_tests +``` diff --git a/tests/lsp_tests/code_action.ml b/tests/lsp_tests/code_action.ml new file mode 100644 index 00000000000..c683d25f135 --- /dev/null +++ b/tests/lsp_tests/code_action.ml @@ -0,0 +1,298 @@ +open Lsp +open Types + +let actions doc start end_ = + Client_request.CodeAction + (CodeActionParams.create ~textDocument:(Test.text_document doc) + ~range:(Test.range start end_) + ~context:(CodeActionContext.create ~diagnostics:[] ()) + ()) + +let%expect_test "expands a wildcard to the remaining variant cases" = + Test.with_project ~name:"code-action" + ~text: + {|type kind = First | Second | Third | Fourth(int) +let variant = First +let value = switch variant { +| First => "first" +| _ => "other" +} +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (actions doc (4, 3) (4, 3))); + [%expect + {| + [ + { + "edit": { + "documentChanges": [ + { + "edits": [ + { + "newText": "Second | Third | Fourth(_)", + "range": { + "end": { "character": 3, "line": 4 }, + "start": { "character": 2, "line": 4 } + } + } + ], + "textDocument": { + "uri": "file:///workspace/Test.res", + "version": 0 + } + } + ] + }, + "kind": "refactor.rewrite", + "title": "Expand catch-all" + } + ] + |}] + +let%expect_test "extracts a local module into its own file" = + Test.with_project ~name:"code-action" + ~text: + {|module ExtractableModule = { + /** Doc comment. */ + type t = int + let doStuff = a => a + 1 +} +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (actions doc (3, 5) (3, 5))); + [%expect + {| + [ + { + "edit": { + "documentChanges": [ + { + "kind": "create", + "options": { "ignoreIfExists": true, "overwrite": false }, + "uri": "file:///workspace/ExtractableModule.res" + }, + { + "edits": [ + { + "newText": "/** Doc comment. */\ntype t = int\nlet doStuff = a => a + 1\n", + "range": { + "end": { "character": 1, "line": 4 }, + "start": { "character": 0, "line": 0 } + } + } + ], + "textDocument": { + "uri": "file:///workspace/ExtractableModule.res" + } + }, + { + "edits": [ + { + "newText": "", + "range": { + "end": { "character": 1, "line": 4 }, + "start": { "character": 0, "line": 0 } + } + } + ], + "textDocument": { "uri": "file:///workspace/Test.res" } + } + ] + }, + "kind": "refactor.rewrite", + "title": "Extract local module \"ExtractableModule\" to file \"ExtractableModule.res\"" + } + ] + |}] + +let%expect_test "rewrites an if expression as a switch" = + Test.with_project ~name:"code-action" + ~text: + {|type kind = First | Second +let kind = First +let value = if kind == First { + "First" +} else { + "Not First" +} +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (actions doc (2, 18) (2, 18))); + [%expect + {| + [ + { + "edit": { + "documentChanges": [ + { + "edits": [ + { + "newText": "switch kind {\n | First => failwith(\"TODO\")\n | Second => failwith(\"TODO\")\n }", + "range": { + "end": { "character": 19, "line": 2 }, + "start": { "character": 15, "line": 2 } + } + } + ], + "textDocument": { + "uri": "file:///workspace/Test.res", + "version": 0 + } + } + ] + }, + "kind": "refactor.rewrite", + "title": "Exhaustive switch" + }, + { + "edit": { + "documentChanges": [ + { + "edits": [ + { + "newText": "switch kind {\n | First => \"First\"\n | _ => \"Not First\"\n }", + "range": { + "end": { "character": 1, "line": 6 }, + "start": { "character": 12, "line": 2 } + } + } + ], + "textDocument": { + "uri": "file:///workspace/Test.res", + "version": 0 + } + } + ] + }, + "kind": "refactor.rewrite", + "title": "Replace with switch" + } + ] + |}] + +let%expect_test "offers a type annotation for an inferred string binding" = + Test.with_project ~name:"code-action" ~text:{|let name = "hello" +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (actions doc (0, 5) (0, 5))); + [%expect + {| + [ + { + "edit": { + "documentChanges": [ + { + "edits": [ + { + "newText": ": string", + "range": { + "end": { "character": 8, "line": 0 }, + "start": { "character": 8, "line": 0 } + } + } + ], + "textDocument": { + "uri": "file:///workspace/Test.res", + "version": 0 + } + } + ] + }, + "kind": "refactor.rewrite", + "title": "Add type annotation" + }, + { + "edit": { + "documentChanges": [ + { + "edits": [ + { + "newText": "/**\n\n*/\nlet name = \"hello\"", + "range": { + "end": { "character": 18, "line": 0 }, + "start": { "character": 0, "line": 0 } + } + } + ], + "textDocument": { + "uri": "file:///workspace/Test.res", + "version": 0 + } + } + ] + }, + "kind": "refactor.rewrite", + "title": "Add Documentation template" + } + ] + |}] + +let%expect_test "offers braces around a function body" = + Test.with_project ~name:"code-action" + ~text:{|let name = "hello" +let noBraces = () => name +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (actions doc (1, 21) (1, 21))); + [%expect + {| + [ + { + "edit": { + "documentChanges": [ + { + "edits": [ + { + "newText": "let noBraces = () => {\n name\n}", + "range": { + "end": { "character": 25, "line": 1 }, + "start": { "character": 0, "line": 1 } + } + } + ], + "textDocument": { + "uri": "file:///workspace/Test.res", + "version": 0 + } + } + ] + }, + "kind": "refactor.rewrite", + "title": "Add braces to function" + } + ] + |}] + +let%expect_test "does not offer another annotation for an annotated binding" = + Test.with_project ~name:"code-action" ~text:{|let annotated: int = 34 +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (actions doc (0, 5) (0, 5))); + [%expect + {| + [ + { + "edit": { + "documentChanges": [ + { + "edits": [ + { + "newText": "/**\n\n*/\nlet annotated: int = 34", + "range": { + "end": { "character": 23, "line": 0 }, + "start": { "character": 0, "line": 0 } + } + } + ], + "textDocument": { + "uri": "file:///workspace/Test.res", + "version": 0 + } + } + ] + }, + "kind": "refactor.rewrite", + "title": "Add Documentation template" + } + ] + |}] diff --git a/tests/lsp_tests/code_lens.ml b/tests/lsp_tests/code_lens.ml new file mode 100644 index 00000000000..c40f8d8a3ee --- /dev/null +++ b/tests/lsp_tests/code_lens.ml @@ -0,0 +1,111 @@ +open Lsp +open Types + +let lenses doc = + Client_request.TextDocumentCodeLens + (CodeLensParams.create ~textDocument:(Test.text_document doc) ()) + +let%expect_test "omits generated React component bindings from code lenses" = + Test.with_project ~name:"code-lens-jsx" ~jsx:true + ~text:{|@react.component +let make = (~name) => React.string(name) +|} + (fun ~root ~doc client -> + Test.configure client (`Assoc [("codeLens", `Bool true)]); + Test.snapshot ~root client (lenses doc)); + [%expect {| [] |}] + +let%expect_test "shows a function type for a dictionary constructor" = + Test.with_project ~name:"code-lens" + ~text:{|let make = value => dict{"a": value} +|} (fun ~root ~doc client -> + Test.configure client (`Assoc [("codeLens", `Bool true)]); + Test.snapshot ~root client (lenses doc)); + [%expect + {| + [ + { + "command": { "command": "", "title": "'a => dict<'a>" }, + "range": { + "end": { "character": 8, "line": 0 }, + "start": { "character": 4, "line": 0 } + } + } + ] + |}] + +let%expect_test "shows a function type for an interpolated template" = + Test.with_project ~name:"code-lens" + ~text:{|let greeting = name => `hello ${name}` +|} (fun ~root ~doc client -> + Test.configure client (`Assoc [("codeLens", `Bool true)]); + Test.snapshot ~root client (lenses doc)); + [%expect + {| + [ + { + "command": { "command": "", "title": "string => string" }, + "range": { + "end": { "character": 12, "line": 0 }, + "start": { "character": 4, "line": 0 } + } + } + ] + |}] + +let%expect_test "does not return code lenses when disabled" = + Test.with_project ~name:"code-lens" ~text:{|let add = (x, y) => x + y +|} + (fun ~root ~doc client -> Test.snapshot ~root client (lenses doc)); + [%expect {| [] |}] + +let%expect_test "shows types for positional and labeled functions" = + Test.with_project ~name:"code-lens" + ~text: + {|let add = (x, y) => x + y +let foo = (~age, ~name) => name ++ Int.toString(age) +|} + (fun ~root ~doc client -> + Test.configure client (`Assoc [("codeLens", `Bool true)]); + Test.snapshot ~root client (lenses doc)); + [%expect + {| + [ + { + "command": { + "command": "", + "title": "(~age: int, ~name: string) => string" + }, + "range": { + "end": { "character": 7, "line": 1 }, + "start": { "character": 4, "line": 1 } + } + }, + { + "command": { "command": "", "title": "(int, int) => int" }, + "range": { + "end": { "character": 7, "line": 0 }, + "start": { "character": 4, "line": 0 } + } + } + ] + |}] + +let%expect_test "shows a type lens for a braced function" = + Test.with_project ~name:"code-lens" + ~text:{|let bracedAdd = {(x, y) => x + y} +|} (fun ~root ~doc client -> + Test.configure client (`Assoc [("codeLens", `Bool true)]); + Test.snapshot ~root client (lenses doc)); + [%expect + {| + [ + { + "command": { "command": "", "title": "(int, int) => int" }, + "range": { + "end": { "character": 13, "line": 0 }, + "start": { "character": 4, "line": 0 } + } + } + ] + |}] diff --git a/tests/lsp_tests/completion.ml b/tests/lsp_tests/completion.ml new file mode 100644 index 00000000000..d661a42e301 --- /dev/null +++ b/tests/lsp_tests/completion.ml @@ -0,0 +1,192 @@ +open Lsp +open Types + +let completion doc pos = + Client_request.TextDocumentCompletion + (CompletionParams.create ~textDocument:(Test.text_document doc) + ~position:(Test.position pos) ()) + +let%expect_test "completes a string function after a pipe" = + let text = {|let value = "abc" +|} in + Test.with_project ~name:"completion" ~text (fun ~root ~doc client -> + Test.change_document client doc ~version:1 ~text:(text ^ {|value->toU|}); + Test.snapshot ~root client (completion doc (1, 10))); + [%expect + {| + [ + { + "detail": "string => string", + "documentation": { + "kind": "markdown", + "value": "\n`toUpperCase(str)` converts `str` to upper case using the locale-insensitive\ncase mappings in the Unicode Character Database. Notice that the conversion can\nexpand the number of letters in the result, for example the German ß\ncapitalizes to two Ses in a row.\nSee [`String.toUpperCase`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/toUpperCase) on MDN.\n\n## Examples\n\n```rescript\nString.toUpperCase(\"abc\") == \"ABC\"\nString.toUpperCase(`Straße`) == `STRASSE`\nString.toUpperCase(`πς`) == `ΠΣ`\n```\n" + }, + "kind": 12, + "label": "String.toUpperCase", + "tags": [] + } + ] + |}] + +let%expect_test "completes a named argument in a function call" = + let text = + {|module Lib = { + let foo = (~age, ~name) => name ++ Int.toString(age) +} +|} + in + Test.with_project ~name:"completion" ~text (fun ~root ~doc client -> + Test.change_document client doc ~version:1 + ~text:(text ^ {|let x = Lib.foo(~na|}); + Test.snapshot ~root client (completion doc (3, 19))); + [%expect + {| [ { "detail": "string", "kind": 4, "label": "name", "tags": [] } ] |}] + +let%expect_test "completes a constructor from the expected variant type" = + let text = {|type someVariant = One | Two | Three(int, string) +|} in + Test.with_project ~name:"completion" ~text (fun ~root ~doc client -> + Test.change_document client doc ~version:1 + ~text:(text ^ {|let x: someVariant = O|}); + Test.snapshot ~root client (completion doc (1, 22))); + [%expect + {| + [ + { + "detail": "One", + "documentation": { + "kind": "markdown", + "value": "```rescript\nOne\n```\n\n```rescript\ntype someVariant = One | Two | Three(int, string)\n```" + }, + "insertText": "One", + "insertTextFormat": 2, + "kind": 4, + "label": "One", + "sortText": "A One", + "tags": [] + }, + { "detail": "module Option", "kind": 9, "label": "Option", "tags": [] }, + { "detail": "module Ordering", "kind": 9, "label": "Ordering", "tags": [] }, + { "detail": "module Object", "kind": 9, "label": "Object", "tags": [] }, + { + "data": { "modulePath": "Obj", "filePath": "/workspace/Test.res" }, + "detail": "module Obj", + "kind": 9, + "label": "Obj", + "tags": [] + } + ] + |}] + +let%expect_test "completes a module member in a dictionary literal with spread" + = + let text = + {|module Colors = { + let red = "red" + let green = "green" +} +let base = dict{"a": Colors.red} +|} + in + Test.with_project ~name:"completion" ~text (fun ~root ~doc client -> + Test.change_document client doc ~version:1 + ~text:(text ^ {|let x = dict{...base, "b": Colors.g}|}); + Test.snapshot ~root client (completion doc (5, 33))); + [%expect + {| [ { "detail": "string", "kind": 12, "label": "green", "tags": [] } ] |}] + +let%expect_test "completes a record field in a destructuring pattern" = + let text = + {|type someRecord = {first: int, second: bool} +let f = {first: 123, second: true} +|} + in + Test.with_project ~name:"completion" ~text (fun ~root ~doc client -> + Test.change_document client doc ~version:1 ~text:(text ^ {|let {fi} = f|}); + Test.snapshot ~root client (completion doc (2, 7))); + [%expect + {| + [ + { + "detail": "int", + "documentation": { + "kind": "markdown", + "value": "```rescript\nfirst: int\n```\n\n```rescript\ntype someRecord = {first: int, second: bool}\n```" + }, + "kind": 5, + "label": "first", + "tags": [] + } + ] + |}] + +let%expect_test + "completes a documented deprecated module member in an unsaved buffer" = + let text = + {|module Dep: { + @res.doc("Some doc comment") @deprecated("Use customDouble instead") + let customDouble: int => int +} = { + let customDouble = foo => foo * 2 +} +|} + in + Test.with_project ~name:"completion" ~text (fun ~root ~doc client -> + Test.change_document client doc ~version:1 + ~text:(text ^ {|let cc = Dep.c|}); + Test.snapshot ~root client (completion doc (6, 14))); + [%expect + {| + [ + { + "deprecated": true, + "detail": "int => int", + "documentation": { + "kind": "markdown", + "value": "Deprecated: Use customDouble instead\n\nSome doc comment" + }, + "kind": 12, + "label": "customDouble", + "tags": [ 1 ] + } + ] + |}] + +let%expect_test "completes fields on a record receiver" = + let text = + {|type person = {name: string, age: int} +let person = {name: "Alice", age: 42} +|} + in + Test.with_project ~name:"completion" ~text (fun ~root ~doc client -> + Test.change_document client doc ~version:1 + ~text:(text ^ {|let name = person.na|}); + Test.snapshot ~root client (completion doc (2, 20))); + [%expect + {| + [ + { + "detail": "string", + "documentation": { + "kind": "markdown", + "value": "```rescript\nname: string\n```\n\n```rescript\ntype person = {name: string, age: int}\n```" + }, + "kind": 5, + "label": "name", + "tags": [] + } + ] + |}] + +let%expect_test "does not offer a module member with an unmatched prefix" = + let text = + {|module Lib = { + let next = (~number=0, ~year) => number + year +} +|} + in + Test.with_project ~name:"completion" ~text (fun ~root ~doc client -> + Test.change_document client doc ~version:1 + ~text:(text ^ {|let x = Lib.zzzz|}); + Test.snapshot ~root client (completion doc (3, 16))); + [%expect {| [] |}] diff --git a/tests/lsp_tests/completion_jsx.ml b/tests/lsp_tests/completion_jsx.ml new file mode 100644 index 00000000000..bbf8d29c1a7 --- /dev/null +++ b/tests/lsp_tests/completion_jsx.ml @@ -0,0 +1,36 @@ +module Completion_test = Completion + +let%expect_test "completes a nested JSX component prop" = + let text = + {|module O = { + module Comp = { + @react.component + let make = (~first="", ~zoo=3, ~second) => React.string(first ++ second ++ Int.toString(zoo)) + } +} +|} + in + Test.with_project ~name:"completion-jsx" ~jsx:true ~text + (fun ~root ~doc client -> + Test.change_document client doc ~version:1 + ~text:(text ^ {|let comp = ", "kind": 4, "label": "zoo", "tags": [] } ] |}] + +let%expect_test "completes a value in a JSX prop expression" = + let text = + {|module Comp = { + @react.component + let make = (~second) => React.string(second) +} +let zzz = "hello" +|} + in + Test.with_project ~name:"completion-jsx" ~jsx:true ~text + (fun ~root ~doc client -> + Test.change_document client doc ~version:1 + ~text:(text ^ {|let comp = + Test.change_document client doc ~version:1 ~text:{|let x = 1 +ModuleStu|}; + let items = + match Test.Client.request client (Completion.completion doc (1, 9)) with + | Some (`List items) -> items + | Some (`CompletionList list) -> list.items + | None -> failwith "Expected module completion" + in + let item = + List.find + (fun (item : CompletionItem.t) -> item.label = "ModuleStuff") + items + in + Test.snapshot ~root client (Client_request.CompletionItemResolve item)); + [%expect + {| + { + "data": { "modulePath": "ModuleStuff", "filePath": "/workspace/Test.res" }, + "detail": "module ModuleStuff", + "documentation": { + "kind": "markdown", + "value": " This is a top level module doc. " + }, + "kind": 9, + "label": "ModuleStuff", + "tags": [] + } + |}] + +let%expect_test "keeps an item without resolve metadata unchanged" = + Test.with_project ~name:"completion-resolve" ~text:{|let x = 1 +|} + (fun ~root ~doc:_ client -> + Test.snapshot ~root client + (Client_request.CompletionItemResolve + (CompletionItem.create ~label:"x" ()))); + [%expect {| { "label": "x" } |}] + +let%expect_test "preserves documentation already supplied on an item" = + Test.with_project ~name:"completion-resolve" ~text:{|let x = 1 +|} + (fun ~root ~doc:_ client -> + Test.snapshot ~root client + (Client_request.CompletionItemResolve + (CompletionItem.create ~label:"x" + ~documentation:(`String "Existing docs") ()))); + [%expect {| { "documentation": "Existing docs", "label": "x" } |}] diff --git a/tests/lsp_tests/create_interface.ml b/tests/lsp_tests/create_interface.ml new file mode 100644 index 00000000000..e3f96c63add --- /dev/null +++ b/tests/lsp_tests/create_interface.ml @@ -0,0 +1,118 @@ +open Lsp +open Types + +let create_interface doc = + Client_request.UnknownRequest + { + meth = "textDocument/createInterface"; + params = + Some + (Jsonrpc.Structured.t_of_yojson + (TextDocumentIdentifier.yojson_of_t (Test.text_document doc))); + } + +let create_and_print ~root ~doc client = + let response = Test.Client.request client (create_interface doc) in + Test.print_json ~root response; + let interface = TextDocumentIdentifier.t_of_yojson response in + In_channel.with_open_bin (Uri.to_path interface.uri) In_channel.input_all + |> print_string + +let%expect_test "creates an interface for components with optional props" = + Test.with_project ~name:"create-interface-jsx" ~jsx:true + ~text: + {|@react.component +let make = (~name, ~count=3) => React.string(name ++ Int.toString(count)) +module Other = { + @react.component + let make = (~children) => children +} +|} + create_and_print; + [%expect + {| + { "uri": "file:///workspace/Test.resi" } + @react.component + let make: (~name: string, ~count: int=?) => React.element + module Other: { + @react.component + let make: (~children: React.element) => React.element + } + |}] + +let%expect_test "preserves functors and module type constraints" = + Test.with_project ~name:"create-interface" + ~text: + {|module type Input = { + type t + let value: t +} +module Make = (X: Input) => { + type t = X.t + let value = X.value +} +|} + create_and_print; + [%expect + {| + { "uri": "file:///workspace/Test.resi" } + module type Input = { + type t + let value: t + } + module Make: ( + X: Input, + ) => + { + type t = X.t + let value: X.t + } + |}] + +let%expect_test + "preserves polymorphic variants and external argument attributes" = + Test.with_project ~name:"create-interface" + ~text: + {|type red = [#Ruby | #Redwood | #Rust] +type blue = [#Sapphire | #Neon | #Navy] +type color = [red | blue | #Papayawhip] +external upperBound: ([< #d | #e | #f]) => unit = "myexternal" +external x: (@as("bar") ~foo: int) => unit = "myexternal" +|} + create_and_print; + [%expect + {| + { "uri": "file:///workspace/Test.resi" } + type red = [#Ruby | #Redwood | #Rust] + type blue = [#Sapphire | #Neon | #Navy] + type color = [red | blue | #Papayawhip] + external upperBound: ([< #d | #e | #f]) => unit = "myexternal" + external x: (@as("bar") ~foo: int) => unit = "myexternal" + |}] + +let%expect_test "creates an interface for records and labeled functions" = + Test.with_project ~name:"create-interface" + ~text:{|type r = {name: string, age: int} +let add = (~x, ~y) => x + y +|} + create_and_print; + [%expect + {| + { "uri": "file:///workspace/Test.resi" } + type r = {name: string, age: int} + let add: (~x: int, ~y: int) => int + |}] + +let%expect_test "preserves external declarations and inline constants" = + Test.with_project ~name:"create-interface" + ~text: + {|@module("path") external dirname: string => string = "dirname" +@inline let f1 = 10 +|} + create_and_print; + [%expect + {| + { "uri": "file:///workspace/Test.resi" } + @module("path") external dirname: string => string = "dirname" + @inline let f1: int + |}] diff --git a/tests/lsp_tests/definition.ml b/tests/lsp_tests/definition.ml new file mode 100644 index 00000000000..cc32e4efc7e --- /dev/null +++ b/tests/lsp_tests/definition.ml @@ -0,0 +1,199 @@ +open Lsp +open Types + +let definition doc pos = + Client_request.TextDocumentDefinition + (DefinitionParams.create ~textDocument:(Test.text_document doc) + ~position:(Test.position pos) ()) + +let%expect_test "follows a module alias to a value in another file" = + Test.with_project ~name:"definition" + ~text:{|module Alias = Other +let x = Alias.value +|} + ~files:[("Other.res", {|let value = 42 +|})] + (fun ~root ~doc client -> + Test.snapshot ~root client (definition doc (1, 16))); + [%expect + {| + [ + { + "range": { + "end": { "character": 9, "line": 0 }, + "start": { "character": 4, "line": 0 } + }, + "uri": "file:///workspace/Other.res" + } + ] + |}] + +let%expect_test "finds a value bound by tuple destructuring" = + Test.with_project ~name:"definition" + ~text: + {|module A = { + let makeX = () => (1, 2) + let (xxx, yyy) = makeX() +} +let y = A.xxx +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (definition doc (4, 10))); + [%expect + {| + [ + { + "range": { + "end": { "character": 10, "line": 2 }, + "start": { "character": 7, "line": 2 } + }, + "uri": "file:///workspace/Test.res" + } + ] + |}] + +let%expect_test "finds a value bound by record destructuring" = + Test.with_project ~name:"definition" + ~text: + {|module A = { + type t = {name: string, age: int} + let makeT = () => {name: "", age: 0} + let {name, age} = makeT() +} +let n1 = A.name +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (definition doc (5, 11))); + [%expect + {| + [ + { + "range": { + "end": { "character": 11, "line": 3 }, + "start": { "character": 7, "line": 3 } + }, + "uri": "file:///workspace/Test.res" + } + ] + |}] + +let%expect_test "finds a React component from a JSX tag" = + Test.with_project ~name:"definition-jsx" ~jsx:true + ~text: + {|module Comp = { + @react.component + let make = (~name) => React.string(name) +} +let element = +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (definition doc (4, 16))); + [%expect + {| + [ + { + "range": { + "end": { "character": 10, "line": 2 }, + "start": { "character": 6, "line": 2 } + }, + "uri": "file:///workspace/Test.res" + } + ] + |}] + +let%expect_test "finds a local value declaration" = + Test.with_project ~name:"definition" ~text:{|let xx = 10 +let y = xx +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (definition doc (1, 8))); + [%expect + {| + [ + { + "range": { + "end": { "character": 6, "line": 0 }, + "start": { "character": 4, "line": 0 } + }, + "uri": "file:///workspace/Test.res" + } + ] + |}] + +let%expect_test "finds a type inside a nested module" = + Test.with_project ~name:"definition" + ~text: + {|module Inner = { + type tInner = int +} +type typeInner = Inner.tInner +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (definition doc (3, 24))); + [%expect + {| + [ + { + "range": { + "end": { "character": 13, "line": 1 }, + "start": { "character": 7, "line": 1 } + }, + "uri": "file:///workspace/Test.res" + } + ] + |}] + +let%expect_test "finds a value declaration in an open interface" = + Test.with_project ~name:"definition" ~text:{|let y = 4 +|} + ~files:[("Test.resi", {|let y: int +|})] + (fun ~root ~doc:_ client -> + let doc = + Test.make_document + ~uri:(Uri.of_path (Filename.concat root "Test.resi")) + ~text:{|let y: int +|} + in + Test.perform client (Test.open_document doc); + Test.snapshot ~root client (definition doc (0, 4))); + [%expect + {| + [ + { + "range": { + "end": { "character": 5, "line": 0 }, + "start": { "character": 4, "line": 0 } + }, + "uri": "file:///workspace/Test.resi" + } + ] + |}] + +let%expect_test "finds an exported value in another file" = + Test.with_project ~name:"definition" ~text:{|let y = Other.x +|} + ~files:[("Other.res", {|let x = 12 +|})] + (fun ~root ~doc client -> + Test.snapshot ~root client (definition doc (0, 14))); + [%expect + {| + [ + { + "range": { + "end": { "character": 5, "line": 0 }, + "start": { "character": 4, "line": 0 } + }, + "uri": "file:///workspace/Other.res" + } + ] + |}] + +let%expect_test "returns no definition in a comment" = + Test.with_project ~name:"definition" ~text:{|let x = 12 +// x +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (definition doc (1, 3))); + [%expect {| null |}] diff --git a/tests/lsp_tests/diagnostics.ml b/tests/lsp_tests/diagnostics.ml new file mode 100644 index 00000000000..dbdea18d81a --- /dev/null +++ b/tests/lsp_tests/diagnostics.ml @@ -0,0 +1,372 @@ +module Syntax_test = Document_syntax + +open Lsp +open Types + +let ( /+ ) = Filename.concat +let push_capabilities = ClientCapabilities.create () + +let notify_log ~root client = + Test.Client.send_notification client + (Client_notification.DidChangeWatchedFiles + (DidChangeWatchedFilesParams.create + ~changes: + [ + FileEvent.create ~type_:FileChangeType.Changed + ~uri:(Uri.of_path (root /+ "lib" /+ "bs" /+ ".compiler.log")); + ])) + +(* Write each build phase explicitly so a partial compiler log is observable + deterministically, without racing a real compiler watcher. *) +let write_log ~root client content = + Test.write_file (root /+ "lib" /+ "bs" /+ ".compiler.log") content; + notify_log ~root client + +let publications client = + Test.synchronize client; + Test.Client.take_notifications client + |> List.filter_map (fun (notification : Jsonrpc.Notification.t) -> + if notification.method_ = "textDocument/publishDiagnostics" then + Some (Option.get notification.params |> Jsonrpc.Structured.yojson_of_t) + else None) + +let print_publications ~root client = + publications client |> fun items -> Test.print_json ~root (`List items) + +let close_document client doc = + Test.Client.send_notification client + (Client_notification.TextDocumentDidClose + (DidCloseTextDocumentParams.create ~textDocument:(Test.text_document doc))) + +let save_document client (doc : TextDocumentItem.t) ~text = + Test.write_file (Uri.to_path doc.uri) text; + Test.Client.send_notification client + (Client_notification.DidSaveTextDocument + (DidSaveTextDocumentParams.create ~textDocument:(Test.text_document doc) + ())) + +let warning_log = + {|#Start(1) +Warning number 26 +Test.res:1:5-9 + +unused variable value. + +#Done(2) +|} + +let syntax_log = + {|#Start(3) +Syntax error! +Test.res:1:12-12 + +Did you forget to write an expression here? + +#Done(4) +|} + +let%expect_test + "removes a compiler warning after saving and rebuilding clean code" = + let clean = {|let value = () => 2 +|} in + let warning = {|let value = () => { + let unused = 1 + 2 +} +|} in + Test.with_project ~name:"diagnostics" ~text:clean (fun ~root ~doc client -> + Test.change_document client doc ~version:1 ~text:warning; + save_document client doc ~text:warning; + Test.run_rescript_build ~root; + notify_log ~root client; + Test.snapshot ~root client (Syntax_test.diagnostics doc); + Test.change_document client doc ~version:2 ~text:clean; + save_document client doc ~text:clean; + Test.run_rescript_build ~root; + notify_log ~root client; + Test.snapshot ~root client (Syntax_test.diagnostics doc)); + [%expect + {| + { + "kind": "full", + "items": [ + { + "message": "Warning 26 - unused variable unused.\nFix this by:\n- Deleting the variable if it's not used anymore.\n- Prepending the variable name with `_` (like `_unused`) to ignore that the variable is unused.\n- Using the variable somewhere.", + "range": { + "end": { "character": 12, "line": 1 }, + "start": { "character": 6, "line": 1 } + }, + "severity": 2, + "source": "ReScript" + } + ] + } + { "kind": "full", "items": [] } + |}] + +let%expect_test "does not clear pushed warnings during an incomplete build" = + Test.with_project ~name:"diagnostics" ~capabilities:push_capabilities + ~text:{|let value = 1 +|} (fun ~root ~doc client -> + ignore (publications client); + write_log ~root client warning_log; + print_endline "completed build:"; + print_publications ~root client; + write_log ~root client {|#Start(3) +|}; + print_endline "build started:"; + print_publications ~root client; + Test.change_document client doc ~version:1 ~text:doc.text; + save_document client doc ~text:doc.text; + print_endline "save during build:"; + print_publications ~root client; + write_log ~root client warning_log; + print_endline "next completed build:"; + print_publications ~root client); + [%expect + {| + completed build: + [ + { + "diagnostics": [ + { + "message": "Warning 26 - unused variable value.", + "range": { + "end": { "character": 9, "line": 0 }, + "start": { "character": 4, "line": 0 } + }, + "severity": 2, + "source": "ReScript" + } + ], + "uri": "file:///workspace/Test.res" + } + ] + build started: + [] + save during build: + [ + { + "diagnostics": [ + { + "message": "Warning 26 - unused variable value.", + "range": { + "end": { "character": 9, "line": 0 }, + "start": { "character": 4, "line": 0 } + }, + "severity": 2, + "source": "ReScript" + } + ], + "uri": "file:///workspace/Test.res" + } + ] + next completed build: + [ + { + "diagnostics": [ + { + "message": "Warning 26 - unused variable value.", + "range": { + "end": { "character": 9, "line": 0 }, + "start": { "character": 4, "line": 0 } + }, + "severity": 2, + "source": "ReScript" + } + ], + "uri": "file:///workspace/Test.res" + } + ] + |}] + +let%expect_test + "moves compiler diagnostics between pull and push without duplication" = + Test.with_project ~name:"diagnostics" ~text:{|let value = 1 +|} + (fun ~root ~doc client -> + ignore (publications client); + write_log ~root client warning_log; + print_endline "open document pushes:"; + print_publications ~root client; + print_endline "open document pull:"; + Test.snapshot ~root client (Syntax_test.diagnostics doc); + close_document client doc; + print_endline "closed document pushes:"; + print_publications ~root client; + Test.perform client (Test.open_document doc); + print_endline "reopened document pushes:"; + print_publications ~root client; + print_endline "reopened document pull:"; + Test.snapshot ~root client (Syntax_test.diagnostics doc)); + [%expect + {| + open document pushes: + [] + open document pull: + { + "kind": "full", + "items": [ + { + "message": "Warning 26 - unused variable value.", + "range": { + "end": { "character": 9, "line": 0 }, + "start": { "character": 4, "line": 0 } + }, + "severity": 2, + "source": "ReScript" + } + ] + } + closed document pushes: + [ + { + "diagnostics": [ + { + "message": "Warning 26 - unused variable value.", + "range": { + "end": { "character": 9, "line": 0 }, + "start": { "character": 4, "line": 0 } + }, + "severity": 2, + "source": "ReScript" + } + ], + "uri": "file:///workspace/Test.res" + } + ] + reopened document pushes: + [ { "diagnostics": [], "uri": "file:///workspace/Test.res" } ] + reopened document pull: + { + "kind": "full", + "items": [ + { + "message": "Warning 26 - unused variable value.", + "range": { + "end": { "character": 9, "line": 0 }, + "start": { "character": 4, "line": 0 } + }, + "severity": 2, + "source": "ReScript" + } + ] + } + |}] + +let%expect_test "clears unsaved syntax diagnostics when a document closes" = + Test.with_project ~name:"diagnostics" ~capabilities:push_capabilities + ~text:{|let value = 1 +|} (fun ~root ~doc client -> + ignore (publications client); + Test.change_document client doc ~version:1 ~text:{|let value = +|}; + print_endline "unsaved buffer:"; + print_publications ~root client; + close_document client doc; + print_endline "closed:"; + print_publications ~root client); + [%expect + {| + unsaved buffer: + [ + { + "diagnostics": [ + { + "message": "This let-binding misses an expression", + "range": { + "end": { "character": 0, "line": 1 }, + "start": { "character": 11, "line": 0 } + }, + "severity": 1, + "source": "ReScript" + } + ], + "uri": "file:///workspace/Test.res" + } + ] + closed: + [ { "diagnostics": [], "uri": "file:///workspace/Test.res" } ] + |}] + +let%expect_test + "keeps saved compiler syntax diagnostics after closing the buffer" = + Test.with_project ~name:"diagnostics" ~capabilities:push_capabilities + ~text:{|let value = 1 +|} (fun ~root ~doc client -> + ignore (publications client); + let text = {|let value = +|} in + Test.change_document client doc ~version:1 ~text; + save_document client doc ~text; + write_log ~root client syntax_log; + ignore (publications client); + close_document client doc; + print_publications ~root client); + [%expect + {| + [ + { + "diagnostics": [ + { + "message": "Did you forget to write an expression here?", + "range": { + "end": { "character": 12, "line": 0 }, + "start": { "character": 11, "line": 0 } + }, + "severity": 1, + "source": "ReScript" + } + ], + "uri": "file:///workspace/Test.res" + } + ] + |}] + +let%expect_test + "preserves other files type errors while the build has only syntax errors" = + Test.with_project ~name:"diagnostics" ~text:{|let value = 1 +|} + ~files:[("Other.res", {|let value = 1 +|})] + (fun ~root ~doc client -> + write_log ~root client + {|#Start(1) +We've found a bug for you! +Other.res:1:13-13 + +This has type: int +Somewhere wanted: string + +#Done(2) +|}; + Test.synchronize client; + Test.change_document client doc ~version:1 ~text:{|let value = +|}; + write_log ~root client syntax_log; + let other = + Test.make_document ~uri:(Uri.of_path (root /+ "Other.res")) ~text:{||} + in + Test.snapshot ~root client (Syntax_test.diagnostics other); + write_log ~root client {|#Start(5) +#Done(6) +|}; + Test.snapshot ~root client (Syntax_test.diagnostics other)); + [%expect + {| + { + "kind": "full", + "items": [ + { + "message": "This has type: int\nSomewhere wanted: string", + "range": { + "end": { "character": 13, "line": 0 }, + "start": { "character": 12, "line": 0 } + }, + "severity": 1, + "source": "ReScript" + } + ] + } + { "kind": "full", "items": [] } + |}] diff --git a/tests/lsp_tests/document_symbol.ml b/tests/lsp_tests/document_symbol.ml new file mode 100644 index 00000000000..79279de1019 --- /dev/null +++ b/tests/lsp_tests/document_symbol.ml @@ -0,0 +1,103 @@ +open Lsp +open Types + +let symbols doc = + Client_request.DocumentSymbol + (DocumentSymbolParams.create ~textDocument:(Test.text_document doc) ()) + +let%expect_test "lists nested module types and functions with their ranges" = + Test.with_project ~name:"document-symbol" ~run_build:false + ~text: + {|module ForAuto = { + type t = int + let abc = (x: t, _y: int) => x +} +let fa: ForAuto.t = 34 +|} + (fun ~root ~doc client -> Test.snapshot ~root client (symbols doc)); + [%expect + {| + [ + { + "kind": 16, + "name": "fa", + "range": { + "end": { "character": 22, "line": 4 }, + "start": { "character": 0, "line": 4 } + }, + "selectionRange": { + "end": { "character": 22, "line": 4 }, + "start": { "character": 0, "line": 4 } + } + }, + { + "children": [ + { + "kind": 12, + "name": "abc", + "range": { + "end": { "character": 32, "line": 2 }, + "start": { "character": 2, "line": 2 } + }, + "selectionRange": { + "end": { "character": 32, "line": 2 }, + "start": { "character": 2, "line": 2 } + } + }, + { + "kind": 26, + "name": "t", + "range": { + "end": { "character": 14, "line": 1 }, + "start": { "character": 2, "line": 1 } + }, + "selectionRange": { + "end": { "character": 14, "line": 1 }, + "start": { "character": 2, "line": 1 } + } + } + ], + "kind": 2, + "name": "ForAuto", + "range": { + "end": { "character": 1, "line": 3 }, + "start": { "character": 7, "line": 0 } + }, + "selectionRange": { + "end": { "character": 1, "line": 3 }, + "start": { "character": 7, "line": 0 } + } + } + ] + |}] + +let%expect_test "updates symbols from the unsaved document" = + Test.with_project ~name:"document-symbol" ~run_build:false + ~text:{|let zzz = 11 +|} (fun ~root ~doc client -> + Test.change_document client doc ~version:1 + ~text:{|let bracedFunction = {x => x + 1} +|}; + Test.snapshot ~root client (symbols doc)); + [%expect + {| + [ + { + "kind": 12, + "name": "bracedFunction", + "range": { + "end": { "character": 33, "line": 0 }, + "start": { "character": 0, "line": 0 } + }, + "selectionRange": { + "end": { "character": 33, "line": 0 }, + "start": { "character": 0, "line": 0 } + } + } + ] + |}] + +let%expect_test "returns no symbols for an empty document" = + Test.with_project ~name:"document-symbol" ~run_build:false ~text:{||} + (fun ~root ~doc client -> Test.snapshot ~root client (symbols doc)); + [%expect {| [] |}] diff --git a/tests/lsp_tests/document_syntax.ml b/tests/lsp_tests/document_syntax.ml new file mode 100644 index 00000000000..97df7ce71f1 --- /dev/null +++ b/tests/lsp_tests/document_syntax.ml @@ -0,0 +1,104 @@ +open Lsp +open Types + +let diagnostics doc = + Client_request.TextDocumentDiagnostic + (DocumentDiagnosticParams.create ~textDocument:(Test.text_document doc) ()) + +let%expect_test "reports parser errors without requiring a successful build" = + Test.with_project ~name:"document-syntax" ~run_build:false + ~text:{|let = 1 + 1.0 +let add = =2 +lett a = 2 +|} (fun ~root ~doc client -> + Test.snapshot ~root client (diagnostics doc)); + [%expect + {| + { + "kind": "full", + "items": [ + { + "message": "Consecutive statements on a line must be separated by ';' or a newline", + "range": { + "end": { "character": 6, "line": 2 }, + "start": { "character": 4, "line": 2 } + }, + "severity": 1, + "source": "ReScript" + }, + { + "message": "This let-binding misses an expression", + "range": { + "end": { "character": 11, "line": 1 }, + "start": { "character": 9, "line": 1 } + }, + "severity": 1, + "source": "ReScript" + }, + { + "message": "I was expecting a name for this let-binding. Example: `let message = \"hello\"`", + "range": { + "end": { "character": 5, "line": 0 }, + "start": { "character": 4, "line": 0 } + }, + "severity": 1, + "source": "ReScript" + } + ] + } + |}] + +let%expect_test "clears syntax diagnostics after correcting the unsaved buffer" + = + Test.with_project ~name:"document-syntax" ~run_build:false + ~text:{|let add = =2 +|} (fun ~root ~doc client -> + Test.snapshot ~root client (diagnostics doc); + Test.change_document client doc ~version:1 ~text:{|let add = 2 +|}; + Test.snapshot ~root client (diagnostics doc)); + [%expect + {| + { + "kind": "full", + "items": [ + { + "message": "This let-binding misses an expression", + "range": { + "end": { "character": 11, "line": 0 }, + "start": { "character": 9, "line": 0 } + }, + "severity": 1, + "source": "ReScript" + } + ] + } + { "kind": "full", "items": [] } + |}] + +let%expect_test "reports syntax errors introduced after opening valid code" = + Test.with_project ~name:"document-syntax" ~run_build:false + ~text:{|let add = 2 +|} (fun ~root ~doc client -> + Test.snapshot ~root client (diagnostics doc); + Test.change_document client doc ~version:1 ~text:{|let add = =2 +|}; + Test.snapshot ~root client (diagnostics doc)); + [%expect + {| + { "kind": "full", "items": [] } + { + "kind": "full", + "items": [ + { + "message": "This let-binding misses an expression", + "range": { + "end": { "character": 11, "line": 0 }, + "start": { "character": 9, "line": 0 } + }, + "severity": 1, + "source": "ReScript" + } + ] + } + |}] diff --git a/tests/lsp_tests/dune b/tests/lsp_tests/dune new file mode 100644 index 00000000000..c704af295ce --- /dev/null +++ b/tests/lsp_tests/dune @@ -0,0 +1,10 @@ +(library + (name rescript_lsp_tests) + (package rescript-language-server) + (libraries lsp jsonrpc yojson eio eio_main) + (inline_tests + (deps ../../rewatch-ocaml/rescript_ocaml.exe)) + (preprocess + (pps ppx_deriving_yojson ppx_expect)) + (flags + (-w "-9-32-33"))) diff --git a/tests/lsp_tests/hover.ml b/tests/lsp_tests/hover.ml new file mode 100644 index 00000000000..c11eecab892 --- /dev/null +++ b/tests/lsp_tests/hover.ml @@ -0,0 +1,801 @@ +open Lsp +open Lsp.Types + +let ( /+ ) = Filename.concat + +let print_hover_response resp = + match resp with + | Some hover -> + Hover.yojson_of_t hover |> Yojson.Safe.pretty_to_string |> print_endline + | None -> print_endline "" + +let hover ~uri ~position = + Test.Request + ( Client_request.TextDocumentHover + { + HoverParams.position; + textDocument = TextDocumentIdentifier.create ~uri; + workDoneToken = None; + }, + print_hover_response ) + +let rescript_json = + {| +{ + "name": "hover-test", + "sources": { + "dir": "." + }, + "package-specs": { + "module": "esmodule", + "in-source": false + }, + "suffix": ".res.js" +} +|} + +let test_hover ~text ~position = + let root = Filename.get_temp_dir_name () /+ "hover" in + let uri = Uri.of_path (root /+ "test.res") in + let doc = Test.make_document ~text ~uri in + Test.setup_project ~root ~rescript_json ~run_build:true ~documents:[doc]; + let line, character = position in + Test.with_client ~root (fun client -> + Test.perform_all client + [ + Test.open_document doc; + hover ~uri ~position:(Position.create ~line ~character); + ]) + |> ignore + +let%expect_test "shows documentation for an exported value from another file" = + Test.with_project ~name:"hover" ~text:{|let x = Other.customDouble(2) +|} + ~files: + [ + ("Other.res", {|/** Doubles a value. */ +let customDouble = x => x * 2 +|}); + ] + (fun ~root ~doc client -> + Test.snapshot ~root client + (Client_request.TextDocumentHover + (HoverParams.create ~textDocument:(Test.text_document doc) + ~position:(Test.position (0, 18)) + ()))); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nint => int\n```\n---\n Doubles a value. " + } + } + |}] + +let%expect_test "shows the abstract type exposed by an interface" = + Test.with_project ~name:"hover" ~text:{|let x = Other.value +|} + ~files: + [ + ("Other.res", {|type t = int +let value = 1 +|}); + ("Other.resi", {|type t +let value: t +|}); + ] + (fun ~root ~doc client -> + Test.snapshot ~root client + (Client_request.TextDocumentHover + (HoverParams.create ~textDocument:(Test.text_document doc) + ~position:(Test.position (0, 15)) + ()))); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nOther.t\n```\n\n---\n```rescript\ntype Other.t\n```\n" + } + } + |}] + +let%expect_test "infers the type of an arithmetic binding" = + test_hover ~position:(0, 4) ~text:{|let abc = 22 + 34 +|}; + [%expect + {| { "contents": { "kind": "markdown", "value": "```rescript\nint\n```" } } |}] + +let%expect_test "shows a tuple type declaration" = + test_hover ~position:(0, 5) ~text:{|type t = (int, float) +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\ntype t = (int, float)\n```" + } + } + |}] + +let%expect_test "shows the signature of a module containing a type" = + test_hover ~position:(0, 7) ~text:{|module Id = { + type x = int +} +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nmodule Id: {\n type x = int\n}\n```" + } + } + |}] + +let documented_module = + {|@res.doc("This module is commented") +module Dep: { + @res.doc("Some doc comment") + let customDouble: int => int +} = { + let customDouble = foo => foo * 2 +} + +module D = Dep + +let cd = D.customDouble +|} + +let%expect_test "shows module documentation at an alias target" = + test_hover ~text:documented_module ~position:(8, 11); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "\nThis module is commented\n---\n\n```\n \n```\n```rescript\nmodule Dep: {\n let customDouble: int => int\n}\n```" + } + } + |}] + +let%expect_test "shows function documentation through a module alias" = + test_hover ~text:documented_module ~position:(10, 11); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nint => int\n```\n---\nSome doc comment" + } + } + |}] + +let%expect_test "shows the annotation and documentation of a function" = + test_hover ~position:(1, 4) + ~text: + {|@res.doc("Doc comment for functionWithTypeAnnotation") +let functionWithTypeAnnotation: unit => int = () => 1 +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nunit => int\n```\n---\nDoc comment for functionWithTypeAnnotation" + } + } + |}] + +let%expect_test "infers the type of an integer literal" = + test_hover ~position:(0, 10) ~text:{|let num = 34 +|}; + [%expect + {| { "contents": { "kind": "markdown", "value": "```rescript\nint\n```" } } |}] + +let logger_module = + {|module type Logger = { + let log: string => unit +} + +module JsLogger: Logger = { + let log = (msg: string) => Console.log(msg) + let _oneMore = 3 +} +|} + +let%expect_test "shows a module type signature" = + test_hover ~text:logger_module ~position:(0, 13); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nmodule type Logger = {\n let log: string => unit\n}\n```" + } + } + |}] + +let%expect_test "shows the constrained signature of a module" = + test_hover ~text:logger_module ~position:(4, 7); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nmodule type Logger = {\n let log: string => unit\n}\n```" + } + } + |}] + +let%expect_test "shows only the visible bindings of a module with shadowing" = + test_hover ~position:(0, 9) + ~text: + {|module IdDefinedTwice = { + let _x = 10 + let y = 20 + let _x = 10 +} +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nmodule IdDefinedTwice: {\n let y: int\n let _x: int\n}\n```" + } + } + |}] + +let module_aliases = + {|module A = { + let x = 13 +} + +module B = A + +module C = B +|} + +let%expect_test "resolves a module alias binding" = + test_hover ~text:module_aliases ~position:(4, 7); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nmodule A: {\n let x: int\n}\n```" + } + } + |}] + +let%expect_test "resolves a chain of module aliases" = + test_hover ~text:module_aliases ~position:(6, 7); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nmodule A: {\n let x: int\n}\n```" + } + } + |}] + +let%expect_test "infers a generic record field from a float operation" = + test_hover ~position:(2, 25) + ~text:{|type r<'a> = {i: 'a, f: float} + +let _get = r => r.f +. r.i +|}; + [%expect + {| { "contents": { "kind": "markdown", "value": "```rescript\nfloat\n```" } } |}] + +let%expect_test "infers the type of an aliased labeled parameter" = + test_hover ~position:(0, 21) ~text:{|let withAs = (~xx as yyy) => yyy + 1 +|}; + [%expect + {| { "contents": { "kind": "markdown", "value": "```rescript\nint\n```" } } |}] + +let polymorphic_variant_aliases = + {|module AA = { + type cond<'a> = [< #str(string)] as 'a + let fnnxx = (b: cond<_>) => true ? b : b +} + +let funAlias = AA.fnnxx + +let typeOk = funAlias + +let typeDuplicate = AA.fnnxx +|} + +let%expect_test + "preserves polymorphic variant constraints through a function alias" = + test_hover ~text:polymorphic_variant_aliases ~position:(7, 16); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nAA.cond<([< #str(string)] as 'a)> => AA.cond<'a>\n```\n\n---\n```rescript\ntype AA.cond<'a> = 'a\n constraint 'a = [< #str(string)]\n```\n" + } + } + |}] + +let%expect_test "shows polymorphic variant constraints on a qualified function" + = + test_hover ~text:polymorphic_variant_aliases ~position:(9, 25); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nAA.cond<([< #str(string)] as 'a)> => AA.cond<'a>\n```\n\n---\n```rescript\ntype AA.cond<'a> = 'a\n constraint 'a = [< #str(string)]\n```\n" + } + } + |}] + +let%expect_test "shows both unit parameters in a function type" = + test_hover ~position:(0, 4) ~text:{|let arity0b = ((), ()) => 3 +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\n(unit, unit) => int\n```" + } + } + |}] + +let%expect_test "shows the type of a function returning a function" = + test_hover ~position:(0, 5) + ~text:{|let arity0d = () => { + let f = () => 3 + f +} +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nunit => unit => int\n```" + } + } + |}] + +let%expect_test "includes a compact doc comment on a value" = + test_hover ~position:(1, 9) ~text:{|/**doc comment 1*/ +let docComment1 = 12 +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nint\n```\n---\ndoc comment 1" + } + } + |}] + +let%expect_test "preserves spaces in a value doc comment" = + test_hover ~position:(1, 6) + ~text:{|/** doc comment 2 */ +let docComment2 = 12 +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nint\n```\n---\n doc comment 2 " + } + } + |}] + +let record_substitution = + {|module TypeSubstitutionRecords = { + type foo<'a> = {content: 'a, zzz: string} + type bar = {age: int} + type foobar = foo + + let x1: foo = {content: {age: 42}, zzz: ""} + let x2: foobar = {content: {age: 42}, zzz: ""} +} +|} + +let%expect_test "expands the type argument of a generic record" = + test_hover ~text:record_substitution ~position:(5, 23); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nfoo\n```\n\n---\n```rescript\ntype foo<'a> = {content: 'a, zzz: string}\n```\n\n\n---\n```rescript\ntype bar = {age: int}\n```\n" + } + } + |}] + +let%expect_test "shows the alias of a specialized record" = + test_hover ~text:record_substitution ~position:(6, 22); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nfoobar\n```\n\n---\n```rescript\ntype foobar = foo\n```\n" + } + } + |}] + +let%expect_test "expands record types nested inside list and option" = + test_hover ~position:(3, 16) + ~text: + {|type r<'a> = {i: 'a, f: float} +type useR = {x: int, y: list>>} + +let testUseR = (v: useR) => v +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nuseR\n```\n\n---\n```rescript\ntype useR = {x: int, y: list>>}\n```\n\n\n---\n```rescript\ntype r<'a> = {i: 'a, f: float}\n```\n" + } + } + |}] + +let%expect_test "uses the documentation of a value rather than its initializer" + = + test_hover ~position:(8, 20) + ~text: + {|module NotShadowed = { + /** Stuff */ + let xx_ = 10 + + /** More Stuff */ + let xx = xx_ +} + +let _ = NotShadowed.xx +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nint\n```\n---\n More Stuff " + } + } + |}] + +let%expect_test "uses the documentation of the latest shadowing binding" = + test_hover ~position:(8, 17) + ~text: + {|module Shadowed = { + /** Stuff */ + let xx = 10 + + /** More Stuff */ + let xx = xx +} + +let _ = Shadowed.xx +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nint\n```\n---\n More Stuff " + } + } + |}] + +let%expect_test "shows documentation for a record field access" = + test_hover ~position:(9, 19) + ~text: + {|type recordWithDocstringField = { + /** Mighty fine field here. */ + someField: bool, +} + +let x: recordWithDocstringField = { + someField: true, +} + +let someField = x.someField +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nbool\n```\n---\n Mighty fine field here. " + } + } + |}] + +let%expect_test "shows a variant constructor with its documentation" = + test_hover ~position:(2, 20) + ~text: + {|type variant = | /** Cool variant! */ CoolVariant | /** Other cool variant */ OtherCoolVariant + +let coolVariant = CoolVariant +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nvariant\nCoolVariant\n```\n---\n Cool variant! \n\n---\n```rescript\ntype variant = CoolVariant | OtherCoolVariant\n```\n" + } + } + |}] + +let payload_variants = + {|type payloadVariants = InlineRecord({field1: int, field2: bool}) | Args(int, bool) + +let payloadVariant = InlineRecord({field1: 1, field2: true}) + +let payloadVariant2 = Args(1, true) +|} + +let%expect_test "shows a variant constructor with an inline record payload" = + test_hover ~text:payload_variants ~position:(2, 22); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\npayloadVariants\nInlineRecord({field1: int, field2: bool})\n```\n\n---\n```rescript\ntype payloadVariants =\n | InlineRecord({field1: int, field2: bool})\n | Args(int, bool)\n```\n" + } + } + |}] + +let%expect_test "shows a variant constructor with positional payloads" = + test_hover ~text:payload_variants ~position:(4, 23); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\npayloadVariants\nArgs(int, bool)\n```\n\n---\n```rescript\ntype payloadVariants =\n | InlineRecord({field1: int, field2: bool})\n | Args(int, bool)\n```\n" + } + } + |}] + +let%expect_test "expands a recursive variant from another module" = + test_hover ~position:(4, 42) + ~text: + {|module RecursiveVariants = { + type rec t = Action1(int) | Action2(float) | Batch(array) +} + +let recursiveVariant = RecursiveVariants.Action1(1) +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nRecursiveVariants.t\nAction1(int)\n```\n\n---\n```rescript\ntype RecursiveVariants.t =\n | Action1(int)\n | Action2(float)\n | Batch(array)\n```\n" + } + } + |}] + +let%expect_test "expands an alias of a variant type" = + test_hover ~position:(2, 6) + ~text: + {|type variant = CoolVariant | OtherCoolVariant + +type aliased = variant +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\ntype aliased = variant\n```\n\n---\n```rescript\ntype variant = CoolVariant | OtherCoolVariant\n```\n" + } + } + |}] + +let%expect_test "recognizes res.doc as documentation" = + test_hover ~position:(0, 32) + ~text:{|@res.doc("Shown on hover") let resDoc = 1 +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nint\n```\n---\nShown on hover" + } + } + |}] + +let%expect_test "ignores doc attributes from another namespace" = + test_hover ~position:(0, 34) + ~text:{|@ns.doc("Not shown on hover") let nsDoc = 1 +|}; + [%expect + {| { "contents": { "kind": "markdown", "value": "```rescript\nint\n```" } } |}] + +let%expect_test "ignores the legacy ocaml.doc attribute" = + test_hover ~position:(0, 37) + ~text:{|@ocaml.doc("Not shown on hover") let ocamlDoc = 1 +|}; + [%expect + {| { "contents": { "kind": "markdown", "value": "```rescript\nint\n```" } } |}] + +let%expect_test "returns no hover for a binding inside a line comment" = + test_hover ~position:(0, 23) ~text:{|// let fff = "hello"; fff +|}; + [%expect {| |}] + +let%expect_test "returns no hover for a record field inside a line comment" = + test_hover ~position:(3, 6) + ~text: + {|type recordWithDocstringField = {someField: bool} +let x: recordWithDocstringField = {someField: true} + +// x.someField +|}; + [%expect {| |}] + +let%expect_test "preserves fenced examples and nested comments in res.doc" = + test_hover ~position:(9, 9) + ~text: + {|@res.doc(" Doc comment with a triple-backquote example + + ```res example + let a = 10 + /* + * stuff + */ + ``` +") +let docComment1 = 12 +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nint\n```\n---\n Doc comment with a triple-backquote example\n\n ```res example\n let a = 10\n /*\n * stuff\n */\n ```\n" + } + } + |}] + +let%expect_test "preserves fenced examples and nested comments in a doc comment" + = + test_hover ~position:(10, 6) + ~text: + {|/** + Doc comment with a triple-backquote example + + ```res example + let a = 10 + /* + * stuff + */ + ``` +*/ +let docComment2 = 12 +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nint\n```\n---\n\n Doc comment with a triple-backquote example\n\n ```res example\n let a = 10\n /*\n * stuff\n */\n ```\n" + } + } + |}] + +let%expect_test "resolves a field in an inline record access chain" = + test_hover ~position:(2, 41) + ~text: + {|type person = {details: {name: string}} + +let getName = (person: person) => person.details.name +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\ntype person.details = {name: string}\n```" + } + } + |}] + +let nested_records = + {|type options = { + extra?: { + name: string, + superExtra?: {age: int}, + otherExtra: option<{test: bool, anotherInlined: {record: bool}}>, + }, +} + +let options = { + extra: { + name: "test", + superExtra: { + age: 2222, + }, + otherExtra: Some({ + test: true, + anotherInlined: { + record: true, + }, + }), + }, +} +|} + +let%expect_test "infers the outer type of a nested record literal" = + test_hover ~text:nested_records ~position:(8, 7); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\noptions\n```\n\n---\n```rescript\ntype options = {\n extra?: {\n name: string,\n superExtra?: {age: int},\n otherExtra: option<\n {\n test: bool,\n anotherInlined: {record: bool},\n },\n >,\n },\n}\n```\n\n\n---\n```rescript\ntype options.extra = {\n name: string,\n superExtra?: {age: int},\n otherExtra: option<\n {\n test: bool,\n anotherInlined: {record: bool},\n },\n >,\n}\n```\n" + } + } + |}] + +let%expect_test "shows the enclosing inline record at its string field" = + test_hover ~text:nested_records ~position:(10, 6); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\ntype options.extra = {\n name: string,\n superExtra?: {age: int},\n otherExtra: option<\n {\n test: bool,\n anotherInlined: {record: bool},\n },\n >,\n}\n```" + } + } + |}] + +let%expect_test "shows the nested optional record at its integer field" = + test_hover ~text:nested_records ~position:(12, 8); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\ntype options.extra.superExtra = {age: int}\n```" + } + } + |}] + +let%expect_test "shows the option-wrapped record at its boolean field" = + test_hover ~text:nested_records ~position:(15, 9); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\ntype options.extra.otherExtra = {\n test: bool,\n anotherInlined: {record: bool},\n}\n```" + } + } + |}] + +let%expect_test "shows the deepest inline record at its boolean field" = + test_hover ~text:nested_records ~position:(17, 11); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\ntype options.extra.otherExtra.anotherInlined = {\n record: bool,\n}\n```" + } + } + |}] diff --git a/tests/lsp_tests/hover_jsx.ml b/tests/lsp_tests/hover_jsx.ml new file mode 100644 index 00000000000..5b70bd32702 --- /dev/null +++ b/tests/lsp_tests/hover_jsx.ml @@ -0,0 +1,288 @@ +module Hover_test = Hover + +open Lsp +open Lsp.Types + +let ( /+ ) = Filename.concat + +let jsx_rescript_json = + {|{ + "name": "hover-jsx-test", + "sources": {"dir": "."}, + "dependencies": ["@rescript/react"], + "jsx": {"version": 4}, + "package-specs": {"module": "esmodule", "in-source": false}, + "suffix": ".res.js" +}|} + +let test_hover_with_interface ~interface ~text ~position = + let root = + Filename.get_temp_dir_name () + /+ if Option.is_some interface then "hover-jsx-interface" else "hover-jsx" + in + let uri = Uri.of_path (root /+ "test.res") in + let doc = Test.make_document ~text ~uri in + let doc, documents = + match interface with + | None -> (doc, [doc]) + | Some text -> + let interface_doc = + Test.make_document ~text ~uri:(Uri.of_path (root /+ "test.resi")) + in + (interface_doc, [doc; interface_doc]) + in + let uri = doc.uri in + Test.setup_react ~root; + Test.setup_project ~root ~rescript_json:jsx_rescript_json ~run_build:true + ~documents; + + let line, character = position in + + Test.with_client ~root (fun client -> + Test.perform_all client + [ + Test.open_document doc; + Hover_test.hover ~uri ~position:(Position.create ~line ~character); + ]) + |> ignore + +let test_jsx_hover ~text ~position = + test_hover_with_interface ~interface:None ~text ~position + +let test_jsx_interface_hover ~interface ~text ~position = + test_hover_with_interface ~interface:(Some interface) ~text ~position + +let%expect_test "infers a value inside a module containing a React component" = + test_jsx_hover ~position:(1, 6) + ~text: + {|module HoverInsideModuleWithComponent = { + let x = 2 + @react.component + let make = () => React.null +} +|}; + [%expect + {| { "contents": { "kind": "markdown", "value": "```rescript\nint\n```" } } |}] + +let%expect_test "infers a React component prop from its use" = + test_jsx_hover ~position:(1, 13) + ~text:{|@react.component +let make = (~name) => React.string(name) +|}; + [%expect + {| { "contents": { "kind": "markdown", "value": "```rescript\nstring\n```" } } |}] + +let%expect_test "shows an annotated prop in a nested React component" = + test_jsx_hover ~position:(2, 15) + ~text: + {|module C2 = { + @react.component + let make2 = (~name: string) => React.string(name) +} +|}; + [%expect + {| { "contents": { "kind": "markdown", "value": "```rescript\nstring\n```" } } |}] + +let%expect_test "shows the type of an intrinsic JSX tag" = + test_jsx_hover ~position:(0, 10) ~text:{|let q =
+|}; + [%expect + {| { "contents": { "kind": "markdown", "value": "```rescript\nstring\n```" } } |}] + +let%expect_test "shows the type of an intrinsic JSX attribute" = + test_jsx_hover ~position:(0, 14) ~text:{|let _ =
+|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nJsxDOM.domProps\n```\n\n---\n```rescript\ntype JsxDOM.domProps = {\n key?: string,\n children?: Jsx.element,\n ref?: domRef,\n allow?: string,\n ariaCurrent?: [\n | #date\n | #\"false\"\n | #location\n | #page\n | #step\n | #time\n | #\"true\"\n ],\n ariaDetails?: string,\n ariaDisabled?: bool,\n ariaHidden?: bool,\n ariaInvalid?: [#\"false\" | #grammar | #spelling | #\"true\"],\n ariaKeyshortcuts?: string,\n ariaLabel?: string,\n ariaRoledescription?: string,\n ariaAutocomplete?: [#both | #inline | #list | #none],\n ariaChecked?: [#\"false\" | #mixed | #\"true\"],\n ariaExpanded?: bool,\n ariaHaspopup?: [\n | #dialog\n | #\"false\"\n | #grid\n | #listbox\n | #menu\n | #tree\n | #\"true\"\n ],\n ariaLevel?: int,\n ariaModal?: bool,\n ariaMultiline?: bool,\n ariaMultiselectable?: bool,\n ariaOrientation?: [#horizontal | #undefined | #vertical],\n ariaPlaceholder?: string,\n ariaPressed?: [#\"false\" | #mixed | #\"true\"],\n ariaReadonly?: bool,\n ariaRequired?: bool,\n ariaSelected?: bool,\n ariaSort?: string,\n ariaValuemax?: float,\n ariaValuemin?: float,\n ariaValuenow?: float,\n ariaValuetext?: string,\n ariaAtomic?: bool,\n ariaBusy?: bool,\n ariaLive?: [#assertive | #off | #polite | #rude],\n ariaRelevant?: string,\n ariaDropeffect?: [\n | #copy\n | #execute\n | #link\n | #move\n | #none\n | #popup\n ],\n ariaGrabbed?: bool,\n ariaActivedescendant?: string,\n ariaColcount?: int,\n ariaColindex?: int,\n ariaColspan?: int,\n ariaControls?: string,\n ariaDescribedby?: string,\n ariaErrormessage?: string,\n ariaFlowto?: string,\n ariaLabelledby?: string,\n ariaOwns?: string,\n ariaPosinset?: int,\n ariaRowcount?: int,\n ariaRowindex?: int,\n ariaRowspan?: int,\n ariaSetsize?: int,\n defaultChecked?: bool,\n defaultValue?: string,\n accessKey?: string,\n capture?: [#environment | #user],\n className?: string,\n contentEditable?: bool,\n contextMenu?: string,\n dataTestId?: string,\n dir?: string,\n draggable?: bool,\n hidden?: bool,\n id?: string,\n inert?: bool,\n lang?: string,\n popover?: popover,\n popoverTarget?: string,\n popoverTargetAction?: popoverTargetAction,\n role?: string,\n style?: style,\n spellCheck?: bool,\n tabIndex?: int,\n title?: string,\n itemID?: string,\n itemProp?: string,\n itemRef?: string,\n itemScope?: bool,\n itemType?: string,\n accept?: string,\n acceptCharset?: string,\n action?: string,\n allowFullScreen?: bool,\n alt?: string,\n as_?: string,\n async?: bool,\n autoComplete?: string,\n autoCapitalize?: string,\n autoFocus?: bool,\n autoPlay?: bool,\n challenge?: string,\n charSet?: string,\n checked?: bool,\n cite?: string,\n crossOrigin?: string,\n cols?: int,\n colSpan?: int,\n content?: string,\n controls?: bool,\n coords?: string,\n data?: string,\n dateTime?: string,\n default?: bool,\n defer?: bool,\n disabled?: bool,\n download?: string,\n encType?: string,\n form?: string,\n formAction?: string,\n formTarget?: string,\n formMethod?: string,\n frameBorder?: int,\n headers?: string,\n height?: string,\n high?: int,\n href?: string,\n hrefLang?: string,\n htmlFor?: string,\n httpEquiv?: string,\n icon?: string,\n inputMode?: string,\n integrity?: string,\n keyType?: string,\n kind?: string,\n label?: string,\n list?: string,\n loading?: [#eager | #lazy],\n loop?: bool,\n low?: int,\n manifest?: string,\n max?: string,\n maxLength?: int,\n media?: string,\n mediaGroup?: string,\n method?: string,\n min?: string,\n minLength?: int,\n multiple?: bool,\n muted?: bool,\n name?: string,\n nonce?: string,\n noValidate?: bool,\n open_?: bool,\n optimum?: int,\n pattern?: string,\n placeholder?: string,\n playsInline?: bool,\n poster?: string,\n preload?: string,\n radioGroup?: string,\n readOnly?: bool,\n rel?: string,\n required?: bool,\n reversed?: bool,\n rows?: int,\n rowSpan?: int,\n sandbox?: string,\n scope?: string,\n scoped?: bool,\n scrolling?: string,\n selected?: bool,\n shape?: string,\n size?: int,\n sizes?: string,\n span?: int,\n src?: string,\n srcDoc?: string,\n srcLang?: string,\n srcSet?: string,\n start?: int,\n step?: float,\n summary?: string,\n target?: string,\n type_?: string,\n useMap?: string,\n value?: string,\n width?: string,\n wrap?: string,\n onCopy?: JsxEvent.Clipboard.t => unit,\n onCut?: JsxEvent.Clipboard.t => unit,\n onPaste?: JsxEvent.Clipboard.t => unit,\n onCompositionEnd?: JsxEvent.Composition.t => unit,\n onCompositionStart?: JsxEvent.Composition.t => unit,\n onCompositionUpdate?: JsxEvent.Composition.t => unit,\n onKeyDown?: JsxEvent.Keyboard.t => unit,\n onKeyPress?: JsxEvent.Keyboard.t => unit,\n onKeyUp?: JsxEvent.Keyboard.t => unit,\n onFocus?: JsxEvent.Focus.t => unit,\n onBlur?: JsxEvent.Focus.t => unit,\n onBeforeInput?: JsxEvent.Form.t => unit,\n onChange?: JsxEvent.Form.t => unit,\n onInput?: JsxEvent.Form.t => unit,\n onReset?: JsxEvent.Form.t => unit,\n onSubmit?: JsxEvent.Form.t => unit,\n onInvalid?: JsxEvent.Form.t => unit,\n onClick?: JsxEvent.Mouse.t => unit,\n onContextMenu?: JsxEvent.Mouse.t => unit,\n onDoubleClick?: JsxEvent.Mouse.t => unit,\n onDrag?: JsxEvent.Mouse.t => unit,\n onDragEnd?: JsxEvent.Mouse.t => unit,\n onDragEnter?: JsxEvent.Mouse.t => unit,\n onDragExit?: JsxEvent.Mouse.t => unit,\n onDragLeave?: JsxEvent.Mouse.t => unit,\n onDragOver?: JsxEvent.Mouse.t => unit,\n onDragStart?: JsxEvent.Mouse.t => unit,\n onDrop?: JsxEvent.Mouse.t => unit,\n onMouseDown?: JsxEvent.Mouse.t => unit,\n onMouseEnter?: JsxEvent.Mouse.t => unit,\n onMouseLeave?: JsxEvent.Mouse.t => unit,\n onMouseMove?: JsxEvent.Mouse.t => unit,\n onMouseOut?: JsxEvent.Mouse.t => unit,\n onMouseOver?: JsxEvent.Mouse.t => unit,\n onMouseUp?: JsxEvent.Mouse.t => unit,\n onSelect?: JsxEvent.Selection.t => unit,\n onTouchCancel?: JsxEvent.Touch.t => unit,\n onTouchEnd?: JsxEvent.Touch.t => unit,\n onTouchMove?: JsxEvent.Touch.t => unit,\n onTouchStart?: JsxEvent.Touch.t => unit,\n onPointerOver?: JsxEvent.Pointer.t => unit,\n onPointerEnter?: JsxEvent.Pointer.t => unit,\n onPointerDown?: JsxEvent.Pointer.t => unit,\n onPointerMove?: JsxEvent.Pointer.t => unit,\n onPointerUp?: JsxEvent.Pointer.t => unit,\n onPointerCancel?: JsxEvent.Pointer.t => unit,\n onPointerOut?: JsxEvent.Pointer.t => unit,\n onPointerLeave?: JsxEvent.Pointer.t => unit,\n onGotPointerCapture?: JsxEvent.Pointer.t => unit,\n onLostPointerCapture?: JsxEvent.Pointer.t => unit,\n onScroll?: JsxEvent.UI.t => unit,\n onWheel?: JsxEvent.Wheel.t => unit,\n onAbort?: JsxEvent.Media.t => unit,\n onCanPlay?: JsxEvent.Media.t => unit,\n onCanPlayThrough?: JsxEvent.Media.t => unit,\n onDurationChange?: JsxEvent.Media.t => unit,\n onEmptied?: JsxEvent.Media.t => unit,\n onEncrypted?: JsxEvent.Media.t => unit,\n onEnded?: JsxEvent.Media.t => unit,\n onError?: JsxEvent.Media.t => unit,\n onLoadedData?: JsxEvent.Media.t => unit,\n onLoadedMetadata?: JsxEvent.Media.t => unit,\n onLoadStart?: JsxEvent.Media.t => unit,\n onPause?: JsxEvent.Media.t => unit,\n onPlay?: JsxEvent.Media.t => unit,\n onPlaying?: JsxEvent.Media.t => unit,\n onProgress?: JsxEvent.Media.t => unit,\n onRateChange?: JsxEvent.Media.t => unit,\n onSeeked?: JsxEvent.Media.t => unit,\n onSeeking?: JsxEvent.Media.t => unit,\n onStalled?: JsxEvent.Media.t => unit,\n onSuspend?: JsxEvent.Media.t => unit,\n onTimeUpdate?: JsxEvent.Media.t => unit,\n onVolumeChange?: JsxEvent.Media.t => unit,\n onWaiting?: JsxEvent.Media.t => unit,\n onLoad?: JsxEvent.Image.t => unit,\n onAnimationStart?: JsxEvent.Animation.t => unit,\n onAnimationEnd?: JsxEvent.Animation.t => unit,\n onAnimationIteration?: JsxEvent.Animation.t => unit,\n onTransitionEnd?: JsxEvent.Transition.t => unit,\n accentHeight?: string,\n accumulate?: string,\n additive?: string,\n alignmentBaseline?: string,\n allowReorder?: string,\n alphabetic?: string,\n amplitude?: string,\n arabicForm?: string,\n ascent?: string,\n attributeName?: string,\n attributeType?: string,\n autoReverse?: string,\n azimuth?: string,\n baseFrequency?: string,\n baseProfile?: string,\n baselineShift?: string,\n bbox?: string,\n begin?: string,\n begin_?: string,\n bias?: string,\n by?: string,\n calcMode?: string,\n capHeight?: string,\n clip?: string,\n clipPath?: string,\n clipPathUnits?: string,\n clipRule?: string,\n colorInterpolation?: string,\n colorInterpolationFilters?: string,\n colorProfile?: string,\n colorRendering?: string,\n contentScriptType?: string,\n contentStyleType?: string,\n cursor?: string,\n cx?: string,\n cy?: string,\n d?: string,\n decelerate?: string,\n descent?: string,\n diffuseConstant?: string,\n direction?: string,\n display?: string,\n divisor?: string,\n dominantBaseline?: string,\n dur?: string,\n dx?: string,\n dy?: string,\n edgeMode?: string,\n elevation?: string,\n enableBackground?: string,\n end?: string,\n end_?: string,\n exponent?: string,\n externalResourcesRequired?: string,\n fill?: string,\n fillOpacity?: string,\n fillRule?: string,\n filter?: string,\n filterRes?: string,\n filterUnits?: string,\n floodColor?: string,\n floodOpacity?: string,\n focusable?: string,\n fontFamily?: string,\n fontSize?: string,\n fontSizeAdjust?: string,\n fontStretch?: string,\n fontStyle?: string,\n fontVariant?: string,\n fontWeight?: string,\n fomat?: string,\n from?: string,\n fx?: string,\n fy?: string,\n g1?: string,\n g2?: string,\n glyphName?: string,\n glyphOrientationHorizontal?: string,\n glyphOrientationVertical?: string,\n glyphRef?: string,\n gradientTransform?: string,\n gradientUnits?: string,\n hanging?: string,\n horizAdvX?: string,\n horizOriginX?: string,\n ideographic?: string,\n imageRendering?: string,\n in_?: string,\n in2?: string,\n intercept?: string,\n k?: string,\n k1?: string,\n k2?: string,\n k3?: string,\n k4?: string,\n kernelMatrix?: string,\n kernelUnitLength?: string,\n kerning?: string,\n keyPoints?: string,\n keySplines?: string,\n keyTimes?: string,\n lengthAdjust?: string,\n letterSpacing?: string,\n lightingColor?: string,\n limitingConeAngle?: string,\n local?: string,\n markerEnd?: string,\n markerHeight?: string,\n markerMid?: string,\n markerStart?: string,\n markerUnits?: string,\n markerWidth?: string,\n mask?: string,\n maskContentUnits?: string,\n maskUnits?: string,\n mathematical?: string,\n mode?: string,\n numOctaves?: string,\n offset?: string,\n opacity?: string,\n operator?: string,\n order?: string,\n orient?: string,\n orientation?: string,\n origin?: string,\n overflow?: string,\n overflowX?: string,\n overflowY?: string,\n overlinePosition?: string,\n overlineThickness?: string,\n paintOrder?: string,\n panose1?: string,\n pathLength?: string,\n patternContentUnits?: string,\n patternTransform?: string,\n patternUnits?: string,\n pointerEvents?: string,\n points?: string,\n pointsAtX?: string,\n pointsAtY?: string,\n pointsAtZ?: string,\n preserveAlpha?: string,\n preserveAspectRatio?: string,\n primitiveUnits?: string,\n r?: string,\n radius?: string,\n referrerPolicy?: string,\n refX?: string,\n refY?: string,\n renderingIntent?: string,\n repeatCount?: string,\n repeatDur?: string,\n requiredExtensions?: string,\n requiredFeatures?: string,\n restart?: string,\n result?: string,\n rotate?: string,\n rx?: string,\n ry?: string,\n scale?: string,\n seed?: string,\n shapeRendering?: string,\n slope?: string,\n slot?: string,\n spacing?: string,\n specularConstant?: string,\n specularExponent?: string,\n speed?: string,\n spreadMethod?: string,\n startOffset?: string,\n stdDeviation?: string,\n stemh?: string,\n stemv?: string,\n stitchTiles?: string,\n stopColor?: string,\n stopOpacity?: string,\n strikethroughPosition?: string,\n strikethroughThickness?: string,\n string?: string,\n stroke?: string,\n strokeDasharray?: string,\n strokeDashoffset?: string,\n strokeLinecap?: string,\n strokeLinejoin?: string,\n strokeMiterlimit?: string,\n strokeOpacity?: string,\n strokeWidth?: string,\n surfaceScale?: string,\n systemLanguage?: string,\n tableValues?: string,\n targetX?: string,\n targetY?: string,\n textAnchor?: string,\n textDecoration?: string,\n textLength?: string,\n textRendering?: string,\n to?: string,\n to_?: string,\n transform?: string,\n u1?: string,\n u2?: string,\n underlinePosition?: string,\n underlineThickness?: string,\n unicode?: string,\n unicodeBidi?: string,\n unicodeRange?: string,\n unitsPerEm?: string,\n vAlphabetic?: string,\n vHanging?: string,\n vIdeographic?: string,\n vMathematical?: string,\n values?: string,\n vectorEffect?: string,\n version?: string,\n vertAdvX?: string,\n vertAdvY?: string,\n vertOriginX?: string,\n vertOriginY?: string,\n viewBox?: string,\n viewTarget?: string,\n visibility?: string,\n widths?: string,\n wordSpacing?: string,\n writingMode?: string,\n x?: string,\n x1?: string,\n x2?: string,\n xChannelSelector?: string,\n xHeight?: string,\n xlinkActuate?: string,\n xlinkArcrole?: string,\n xlinkHref?: string,\n xlinkRole?: string,\n xlinkShow?: string,\n xlinkTitle?: string,\n xlinkType?: string,\n xmlns?: string,\n xmlnsXlink?: string,\n xmlBase?: string,\n xmlLang?: string,\n xmlSpace?: string,\n y?: string,\n y1?: string,\n y2?: string,\n yChannelSelector?: string,\n z?: string,\n zoomAndPan?: string,\n about?: string,\n datatype?: string,\n inlist?: string,\n prefix?: string,\n property?: string,\n resource?: string,\n typeof?: string,\n vocab?: string,\n dangerouslySetInnerHTML?: {\"__html\": string},\n suppressContentEditableWarning?: bool,\n}\n```\n\n\n---\n```rescript\ntype Jsx.element\n```\n\n\n---\n```rescript\ntype domRef\n```\n\n\n---\n```rescript\ntype popover =\n | @as(\"auto\") Auto\n | @as(\"manual\") Manual\n | @as(\"hint\") Hint\n```\n\n\n---\n```rescript\ntype popoverTargetAction =\n | @as(\"toggle\") Toggle\n | @as(\"show\") Show\n | @as(\"hide\") Hide\n```\n\n\n---\n```rescript\ntype style = JsxDOMStyle.t\n```\n\n\n---\n```rescript\ntype JsxEvent.Clipboard.t = synthetic\n```\n\n\n---\n```rescript\ntype JsxEvent.Composition.t = synthetic\n```\n\n\n---\n```rescript\ntype JsxEvent.Keyboard.t = synthetic\n```\n\n\n---\n```rescript\ntype JsxEvent.Focus.t = synthetic\n```\n\n\n---\n```rescript\ntype JsxEvent.Form.t = synthetic\n```\n\n\n---\n```rescript\ntype JsxEvent.Mouse.t = synthetic\n```\n\n\n---\n```rescript\ntype JsxEvent.Selection.t = synthetic\n```\n\n\n---\n```rescript\ntype JsxEvent.Touch.t = synthetic\n```\n\n\n---\n```rescript\ntype JsxEvent.Pointer.t = synthetic\n```\n\n\n---\n```rescript\ntype JsxEvent.UI.t = synthetic\n```\n\n\n---\n```rescript\ntype JsxEvent.Wheel.t = synthetic\n```\n\n\n---\n```rescript\ntype JsxEvent.Media.t = synthetic\n```\n\n\n---\n```rescript\ntype JsxEvent.Image.t = synthetic\n```\n\n\n---\n```rescript\ntype JsxEvent.Animation.t = synthetic\n```\n\n\n---\n```rescript\ntype JsxEvent.Transition.t = synthetic\n```\n" + } + } + |}] + +let%expect_test "shows a JSX component with documentation and optional props" = + test_jsx_hover ~position:(6, 9) + ~text: + {|module M4 = { + /** Doc Comment For M4 */ + @react.component + let make = (~first, ~fun="", ~second="") => React.string(first ++ fun ++ second) +} + +let _ = +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nReact.component>\n```\n---\n Doc Comment For M4 \n\n---\n```rescript\ntype React.component<'props> = Jsx.component<'props>\n```\n\n\n---\n```rescript\ntype M4.props<'first, 'fun, 'second> = {\n first: 'first,\n fun?: 'fun,\n second?: 'second,\n}\n```\n" + } + } + |}] + +let fragment_component = + {|module SectionHeader = { + @react.component + let make = (~children) => children +} + +let z = <> {React.string("abc")} +|} + +let%expect_test "shows a component opening tag inside a JSX fragment" = + test_jsx_hover ~text:fragment_component ~position:(5, 18); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nReact.component>\n```\n\n---\n```rescript\ntype React.component<'props> = Jsx.component<'props>\n```\n\n\n---\n```rescript\ntype SectionHeader.props<'children> = {children: 'children}\n```\n\n\n---\n```rescript\ntype React.element = Jsx.element\n```\n" + } + } + |}] + +let%expect_test "shows hover at a component closing tag inside a JSX fragment" = + test_jsx_hover ~text:fragment_component ~position:(5, 55); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nReact.component\n```\n\n---\n```rescript\ntype React.component<'props> = Jsx.component<'props>\n```\n\n\n---\n```rescript\ntype React.fragmentProps = {children?: element}\n```\n" + } + } + |}] + +let%expect_test "shows a component prop inside a JSX fragment" = + test_jsx_hover ~position:(6, 12) + ~text: + {|module Comp = { + @react.component + let make = (~age) => React.int(age) +} + +let _ = { + <> +} +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nComp.props\n```\n\n---\n```rescript\ntype Comp.props<'age> = {age: 'age}\n```\n" + } + } + |}] + +let%expect_test "shows a component prop inside nested JSX fragments" = + test_jsx_hover ~position:(6, 16) + ~text: + {|module Comp = { + @react.component + let make = (~age) => React.int(age) +} + +let _ = { + <> {<> } +} +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nComp.props\n```\n\n---\n```rescript\ntype Comp.props<'age> = {age: 'age}\n```\n" + } + } + |}] + +let children_component = + {|module Comp = { + @react.component + let make = (~children: React.element) => children +} + +module Comp1 = Comp + +let _ = + +
+
+ + +let _ = + +
+
+ +|} + +let%expect_test + "returns no hover after a JSX closing tag with multiple children" = + test_jsx_hover ~text:children_component ~position:(11, 10); + [%expect {| |}] + +let%expect_test "returns no hover after an aliased JSX component closing tag" = + test_jsx_hover ~text:children_component ~position:(17, 10); + [%expect {| |}] + +let%expect_test "infers optional props of a function returning a React element" + = + test_jsx_hover ~position:(8, 4) + ~text: + {|module CompV4 = { + type props<'n, 's> = {n?: 'n, s: 's} + let make = props => { + let _ = props.n == Some(10) + React.string(props.s) + } +} + +let mk = CompV4.make +|}; + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nCompV4.props => React.element\n```\n\n---\n```rescript\ntype CompV4.props<'n, 's> = {n?: 'n, s: 's}\n```\n\n\n---\n```rescript\ntype React.element = Jsx.element\n```\n" + } + } + |}] + +let component_implementation = + {|@react.component +let make = (~first) => React.string(first) +let y = 1 +|} + +let component_interface = + {|@react.component +let make: (~first: string) => React.element + +let y: int +|} + +let%expect_test "shows a React component signature in an interface" = + test_jsx_interface_hover ~text:component_implementation + ~interface:component_interface ~position:(1, 4); + [%expect + {| + { + "contents": { + "kind": "markdown", + "value": "```rescript\nprops\n```\n\n---\n```rescript\ntype props<'first> = {first: 'first}\n```\n" + } + } + |}] + +let%expect_test + "shows a value signature next to a React component in an interface" = + test_jsx_interface_hover ~text:component_implementation + ~interface:component_interface ~position:(3, 4); + [%expect + {| { "contents": { "kind": "markdown", "value": "```rescript\nint\n```" } } |}] diff --git a/tests/lsp_tests/inlay_hint.ml b/tests/lsp_tests/inlay_hint.ml new file mode 100644 index 00000000000..31e5a0ec122 --- /dev/null +++ b/tests/lsp_tests/inlay_hint.ml @@ -0,0 +1,170 @@ +open Lsp +open Types + +let hints doc start end_ = + Client_request.InlayHint + (InlayHintParams.create ~textDocument:(Test.text_document doc) + ~range:(Test.range start end_) ()) + +let enable client = + Test.configure client + (`Assoc [("inlayHints", `Assoc [("enable", `Bool true)])]) + +let%expect_test "shows inferred dictionary and dictionary pattern types" = + Test.with_project ~name:"inlay-hint" + ~text: + {|let ints = dict{"a": 1} +let spread = dict{...ints, "b": 2} +let dict{"a": ?first} = ints +|} + (fun ~root ~doc client -> + enable client; + Test.snapshot ~root client (hints doc (0, 0) (3, 0))); + [%expect + {| + [ + { + "kind": 1, + "label": ": option", + "paddingLeft": true, + "paddingRight": false, + "position": { "character": 20, "line": 2 } + }, + { + "kind": 1, + "label": ": dict", + "paddingLeft": true, + "paddingRight": false, + "position": { "character": 10, "line": 1 } + }, + { + "kind": 1, + "label": ": dict", + "paddingLeft": true, + "paddingRight": false, + "position": { "character": 8, "line": 0 } + } + ] + |}] + +let%expect_test "shows inferred plain and tagged template types" = + Test.with_project ~name:"inlay-hint" + ~text: + {|@module("tag") +external tag: taggedTemplate = "default" +let value = "x" +let template = `value: ${value}` +let tagged = tag`value: ${value}` +|} + (fun ~root ~doc client -> + enable client; + Test.snapshot ~root client (hints doc (0, 0) (5, 0))); + [%expect + {| + [ + { + "kind": 1, + "label": ": string", + "paddingLeft": true, + "paddingRight": false, + "position": { "character": 10, "line": 4 } + }, + { + "kind": 1, + "label": ": string", + "paddingLeft": true, + "paddingRight": false, + "position": { "character": 12, "line": 3 } + }, + { + "kind": 1, + "label": ": string", + "paddingLeft": true, + "paddingRight": false, + "position": { "character": 9, "line": 2 } + } + ] + |}] + +let%expect_test "omits generated React component bindings from inlay hints" = + Test.with_project ~name:"inlay-hint-jsx" ~jsx:true + ~text:{|@react.component +let make = (~name) => React.string(name) +|} + (fun ~root ~doc client -> + enable client; + Test.snapshot ~root client (hints doc (0, 0) (2, 0))); + [%expect {| [] |}] + +let%expect_test "does not return inlay hints when disabled" = + Test.with_project ~name:"inlay-hint" ~text:{|let add = (x, y) => x + y +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (hints doc (0, 0) (1, 0))); + [%expect {| null |}] + +let%expect_test "shows inferred function and destructured record types" = + Test.with_project ~name:"inlay-hint" + ~text: + {|let add = (x, y) => x + y +type foo = {name: string, age: int} +let alice = {name: "Alice", age: 42} +let {name, age} = alice +|} + (fun ~root ~doc client -> + enable client; + Test.snapshot ~root client (hints doc (0, 0) (4, 0))); + [%expect + {| + [ + { + "kind": 1, + "label": ": int", + "paddingLeft": true, + "paddingRight": false, + "position": { "character": 14, "line": 3 } + }, + { + "kind": 1, + "label": ": string", + "paddingLeft": true, + "paddingRight": false, + "position": { "character": 9, "line": 3 } + }, + { + "kind": 1, + "label": ": foo", + "paddingLeft": true, + "paddingRight": false, + "position": { "character": 9, "line": 2 } + }, + { + "kind": 1, + "label": ": (int, int) => int", + "paddingLeft": true, + "paddingRight": false, + "position": { "character": 7, "line": 0 } + } + ] + |}] + +let%expect_test "restricts inlay hints to the requested lines" = + Test.with_project ~name:"inlay-hint" + ~text:{|let add = (x, y) => x + y +let withAs = (~xx as yyy) => yyy + 1 +|} + (fun ~root ~doc client -> + enable client; + Test.snapshot ~root client (hints doc (1, 0) (1, 35))); + [%expect + {| + [ + { + "kind": 1, + "label": ": (~xx: int) => int", + "paddingLeft": true, + "paddingRight": false, + "position": { "character": 10, "line": 1 } + } + ] + |}] diff --git a/tests/lsp_tests/prepare_rename.ml b/tests/lsp_tests/prepare_rename.ml new file mode 100644 index 00000000000..0322255ee13 --- /dev/null +++ b/tests/lsp_tests/prepare_rename.ml @@ -0,0 +1,41 @@ +open Lsp +open Types + +let prepare_rename doc pos = + Client_request.TextDocumentPrepareRename + (PrepareRenameParams.create ~textDocument:(Test.text_document doc) + ~position:(Test.position pos) ()) + +let%expect_test "selects the identifier range for renaming" = + Test.with_project ~name:"prepare-rename" ~text:{|let x = 12 +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (prepare_rename doc (0, 4))); + [%expect + {| + { + "end": { "character": 5, "line": 0 }, + "start": { "character": 4, "line": 0 } + } + |}] + +let%expect_test "selects a labeled parameter use for renaming" = + Test.with_project ~name:"prepare-rename" ~text:{|let foo = (~xx) => xx + 1 +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (prepare_rename doc (0, 19))); + [%expect + {| + { + "end": { "character": 21, "line": 0 }, + "start": { "character": 19, "line": 0 } + } + |}] + +let%expect_test "does not prepare a rename inside a comment" = + Test.with_project ~name:"prepare-rename" ~text:{|let x = 12 +// x +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (prepare_rename doc (1, 3))); + [%expect {| null |}] diff --git a/tests/lsp_tests/references.ml b/tests/lsp_tests/references.ml new file mode 100644 index 00000000000..a51138bd48d --- /dev/null +++ b/tests/lsp_tests/references.ml @@ -0,0 +1,201 @@ +open Lsp +open Types + +let references doc pos = + Client_request.TextDocumentReferences + (ReferenceParams.create ~textDocument:(Test.text_document doc) + ~position:(Test.position pos) + ~context:(ReferenceContext.create ~includeDeclaration:true) + ()) + +let%expect_test "finds uses through a module alias in another file" = + Test.with_project ~name:"references" ~text:{|let x = 12 +|} + ~files: + [ + ( "Consumer.res", + {|module Ref = Test +let first = Ref.x +let second = Test.x +|} ); + ] + (fun ~root ~doc client -> + Test.snapshot ~root client (references doc (0, 4))); + [%expect + {| + [ + { + "range": { + "end": { "character": 17, "line": 1 }, + "start": { "character": 16, "line": 1 } + }, + "uri": "file:///workspace/./Consumer.res" + }, + { + "range": { + "end": { "character": 19, "line": 2 }, + "start": { "character": 18, "line": 2 } + }, + "uri": "file:///workspace/./Consumer.res" + }, + { + "range": { + "end": { "character": 5, "line": 0 }, + "start": { "character": 4, "line": 0 } + }, + "uri": "file:///workspace/Test.res" + } + ] + |}] + +(* TODO: Generated component bindings currently repeat the declaration range. *) +let%expect_test "finds references to a nested React component from JSX" = + Test.with_project ~name:"references-jsx" ~jsx:true + ~text: + {|module Comp = { + @react.component + let make = () => React.null +} +let first = +let second = +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (references doc (4, 14))); + [%expect + {| + [ + { + "range": { + "end": { "character": 10, "line": 2 }, + "start": { "character": 6, "line": 2 } + }, + "uri": "file:///workspace/Test.res" + }, + { + "range": { + "end": { "character": 10, "line": 2 }, + "start": { "character": 6, "line": 2 } + }, + "uri": "file:///workspace/Test.res" + }, + { + "range": { + "end": { "character": 10, "line": 2 }, + "start": { "character": 6, "line": 2 } + }, + "uri": "file:///workspace/Test.res" + }, + { + "range": { + "end": { "character": 17, "line": 4 }, + "start": { "character": 13, "line": 4 } + }, + "uri": "file:///workspace/Test.res" + }, + { + "range": { + "end": { "character": 18, "line": 5 }, + "start": { "character": 14, "line": 5 } + }, + "uri": "file:///workspace/Test.res" + } + ] + |}] + +let%expect_test "finds the declaration and local uses of a value" = + Test.with_project ~name:"references" + ~text:{|let x = 12 +let a = x +let b = a +let c = x +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (references doc (0, 4))); + [%expect + {| + [ + { + "range": { + "end": { "character": 5, "line": 0 }, + "start": { "character": 4, "line": 0 } + }, + "uri": "file:///workspace/Test.res" + }, + { + "range": { + "end": { "character": 9, "line": 1 }, + "start": { "character": 8, "line": 1 } + }, + "uri": "file:///workspace/Test.res" + }, + { + "range": { + "end": { "character": 9, "line": 3 }, + "start": { "character": 8, "line": 3 } + }, + "uri": "file:///workspace/Test.res" + } + ] + |}] + +let%expect_test "finds a labeled parameter and its use" = + Test.with_project ~name:"references" ~text:{|let foo = (~xx) => xx + 1 +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (references doc (0, 19))); + [%expect + {| + [ + { + "range": { + "end": { "character": 14, "line": 0 }, + "start": { "character": 11, "line": 0 } + }, + "uri": "file:///workspace/Test.res" + }, + { + "range": { + "end": { "character": 21, "line": 0 }, + "start": { "character": 19, "line": 0 } + }, + "uri": "file:///workspace/Test.res" + } + ] + |}] + +let%expect_test "finds references across implementation interface and consumer" + = + Test.with_project ~name:"references" ~text:{|let x = 12 +|} + ~files: + [("Test.resi", {|let x: int +|}); ("Consumer.res", {|let a = Test.x +|})] + (fun ~root ~doc client -> + Test.snapshot ~root client (references doc (0, 4))); + [%expect + {| + [ + { + "range": { + "end": { "character": 14, "line": 0 }, + "start": { "character": 13, "line": 0 } + }, + "uri": "file:///workspace/./Consumer.res" + }, + { + "range": { + "end": { "character": 5, "line": 0 }, + "start": { "character": 4, "line": 0 } + }, + "uri": "file:///workspace/./Test.resi" + }, + { + "range": { + "end": { "character": 5, "line": 0 }, + "start": { "character": 4, "line": 0 } + }, + "uri": "file:///workspace/Test.res" + } + ] + |}] diff --git a/tests/lsp_tests/rename.ml b/tests/lsp_tests/rename.ml new file mode 100644 index 00000000000..5924bf8479a --- /dev/null +++ b/tests/lsp_tests/rename.ml @@ -0,0 +1,259 @@ +open Lsp +open Types + +let rename doc pos new_name = + Client_request.TextDocumentRename + (RenameParams.create ~textDocument:(Test.text_document doc) + ~position:(Test.position pos) ~newName:new_name ()) + +(* TODO: The newUri values are currently double encoded. This snapshot records + the defect rather than normalizing it into a valid destination URI. *) +let%expect_test "records file renames and qualified edits for a source module" = + Test.with_project ~name:"rename" + ~text:{|let first = Other.x +let second = Other.x +|} + ~files:[("Other.res", {|let x = 12 +|}); ("Other.resi", {|let x: int +|})] + (fun ~root ~doc client -> + Test.snapshot ~root client (rename doc (0, 13) "Renamed")); + [%expect + {| + { + "documentChanges": [ + { + "kind": "rename", + "newUri": "file:///file%3A///workspace/./Renamed.resi", + "oldUri": "file:///workspace/./Other.resi" + }, + { + "kind": "rename", + "newUri": "file:///file%3A///workspace/./Renamed.res", + "oldUri": "file:///workspace/./Other.res" + }, + { + "edits": [ + { + "newText": "Renamed", + "range": { + "end": { "character": 17, "line": 0 }, + "start": { "character": 12, "line": 0 } + } + }, + { + "newText": "Renamed", + "range": { + "end": { "character": 18, "line": 1 }, + "start": { "character": 13, "line": 1 } + } + } + ], + "textDocument": { "uri": "file:///workspace/Test.res", "version": 0 } + } + ] + } + |}] + +let%expect_test "renames a pattern binding without renaming the record field" = + Test.with_project ~name:"rename" + ~text: + {|type person = {name: string} +let person = {name: "Alice"} +let {name: localName} = person +let greeting = localName +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (rename doc (2, 15) "displayName")); + [%expect + {| + { + "documentChanges": [ + { + "edits": [ + { + "newText": "displayName", + "range": { + "end": { "character": 20, "line": 2 }, + "start": { "character": 11, "line": 2 } + } + }, + { + "newText": "displayName", + "range": { + "end": { "character": 24, "line": 3 }, + "start": { "character": 15, "line": 3 } + } + } + ], + "textDocument": { "uri": "file:///workspace/Test.res", "version": 0 } + } + ] + } + |}] + +(* TODO: Renaming the module currently omits its JSX tag references. *) +let%expect_test + "records current rename edits for a component module used in JSX" = + Test.with_project ~name:"rename-jsx" ~jsx:true + ~text: + {|module Comp = { + @react.component + let make = () => React.null +} +let first = +let second = +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (rename doc (0, 8) "Panel")); + [%expect + {| + { + "documentChanges": [ + { + "edits": [ + { + "newText": "Panel", + "range": { + "end": { "character": 11, "line": 0 }, + "start": { "character": 7, "line": 0 } + } + } + ], + "textDocument": { "uri": "file:///workspace/Test.res", "version": 0 } + } + ] + } + |}] + +let%expect_test "renames a value and all local uses" = + Test.with_project ~name:"rename" ~text:{|let x = 12 +let a = x +let c = x +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (rename doc (0, 4) "y")); + [%expect + {| + { + "documentChanges": [ + { + "edits": [ + { + "newText": "y", + "range": { + "end": { "character": 5, "line": 0 }, + "start": { "character": 4, "line": 0 } + } + }, + { + "newText": "y", + "range": { + "end": { "character": 9, "line": 1 }, + "start": { "character": 8, "line": 1 } + } + }, + { + "newText": "y", + "range": { + "end": { "character": 9, "line": 2 }, + "start": { "character": 8, "line": 2 } + } + } + ], + "textDocument": { "uri": "file:///workspace/Test.res", "version": 0 } + } + ] + } + |}] + +let%expect_test "renames a labeled parameter and its use" = + Test.with_project ~name:"rename" ~text:{|let foo = (~xx) => xx + 1 +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (rename doc (0, 19) "yy")); + [%expect + {| + { + "documentChanges": [ + { + "edits": [ + { + "newText": "yy", + "range": { + "end": { "character": 14, "line": 0 }, + "start": { "character": 11, "line": 0 } + } + }, + { + "newText": "yy", + "range": { + "end": { "character": 21, "line": 0 }, + "start": { "character": 19, "line": 0 } + } + } + ], + "textDocument": { "uri": "file:///workspace/Test.res", "version": 0 } + } + ] + } + |}] + +let%expect_test "renames an export in its interface and consumer" = + Test.with_project ~name:"rename" ~text:{|let x = 12 +|} + ~files: + [("Test.resi", {|let x: int +|}); ("Consumer.res", {|let a = Test.x +|})] + (fun ~root ~doc client -> + Test.snapshot ~root client (rename doc (0, 4) "y")); + [%expect + {| + { + "documentChanges": [ + { + "edits": [ + { + "newText": "y", + "range": { + "end": { "character": 5, "line": 0 }, + "start": { "character": 4, "line": 0 } + } + } + ], + "textDocument": { "uri": "file:///workspace/Test.res", "version": 0 } + }, + { + "edits": [ + { + "newText": "y", + "range": { + "end": { "character": 5, "line": 0 }, + "start": { "character": 4, "line": 0 } + } + } + ], + "textDocument": { + "uri": "file:///workspace/./Test.resi", + "version": 0 + } + }, + { + "edits": [ + { + "newText": "y", + "range": { + "end": { "character": 14, "line": 0 }, + "start": { "character": 13, "line": 0 } + } + } + ], + "textDocument": { + "uri": "file:///workspace/./Consumer.res", + "version": 0 + } + } + ] + } + |}] diff --git a/tests/lsp_tests/semantic_tokens.ml b/tests/lsp_tests/semantic_tokens.ml new file mode 100644 index 00000000000..d72ab191cb9 --- /dev/null +++ b/tests/lsp_tests/semantic_tokens.ml @@ -0,0 +1,104 @@ +open Lsp +open Types + +let tokens doc = + Client_request.SemanticTokensFull + (SemanticTokensParams.create ~textDocument:(Test.text_document doc) ()) + +let%expect_test "encodes tokens inside a dictionary literal" = + Test.with_project ~name:"semantic-tokens" ~run_build:false + ~text:{|let d = dict{"foo": bar} +|} (fun ~root ~doc client -> + Test.snapshot ~root client (tokens doc)); + [%expect {| { "data": [ 0, 4, 1, 1, 0, 0, 16, 3, 1, 0 ] } |}] + +let%expect_test "encodes array access with literal and variable indices" = + Test.with_project ~name:"semantic-tokens" ~run_build:false + ~text:{|let x = xs[0] +let x = xs[index] +|} (fun ~root ~doc client -> + Test.snapshot ~root client (tokens doc)); + [%expect + {| + { + "data": [ + 0, 4, 1, 1, 0, 0, 4, 2, 1, 0, 1, 4, 1, 1, 0, 0, 4, 2, 1, 0, 0, 3, 5, 1, 0 + ] + } + |}] + +let%expect_test "encodes array mutation" = + Test.with_project ~name:"semantic-tokens" ~run_build:false + ~text:{|xs[0] = 42 +|} (fun ~root ~doc client -> + Test.snapshot ~root client (tokens doc)); + [%expect {| { "data": [ 0, 0, 2, 1, 0 ] } |}] + +let%expect_test "encodes nested module types and aliased argument labels" = + Test.with_project ~name:"semantic-tokens" ~run_build:false + ~text: + {|module XX = { + module YY = { + type t = int + } +} +open XX.YY +type tt = t +let add = (~hello as x, ~world) => x + world +let _ = add(~hello=3, ...) +|} + (fun ~root ~doc client -> Test.snapshot ~root client (tokens doc)); + [%expect + {| + { + "data": [ + 0, 7, 2, 4, 0, 1, 9, 2, 4, 0, 1, 9, 1, 2, 0, 0, 4, 3, 2, 0, 3, 5, 2, 4, + 0, 0, 3, 2, 4, 0, 1, 5, 2, 2, 0, 0, 5, 1, 2, 0, 1, 4, 3, 1, 0, 0, 17, 1, + 1, 0, 0, 3, 6, 1, 0, 0, 11, 1, 1, 0, 0, 4, 5, 1, 0, 1, 8, 3, 1, 0 + ] + } + |}] + +let%expect_test "encodes intrinsic and nested component JSX tags" = + Test.with_project ~name:"semantic-tokens" ~run_build:false + ~text:{|let _ =
+|} + (fun ~root ~doc client -> Test.snapshot ~root client (tokens doc)); + [%expect + {| + { + "data": [ + 0, 8, 1, 3, 0, 0, 1, 3, 7, 0, 0, 15, 1, 3, 0, 0, 2, 1, 3, 0, 0, 1, 13, 4, + 0, 0, 14, 6, 4, 0, 0, 7, 1, 3, 0, 0, 3, 1, 3, 0, 0, 2, 3, 7, 0, 0, 3, 1, + 3, 0 + ] + } + |}] + +let%expect_test "encodes semantic tokens for array literals and spreads" = + Test.with_project ~name:"semantic-tokens" ~run_build:false + ~text:{|let xs = [1, 2] +let ys = [...xs, 3] +|} (fun ~root ~doc client -> + Test.snapshot ~root client (tokens doc)); + [%expect {| { "data": [ 0, 4, 2, 1, 0, 1, 4, 2, 1, 0, 0, 9, 2, 1, 0 ] } |}] + +let%expect_test "encodes semantic tokens for list literals and spreads" = + Test.with_project ~name:"semantic-tokens" ~run_build:false + ~text:{|let xs = list{1, 2} +let ys = list{...xs, 3} +|} + (fun ~root ~doc client -> Test.snapshot ~root client (tokens doc)); + [%expect {| { "data": [ 0, 4, 2, 1, 0, 1, 4, 2, 1, 0, 0, 13, 2, 1, 0 ] } |}] + +let%expect_test "clears semantic tokens when the buffer becomes empty" = + Test.with_project ~name:"semantic-tokens" ~run_build:false + ~text:{|let x = 1 +|} (fun ~root ~doc client -> + Test.snapshot ~root client (tokens doc); + Test.change_document client doc ~version:1 ~text:{||}; + Test.snapshot ~root client (tokens doc)); + [%expect {| + { "data": [ 0, 4, 1, 1, 0 ] } + { "data": [] } + |}] diff --git a/tests/lsp_tests/signature_help.ml b/tests/lsp_tests/signature_help.ml new file mode 100644 index 00000000000..050a3a54eff --- /dev/null +++ b/tests/lsp_tests/signature_help.ml @@ -0,0 +1,305 @@ +open Lsp +open Types + +let signature doc pos = + Client_request.SignatureHelp + (SignatureHelpParams.create ~textDocument:(Test.text_document doc) + ~position:(Test.position pos) ()) + +let%expect_test "selects an optional labeled argument" = + Test.with_project ~name:"signature-help" + ~text: + {|let someFunc = (one: int, ~two: option=?, ()) => { + ignore(one) + ignore(two) +} +let _ = someFunc(123, ~two="123", ()) +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (signature doc (4, 28))); + [%expect + {| + { + "activeParameter": 1, + "activeSignature": 0, + "signatures": [ + { + "activeParameter": 1, + "label": "(int, ~two: string=?, unit) => unit", + "parameters": [ + { + "documentation": { "kind": "markdown", "value": "" }, + "label": [ 1, 4 ] + }, + { + "documentation": { "kind": "markdown", "value": "" }, + "label": [ 7, 20 ] + }, + { + "documentation": { "kind": "markdown", "value": "" }, + "label": [ 22, 26 ] + } + ] + } + ] + } + |}] + +let%expect_test "accounts for the piped argument when selecting a parameter" = + Test.with_project ~name:"signature-help" + ~text: + {|let otherFunc = (first: string, second: int, third: float) => { + ignore(first) + ignore(second) + ignore(third) +} +let _ = "hello"->otherFunc(1, 2.0) +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (signature doc (5, 26))); + [%expect + {| + { + "activeParameter": 1, + "activeSignature": 0, + "signatures": [ + { + "activeParameter": 1, + "label": "(string, int, float) => unit", + "parameters": [ + { + "documentation": { "kind": "markdown", "value": "" }, + "label": [ 1, 7 ] + }, + { + "documentation": { "kind": "markdown", "value": "" }, + "label": [ 9, 12 ] + }, + { + "documentation": { "kind": "markdown", "value": "" }, + "label": [ 14, 19 ] + } + ] + } + ] + } + |}] + +let%expect_test "distinguishes a constructor with a single tuple payload" = + Test.with_project ~name:"signature-help" + ~text:{|type unary = Unary((int, string)) +let unary = Unary(1, "test") +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (signature doc (1, 22))); + [%expect + {| + { + "activeParameter": 0, + "activeSignature": 0, + "signatures": [ + { + "activeParameter": 0, + "label": "Unary((int, string))", + "parameters": [ + { + "documentation": { "kind": "markdown", "value": "" }, + "label": [ 6, 19 ] + } + ] + } + ] + } + |}] + +let%expect_test "shows constructor signature help in a destructuring pattern" = + Test.with_project ~name:"signature-help" + ~text: + {|type t = Pair(int, string) +let read = value => switch value { +| Pair(first, second) => second +} +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (signature doc (2, 15))); + [%expect + {| + { + "activeParameter": 1, + "activeSignature": 0, + "signatures": [ + { + "activeParameter": 1, + "label": "Pair(int, string)", + "parameters": [ + { + "documentation": { "kind": "markdown", "value": "" }, + "label": [ 5, 8 ] + }, + { + "documentation": { "kind": "markdown", "value": "" }, + "label": [ 10, 16 ] + } + ] + } + ] + } + |}] + +let%expect_test "selects the next constructor argument in the gap after a comma" + = + Test.with_project ~name:"signature-help" + ~text:{|type t = Three(string, array) +let value = Three("", []) +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (signature doc (1, 22))); + [%expect + {| + { + "activeParameter": 1, + "activeSignature": 0, + "signatures": [ + { + "activeParameter": 1, + "label": "Three(string, array)", + "parameters": [ + { + "documentation": { "kind": "markdown", "value": "" }, + "label": [ 6, 12 ] + }, + { + "documentation": { "kind": "markdown", "value": "" }, + "label": [ 14, 24 ] + } + ] + } + ] + } + |}] + +(* TODO: Recovery for an omitted constructor argument currently returns no + signature, unlike the whitespace gap before an existing argument above. *) +let%expect_test "returns no signature for a missing constructor payload" = + let text = + {|type t = Three(string, array) +let value = Three("", []) +|} + in + Test.with_project ~name:"signature-help" ~text (fun ~root ~doc client -> + Test.change_document client doc ~version:1 + ~text:(text ^ {|let next = Three("", )|}); + Test.snapshot ~root client (signature doc (2, 21))); + [%expect {| { "signatures": [] } |}] + +let function_source = + {|let otherFunc = (first: string, second: int, third: float) => { + ignore(first) + ignore(second) + ignore(third) +} +|} + +let%expect_test "shows the first parameter of an incomplete function call" = + Test.with_project ~name:"signature-help" ~text:function_source + (fun ~root ~doc client -> + Test.change_document client doc ~version:1 + ~text:(function_source ^ {|let _ = otherFunc(|}); + Test.snapshot ~root client (signature doc (5, 18))); + [%expect + {| + { + "activeParameter": 0, + "activeSignature": 0, + "signatures": [ + { + "activeParameter": 0, + "label": "(string, int, float) => unit", + "parameters": [ + { + "documentation": { "kind": "markdown", "value": "" }, + "label": [ 1, 7 ] + }, + { + "documentation": { "kind": "markdown", "value": "" }, + "label": [ 9, 12 ] + }, + { + "documentation": { "kind": "markdown", "value": "" }, + "label": [ 14, 19 ] + } + ] + } + ] + } + |}] + +let%expect_test "selects the second parameter after a comma" = + Test.with_project ~name:"signature-help" ~text:function_source + (fun ~root ~doc client -> + Test.change_document client doc ~version:1 + ~text:(function_source ^ {|let _ = otherFunc("123", 123, 123.0) +|}); + Test.snapshot ~root client (signature doc (5, 25))); + [%expect + {| + { + "activeParameter": 1, + "activeSignature": 0, + "signatures": [ + { + "activeParameter": 1, + "label": "(string, int, float) => unit", + "parameters": [ + { + "documentation": { "kind": "markdown", "value": "" }, + "label": [ 1, 7 ] + }, + { + "documentation": { "kind": "markdown", "value": "" }, + "label": [ 9, 12 ] + }, + { + "documentation": { "kind": "markdown", "value": "" }, + "label": [ 14, 19 ] + } + ] + } + ] + } + |}] + +let%expect_test "shows a documented constructor payload signature" = + Test.with_project ~name:"signature-help" + ~text:{|type t = + | /** Two is fun! */ Two(string) +let two = Two("true") +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (signature doc (2, 14))); + [%expect + {| + { + "activeParameter": 0, + "activeSignature": 0, + "signatures": [ + { + "activeParameter": 0, + "documentation": { "kind": "markdown", "value": " Two is fun! " }, + "label": "Two(string)", + "parameters": [ + { + "documentation": { "kind": "markdown", "value": "" }, + "label": [ 4, 10 ] + } + ] + } + ] + } + |}] + +let%expect_test "returns no signatures outside a call" = + Test.with_project ~name:"signature-help" ~text:{|let x = 12 +|} + (fun ~root ~doc client -> Test.snapshot ~root client (signature doc (0, 4))); + [%expect {| { "signatures": [] } |}] diff --git a/tests/lsp_tests/test.ml b/tests/lsp_tests/test.ml new file mode 100644 index 00000000000..12012f3da48 --- /dev/null +++ b/tests/lsp_tests/test.ml @@ -0,0 +1,441 @@ +let ( // ) = Filename.concat +let ( / ) = Eio.Path.( / ) + +(* Dune runs inline tests from this library's build directory. Resolve its + declared CLI dependency before setup changes into temporary workspaces. *) +let executable = + Sys.getcwd () // ".." // ".." // "rewatch-ocaml" // "rescript_ocaml.exe" + +let source_root () = + Sys.getenv_opt "DUNE_SOURCEROOT" |> Option.value ~default:(Sys.getcwd ()) + +(* These tests invoke the native CLI without the npm launcher that normally + sets RESCRIPT_RUNTIME. Both builds and the LSP inherit the same runtime. *) +let () = + Unix.putenv "RESCRIPT_RUNTIME" + (source_root () // "packages" // "@rescript" // "runtime") + +let rec mkdir_p path = + if path = "" || Sys.file_exists path then () + else ( + mkdir_p (Filename.dirname path); + Unix.mkdir path 0o755) + +let write_file path content = + mkdir_p (Filename.dirname path); + let oc = open_out_bin path in + Fun.protect + ~finally:(fun () -> close_out_noerr oc) + (fun () -> output_string oc content) + +let with_chdir dir f = + let cwd = Sys.getcwd () in + Fun.protect + ~finally:(fun () -> Sys.chdir cwd) + (fun () -> + Sys.chdir dir; + f ()) + +let run_rescript_build ~root = + let null_device = if Sys.os_type = "Win32" then "NUL" else "/dev/null" in + let dev_null = Unix.openfile null_device [Unix.O_WRONLY] 0 in + Fun.protect + ~finally:(fun () -> Unix.close dev_null) + (fun () -> + with_chdir root (fun () -> + let pid = + Unix.create_process_env executable [|executable; "build"|] + (Unix.environment ()) Unix.stdin dev_null dev_null + in + match snd (Unix.waitpid [] pid) with + | Unix.WEXITED 0 -> () + | Unix.WEXITED code -> + failwith (Printf.sprintf "rescript build exited with code %d" code) + | Unix.WSIGNALED signal -> + failwith + (Printf.sprintf "rescript build killed by signal %d" signal) + | Unix.WSTOPPED signal -> + failwith + (Printf.sprintf "rescript build stopped by signal %d" signal))) + +module Client = struct + (** Helpers for spawning the ReScript language server in tests, sending + LSP requests/notifications over stdio, and reading responses back. *) + + type t = { + process_stdout: in_channel; + process_stdin: out_channel; + mutable next_id: int; + mutable stopped: bool; + mutable notifications: Jsonrpc.Notification.t list; + } + + let frame (json : Yojson.Safe.t) : string = + let body = Yojson.Safe.to_string json in + Printf.sprintf "Content-Length: %d\r\n\r\n%s" (String.length body) body + + let read_headers t = + let rec loop acc = + match input_line t.process_stdout with + | line when String.trim line = "" -> Some acc + | line -> + let acc = + match String.index_opt line ':' with + | None -> acc + | Some i -> + let k = String.sub line 0 i in + let v = + String.trim (String.sub line (i + 1) (String.length line - i - 1)) + in + (k, v) :: acc + in + loop acc + | exception End_of_file -> if acc = [] then None else Some acc + in + loop [] + + let read_message t = + match read_headers t with + | None -> None + | Some headers -> ( + let len = int_of_string (List.assoc "Content-Length" headers) in + match really_input_string t.process_stdout len with + | body -> Some (Yojson.Safe.from_string body) + | exception End_of_file -> None) + + let start () = + let process_stdout, process_stdin = + Unix.open_process_args executable [|executable; "lsp"; "--stdio"|] + in + set_binary_mode_in process_stdout true; + set_binary_mode_out process_stdin true; + { + process_stdout; + process_stdin; + next_id = 0; + stopped = false; + notifications = []; + } + + let send_packet t (packet : Jsonrpc.Packet.t) = + let json = Jsonrpc.Packet.yojson_of_t packet in + output_string t.process_stdin (frame json); + flush t.process_stdin + + let next_id t = + t.next_id <- t.next_id + 1; + t.next_id + + (** Send a typed LSP request and return the assigned id. *) + let send_request t (req : 'r Lsp.Client_request.t) = + let id = `Int (next_id t) in + let jsonrpc_req = Lsp.Client_request.to_jsonrpc_request req ~id in + send_packet t (Jsonrpc.Packet.Request jsonrpc_req); + id + + (** Send a typed LSP notification. *) + let send_notification t (notif : Lsp.Client_notification.t) = + let jsonrpc_notif = Lsp.Client_notification.to_jsonrpc notif in + send_packet t (Jsonrpc.Packet.Notification jsonrpc_notif) + + (** Keep notifications for assertions and acknowledge refresh requests while + waiting for the response. This lets tests inspect every publication, not + just the final diagnostic state. *) + let rec read_response t id = + match read_message t with + | None -> failwith "Helper.read_response: unexpected EOF" + | Some json -> ( + match Jsonrpc.Packet.t_of_yojson json with + | Response resp when resp.id = id -> resp + | Notification notification -> + t.notifications <- notification :: t.notifications; + read_response t id + | Request request + when List.mem request.method_ + [ + "workspace/diagnostic/refresh"; + "workspace/codeLens/refresh"; + "workspace/inlayHint/refresh"; + ] -> + send_packet t + (Jsonrpc.Packet.Response (Jsonrpc.Response.ok request.id `Null)); + read_response t id + | _ -> read_response t id) + + let take_notifications t = + let notifications = List.rev t.notifications in + t.notifications <- []; + notifications + + (** Send a typed request and synchronously wait for its response, decoded + back into the request's result type. *) + let request (type r) t (req : r Lsp.Client_request.t) : r = + let id = send_request t req in + let resp = read_response t id in + match resp.result with + | Ok json -> Lsp.Client_request.response_of_json req json + | Error err -> failwith ("LSP error response: " ^ err.message) + + let rec read_request t method_ = + match read_message t with + | None -> failwith "Helper.read_request: unexpected EOF" + | Some json -> ( + match Jsonrpc.Packet.t_of_yojson json with + | Request request when request.method_ = method_ -> request + | Notification notification -> + t.notifications <- notification :: t.notifications; + read_request t method_ + | _ -> read_request t method_) + + let respond_ok t (request : Jsonrpc.Request.t) = + Jsonrpc.Response.ok request.id `Null |> fun response -> + send_packet t (Jsonrpc.Packet.Response response) + + (** Read the next packet of any kind. Useful when waiting for a server + notification (e.g. publishDiagnostics). *) + (* let read_packet t = + match read_message t with + | None -> failwith "Helper.read_packet: unexpected EOF" + | Some json -> Jsonrpc.Packet.t_of_yojson json *) + + let stop t = + if t.stopped then Unix.WEXITED 0 + else ( + t.stopped <- true; + (try close_out_noerr t.process_stdin with _ -> ()); + try Unix.close_process (t.process_stdout, t.process_stdin) + with _ -> Unix.WEXITED 0) + + (** Run [f] with a freshly started server, ensuring the process is stopped + and the switch is released afterwards. *) + let with_server f = + let t = start () in + Fun.protect ~finally:(fun () -> ignore (stop t)) (fun () -> f t) +end + +open Lsp +open Types + +let make_document ~uri ~text = + TextDocumentItem.create ~uri ~languageId:"rescript" ~version:0 ~text + +let write_document path (doc : TextDocumentItem.t) = + let path = path // (doc.uri |> Uri.to_path |> Filename.basename) in + write_file path doc.text + +let setup_project ~root ~rescript_json ~run_build ~documents = + mkdir_p root; + write_document root + (TextDocumentItem.create ~languageId:"json" ~text:rescript_json + ~uri:(Uri.of_string "rescript.json") + ~version:0); + List.iter (write_document root) documents; + if run_build then run_rescript_build ~root + +let client_capabilities = + ClientCapabilities.create + ~textDocument: + (TextDocumentClientCapabilities.create + ~diagnostic:(DiagnosticClientCapabilities.create ()) + ()) + ~workspace: + (WorkspaceClientCapabilities.create + ~diagnostics: + (DiagnosticWorkspaceClientCapabilities.create ~refreshSupport:true ()) + ()) + () + +let initialize_client ?(capabilities = client_capabilities) ~root client = + let id = + Client.send_request client + (Client_request.Initialize + (InitializeParams.create ~capabilities + ~rootUri:(DocumentUri.of_path root) ())) + in + assert ((Client.read_response client id).result |> Result.is_ok); + Client.send_notification client Client_notification.Initialized; + let registration = Client.read_request client "client/registerCapability" in + Client.respond_ok client registration + +type action = + | Request : 'result Client_request.t * ('result -> unit) -> action + | Notification of Client_notification.t + +let perform client = function + | Request (request, on_response) -> + Client.request client request |> on_response + | Notification notification -> Client.send_notification client notification + +let perform_all client actions = List.iter (perform client) actions + +let start_client_with_capabilities ~capabilities ~root = + let client = Client.start () in + try + initialize_client ~capabilities ~root client; + client + with exn -> + ignore (Client.stop client); + raise exn + +let start_client ~root = + start_client_with_capabilities ~capabilities:client_capabilities ~root + +let with_client ?(capabilities = client_capabilities) ~root f = + let client = start_client_with_capabilities ~capabilities ~root in + Fun.protect + ~finally:(fun () -> ignore (Client.stop client)) + (fun () -> f client) + +let open_document (doc : TextDocumentItem.t) = + let notification = + Client_notification.TextDocumentDidOpen + (DidOpenTextDocumentParams.create + ~textDocument: + (TextDocumentItem.create ~uri:doc.uri ~languageId:"rescript" + ~version:0 ~text:doc.text)) + in + Notification notification + +let change_document client (doc : TextDocumentItem.t) ~version ~text = + Client.send_notification client + (Client_notification.TextDocumentDidChange + (DidChangeTextDocumentParams.create + ~textDocument: + (VersionedTextDocumentIdentifier.create ~uri:doc.uri ~version) + ~contentChanges:[TextDocumentContentChangeEvent.create ~text ()])) + +let configure client settings = + Client.send_notification client + (Client_notification.ChangeConfiguration + (DidChangeConfigurationParams.create ~settings)); + let request = Client.read_request client "workspace/configuration" in + Client.send_packet client + (Jsonrpc.Packet.Response (Jsonrpc.Response.ok request.id (`List [settings]))) + +let position (line, character) = Position.create ~line ~character + +let range start end_ = + Range.create ~start:(position start) ~end_:(position end_) + +let text_document (doc : TextDocumentItem.t) = + TextDocumentIdentifier.create ~uri:doc.uri + +(* Only replace the temporary workspace prefix; keep relative paths, ranges and + response ordering intact so snapshots still catch navigation/edit regressions. *) +let normalize_json ~root json = + let prefixes = + [ + (* File rename currently encodes an already encoded URI as a path. Keep + that defect visible in snapshots while removing the random root. *) + ( Uri.to_string (Uri.of_path (Uri.to_string (Uri.of_path root))), + "file:///file%3A///workspace" ); + (Uri.to_string (Uri.of_path root), "file:///workspace"); + (root, "/workspace"); + ] + in + let normalize_string value = + List.find_map + (fun (prefix, replacement) -> + let length = String.length prefix in + if + String.starts_with ~prefix value + && (String.length value = length + || value.[length] = '/' + || value.[length] = '\\') + then + let suffix = String.sub value length (String.length value - length) in + let suffix = + if Sys.win32 then + String.map + (function + | '\\' -> '/' + | c -> c) + suffix + else suffix + in + Some (replacement ^ suffix) + else None) + prefixes + |> Option.value ~default:value + in + let rec normalize = function + | `String value -> `String (normalize_string value) + | `Assoc fields -> + `Assoc + (List.map + (fun (key, value) -> (normalize_string key, normalize value)) + fields) + | `List values -> `List (List.map normalize values) + | value -> value + in + normalize json + +let print_json ~root json = + normalize_json ~root json |> Yojson.Safe.pretty_to_string |> print_endline + +let snapshot ~root client request = + Client.request client request + |> Client_request.yojson_of_result request + |> print_json ~root + +(* A round trip is a barrier for preceding notifications. No timing assumptions + or sleeps are needed to assert the complete diagnostic publication sequence. *) +let synchronize client = + Client.request client + (Client_request.ExecuteCommand + (ExecuteCommandParams.create ~command:"rescript/dumpServerState" ())) + |> ignore + +let setup_react ~root = + let source = + source_root () // "tests" // "dependencies" // "rescript-react" + in + let destination = root // "node_modules" // "@rescript" // "react" in + let sources = + Sys.readdir (source // "src") + |> Array.to_list + |> List.filter (fun name -> + Filename.check_suffix name ".res" || Filename.check_suffix name ".resi") + |> List.map (Filename.concat "src") + in + List.iter + (fun path -> + let content = + In_channel.with_open_bin (source // path) In_channel.input_all + in + write_file (destination // path) content) + ("rescript.json" :: "package.json" :: sources) + +let rec remove_project path = + if (Unix.lstat path).st_kind = Unix.S_DIR then ( + Sys.readdir path |> Array.iter (fun name -> remove_project (path // name)); + Unix.rmdir path) + else Sys.remove path + +let with_project ?(files = []) ?(run_build = true) ?(jsx = false) + ?(capabilities = client_capabilities) ~name ~text f = + let root = Filename.temp_file ("rescript-lsp-" ^ name ^ "-") "" in + Sys.remove root; + mkdir_p root; + Fun.protect + ~finally:(fun () -> remove_project root) + (fun () -> + let doc = make_document ~uri:(Uri.of_path (root // "Test.res")) ~text in + let documents = + doc + :: List.map + (fun (file, text) -> + make_document ~uri:(Uri.of_path (root // file)) ~text) + files + in + let rescript_json = + if jsx then + {|{"name":"lsp-test","sources":{"dir":"."},"dependencies":["@rescript/react"],"jsx":{"version":4},"package-specs":{"module":"esmodule","in-source":false},"suffix":".res.js"}|} + else + {|{"name":"lsp-test","sources":{"dir":"."},"package-specs":{"module":"esmodule","in-source":false},"suffix":".res.js"}|} + in + if jsx then setup_react ~root; + setup_project ~root ~rescript_json ~run_build ~documents; + with_client ~capabilities ~root (fun client -> + perform client (open_document doc); + f ~root ~doc client)) diff --git a/tests/lsp_tests/type_definition.ml b/tests/lsp_tests/type_definition.ml new file mode 100644 index 00000000000..41bb24ec394 --- /dev/null +++ b/tests/lsp_tests/type_definition.ml @@ -0,0 +1,93 @@ +open Lsp +open Types + +let type_definition doc pos = + Client_request.TextDocumentTypeDefinition + (TypeDefinitionParams.create ~textDocument:(Test.text_document doc) + ~position:(Test.position pos) ()) + +let%expect_test "finds a record type through a module alias" = + Test.with_project ~name:"type-definition" + ~text:{|module Alias = Other +let value = Alias.make() +|} + ~files: + [ + ( "Other.res", + {|type t = {name: string} +let make = () => {name: "Alice"} +|} ); + ] + (fun ~root ~doc client -> + Test.snapshot ~root client (type_definition doc (1, 5))); + [%expect + {| + [ + { + "range": { + "end": { "character": 23, "line": 0 }, + "start": { "character": 0, "line": 0 } + }, + "uri": "file:///workspace/Other.res" + } + ] + |}] + +let%expect_test "finds the variant type of an inferred binding" = + Test.with_project ~name:"type-definition" + ~text:{|type variant = Foo | Bar +let x = Foo +|} (fun ~root ~doc client -> + Test.snapshot ~root client (type_definition doc (1, 4))); + [%expect + {| + [ + { + "range": { + "end": { "character": 24, "line": 0 }, + "start": { "character": 0, "line": 0 } + }, + "uri": "file:///workspace/Test.res" + } + ] + |}] + +let%expect_test "finds the record type of an inferred binding" = + Test.with_project ~name:"type-definition" + ~text:{|type record = {item: string} +let y = {item: "foo"} +|} + (fun ~root ~doc client -> + Test.snapshot ~root client (type_definition doc (1, 4))); + [%expect + {| + [ + { + "range": { + "end": { "character": 28, "line": 0 }, + "start": { "character": 0, "line": 0 } + }, + "uri": "file:///workspace/Test.res" + } + ] + |}] + +let%expect_test "finds a type declared in another file" = + Test.with_project ~name:"type-definition" ~text:{|let x = Other.Foo +|} + ~files:[("Other.res", {|type variant = Foo | Bar +|})] + (fun ~root ~doc client -> + Test.snapshot ~root client (type_definition doc (0, 4))); + [%expect + {| + [ + { + "range": { + "end": { "character": 24, "line": 0 }, + "start": { "character": 0, "line": 0 } + }, + "uri": "file:///workspace/Other.res" + } + ] + |}] diff --git a/tests/rewatch_ounit_tests/cli_tests.ml b/tests/rewatch_ounit_tests/cli_tests.ml index 1e4453ff0a6..d3acff82b0c 100644 --- a/tests/rewatch_ounit_tests/cli_tests.ml +++ b/tests/rewatch_ounit_tests/cli_tests.ml @@ -34,6 +34,24 @@ let watch_options arguments = let tests = "cli_tests" >:: fun _context -> + check + (parse ["lsp"; "--stdio"] = Cli.Lsp) + "lsp selects the stdio language server"; + check (rejects ["lsp"]) "lsp requires --stdio"; + List.iter + (fun flag -> + check + (rejects ["lsp"; "--stdio"; flag]) + "lsp rejects verbosity options after the command"; + check + (rejects [flag; "lsp"; "--stdio"]) + "lsp rejects verbosity options before the command") + ["--quiet"; "-q"; "--verbose"; "-v"; "-vv"]; + check (shows_help ["lsp"; "--help"]) "lsp displays command help"; + check (shows_help ["help"; "lsp"]) "help recognizes lsp"; + check (shows_version ["lsp"; "--version"]) "lsp accepts the global version"; + check (rejects ["lsp"; "--socket"]) "lsp rejects unsupported transports"; + check (rejects ["lsp"; "project"]) "lsp does not accept a build folder"; check (match parse [] with | Cli.Build _ -> true