From 73bf594ec1a0186bcb35335aee3732c11f413a5f Mon Sep 17 00:00:00 2001 From: Rudi Grinberg Date: Sun, 26 Nov 2023 19:51:42 -0600 Subject: [PATCH] Completion edge cases (#1212) * Tests passing * Added support for whitespace in completion The solution here is to change all whitespace to spaces for ease of regex matching(all whitespace is equivelent semantically) and then remove all spaces from the prefix that's passed to merlin. Co-authored-by: faldor20 Co-authored-by: faldor20 --- CHANGES.md | 2 + Makefile | 5 + ocaml-lsp-server/bench/documents.ml | 98 ++ ocaml-lsp-server/bench/dune | 13 + ocaml-lsp-server/bench/ocaml_lsp_bench.ml | 27 + ocaml-lsp-server/src/compl.ml | 73 +- ocaml-lsp-server/src/compl.mli | 5 +- ocaml-lsp-server/src/import.ml | 7 + ocaml-lsp-server/src/ocaml_lsp_server.ml | 1 + ocaml-lsp-server/src/ocaml_lsp_server.mli | 1 + ocaml-lsp-server/src/prefix_parser.ml | 50 + ocaml-lsp-server/src/prefix_parser.mli | 4 + ocaml-lsp-server/src/testing.ml | 5 + ocaml-lsp-server/test/dune | 3 +- ocaml-lsp-server/test/e2e-new/code_actions.ml | 61 +- ocaml-lsp-server/test/e2e-new/completion.ml | 1164 +++++++++++++++++ ocaml-lsp-server/test/e2e-new/dune | 1 + ocaml-lsp-server/test/e2e-new/lsp_helpers.ml | 55 + ocaml-lsp-server/test/e2e-new/lsp_helpers.mli | 16 + .../__tests__/textDocument-completion.test.ts | 701 ---------- .../test/position_prefix_tests.ml | 86 ++ 21 files changed, 1564 insertions(+), 814 deletions(-) create mode 100644 ocaml-lsp-server/bench/documents.ml create mode 100644 ocaml-lsp-server/bench/dune create mode 100644 ocaml-lsp-server/bench/ocaml_lsp_bench.ml create mode 100644 ocaml-lsp-server/src/prefix_parser.ml create mode 100644 ocaml-lsp-server/src/prefix_parser.mli create mode 100644 ocaml-lsp-server/src/testing.ml create mode 100644 ocaml-lsp-server/test/e2e-new/completion.ml create mode 100644 ocaml-lsp-server/test/e2e-new/lsp_helpers.ml create mode 100644 ocaml-lsp-server/test/e2e-new/lsp_helpers.mli delete mode 100644 ocaml-lsp-server/test/e2e/__tests__/textDocument-completion.test.ts create mode 100644 ocaml-lsp-server/test/position_prefix_tests.ml diff --git a/CHANGES.md b/CHANGES.md index 721eab806..928ff765b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -15,6 +15,8 @@ - Fix the encoding of URI's to match how vscode does it (#1197) +- Fix parsing of completion prefixes (#1181) + ## Features - Compatibility with Odoc 2.3.0, with support for the introduced syntax: tables, diff --git a/Makefile b/Makefile index 77e869bd3..4c49d9de4 100644 --- a/Makefile +++ b/Makefile @@ -35,6 +35,11 @@ install: ## Install the packages on the system lock: ## Generate the lock files opam lock -y . +.PHONY: bench +bench: ## + dune exec ocaml-lsp-server/bench/ocaml_lsp_bench.exe --profile bench + + .PHONY: test-ocaml test-ocaml: ## Run the unit tests dune build @lsp/test/runtest @lsp-fiber/runtest @jsonrpc-fiber/runtest @ocaml-lsp-server/runtest diff --git a/ocaml-lsp-server/bench/documents.ml b/ocaml-lsp-server/bench/documents.ml new file mode 100644 index 000000000..6b262e9e7 --- /dev/null +++ b/ocaml-lsp-server/bench/documents.ml @@ -0,0 +1,98 @@ +let document = + "let mem = ListLabels.mem\n\nlet _ = mem ~se" |> Merlin_kernel.Msource.make + +let long_document_text = + {|let prefix_of_position ~short_path source position = + let open Prefix_parser in + match Msource.text source with + | "" -> "" + | text -> + let end_of_prefix = + let (`Offset index) = Msource.get_offset source position in + min (String.length text - 1) (index - 1) + in + let prefix_text = + let pos = + (*clamp the length of a line to process at 500 chars, this is just a + reasonable limit for regex performance*) + max 0 (end_of_prefix - 500) + in + String.sub text ~pos ~len:(end_of_prefix + 1 - pos) + (*because all whitespace is semantically the same we convert it all to + spaces for easier regex matching*) + |> String.rev_map ~f:(fun x -> if x = '\n' || x = '\t' then ' ' else x) + in + + let reconstructed_prefix = + try_parse_with_regex prefix_text + |> Option.value ~default:"" + |> String.rev_filter ~f:(fun x -> x <> ' ') + in + + if short_path then + match String.split_on_char reconstructed_prefix ~sep:'.' |> List.last with + | Some s -> s + | None -> reconstructed_prefix + else reconstructed_prefix + +let suffix_of_position source position = + match Msource.text source with + | "" -> "" + | text -> + let (`Offset index) = Msource.get_offset source position in + let len = String.length text in + if index >= len then "" + else + let from = index in + let len = + let ident_char = function + | 'a' .. 'z' | 'A' .. 'Z' | '0' .. '9' | '\'' | '_' -> true + | _ -> false + in + let until = + String.findi ~from text ~f:(fun c -> not (ident_char c)) + |> Option.value ~default:len + in + until - from + in + String.sub text ~pos:from ~len + +let reconstruct_ident source position = + let prefix = prefix_of_position ~short_path:false source position in + let suffix = suffix_of_position source position in + let ident = prefix ^ suffix in + Option.some_if (ident <> "") ident + +let range_prefix (lsp_position : Position.t) prefix : Range.t = + let start = + let len = String.length prefix in + let character = lsp_position.character - len in + { lsp_position with character } + in + { Range.start; end_ = lsp_position } + +let sortText_of_index idx = Printf.sprintf "%04d" idx + +module Complete_by_prefix = struct + let completionItem_of_completion_entry idx + (entry : Query_protocol.Compl.entry) ~compl_params ~range ~deprecated = + let kind = completion_kind entry.kind in + let textEdit = `TextEdit { TextEdit.range; newText = entry.name } in + CompletionItem.create + ~label:entry.name + ?kind + ~detail:entry.desc + ?deprecated:(Option.some_if deprecated entry.deprecated) + (* Without this field the client is not forced to respect the order + provided by merlin. *) + ~sortText:(sortText_of_index idx) + ?data:compl_params + ~textEdit + () + + let dispatch_cmd ~prefix position pipeline = + let complete = + Query_protocol.Complete_prefix (prefix, position, [], false, true) + in + Query_commands.dispatch pipeline comp + |} diff --git a/ocaml-lsp-server/bench/dune b/ocaml-lsp-server/bench/dune new file mode 100644 index 000000000..bfd7161cd --- /dev/null +++ b/ocaml-lsp-server/bench/dune @@ -0,0 +1,13 @@ +(executables + (names ocaml_lsp_bench) + (enabled_if + (= %{profile} bench)) + (libraries + ocaml_lsp_server + core_unix.command_unix + merlin-lib.kernel + base + core + core_bench) + (preprocess + (pps ppx_bench))) diff --git a/ocaml-lsp-server/bench/ocaml_lsp_bench.ml b/ocaml-lsp-server/bench/ocaml_lsp_bench.ml new file mode 100644 index 000000000..b7607feb5 --- /dev/null +++ b/ocaml-lsp-server/bench/ocaml_lsp_bench.ml @@ -0,0 +1,27 @@ +open Ocaml_lsp_server +open Core +open Core_bench + +let () = + let open Documents in + let long_document = long_document_text |> Merlin_kernel.Msource.make in + let position = `Logical (3, 15) in + let long_position = `Logical (92, 41) in + Command_unix.run + (Bench.make_command + [ Bench.Test.create ~name:"get_prefix" (fun _ -> + Testing.Compl.prefix_of_position + ~short_path:false + document + position + |> ignore) + ; Bench.Test.create ~name:"get_prefix_long" (fun _ -> + Testing.Compl.prefix_of_position + ~short_path:false + long_document + long_position + |> ignore) + ; Bench.Test.create ~name:"get_offset_long" (fun _ -> + Merlin_kernel.Msource.get_offset long_document long_position + |> ignore) + ]) diff --git a/ocaml-lsp-server/src/compl.ml b/ocaml-lsp-server/src/compl.ml index 9e4aa320c..117cbe101 100644 --- a/ocaml-lsp-server/src/compl.ml +++ b/ocaml-lsp-server/src/compl.ml @@ -26,77 +26,34 @@ let completion_kind kind : CompletionItemKind.t option = | `Constructor -> Some Constructor | `Type -> Some TypeParameter -(** @see reference *) let prefix_of_position ~short_path source position = match Msource.text source with | "" -> "" | text -> - let from = + let end_of_prefix = let (`Offset index) = Msource.get_offset source position in min (String.length text - 1) (index - 1) in let pos = - let should_terminate = ref false in - let has_seen_dot = ref false in - let is_prefix_char c = - if !should_terminate then false - else - match c with - | 'a' .. 'z' - | 'A' .. 'Z' - | '0' .. '9' - | '\'' - | '_' - (* Infix function characters *) - | '$' - | '&' - | '*' - | '+' - | '-' - | '/' - | '=' - | '>' - | '@' - | '^' - | '!' - | '?' - | '%' - | '<' - | ':' - | '~' - | '#' -> true - | '`' -> - if !has_seen_dot then false - else ( - should_terminate := true; - true) - | '.' -> - has_seen_dot := true; - not short_path - | _ -> false - in - String.rfindi text ~from ~f:(fun c -> not (is_prefix_char c)) + (*clamp the length of a line to process at 500 chars, this is just a + reasonable limit for regex performance*) + max 0 (end_of_prefix - 500) in - let pos = - match pos with - | None -> 0 - | Some pos -> pos + 1 + + let reconstructed_prefix = + Prefix_parser.parse ~pos ~len:(end_of_prefix + 1 - pos) text + |> Option.value ~default:"" + (* We remove the whitespace because merlin expects no whitespace and it's + semantically meaningless *) + |> String.filter (fun x -> not (x = ' ' || x = '\n' || x = '\t')) in - let len = from - pos + 1 in - let reconstructed_prefix = String.sub text ~pos ~len in - (* if we reconstructed [~f:ignore] or [?f:ignore], we should take only - [ignore], so: *) - if - String.is_prefix reconstructed_prefix ~prefix:"~" - || String.is_prefix reconstructed_prefix ~prefix:"?" - then - match String.lsplit2 reconstructed_prefix ~on:':' with - | Some (_, s) -> s + + if short_path then + match String.split_on_char reconstructed_prefix ~sep:'.' |> List.last with + | Some s -> s | None -> reconstructed_prefix else reconstructed_prefix -(** [suffix_of_position source position] computes the suffix of the identifier - after [position]. *) let suffix_of_position source position = match Msource.text source with | "" -> "" diff --git a/ocaml-lsp-server/src/compl.mli b/ocaml-lsp-server/src/compl.mli index 8d094e9ca..589bd6600 100644 --- a/ocaml-lsp-server/src/compl.mli +++ b/ocaml-lsp-server/src/compl.mli @@ -27,7 +27,10 @@ val resolve : -> CompletionItem.t Fiber.t (** [prefix_of_position ~short_path source position] computes prefix before - given [position]. + given [position]. A prefix is essentially a piece of code that refers to one + thing eg a single infix operator "|>", a single reference to a function or + variable: "List.map" a keyword "let" etc If there is semantically irrelivent + whitespace it is removed eg "List. map"->"List.map" @param short_path determines whether we want full prefix or cut at ["."], e.g. diff --git a/ocaml-lsp-server/src/import.ml b/ocaml-lsp-server/src/import.ml index 02fd89d83..4f621eb83 100644 --- a/ocaml-lsp-server/src/import.ml +++ b/ocaml-lsp-server/src/import.ml @@ -34,6 +34,13 @@ include struct module String = struct include String + (**Filters a string keeping any chars for which f returns true and + discarding those for which it returns false*) + let filter f s = + let buf = Buffer.create (String.length s) in + iter ~f:(fun c -> if f c then Buffer.add_char buf c) s; + Buffer.contents buf + let findi = let rec loop s len ~f i = if i >= len then None diff --git a/ocaml-lsp-server/src/ocaml_lsp_server.ml b/ocaml-lsp-server/src/ocaml_lsp_server.ml index 6d0793608..4d973ca0a 100644 --- a/ocaml-lsp-server/src/ocaml_lsp_server.ml +++ b/ocaml-lsp-server/src/ocaml_lsp_server.ml @@ -3,6 +3,7 @@ module Version = Version module Diagnostics = Diagnostics module Doc_to_md = Doc_to_md module Diff = Diff +module Testing = Testing open Fiber.O let make_error = Jsonrpc.Response.Error.make diff --git a/ocaml-lsp-server/src/ocaml_lsp_server.mli b/ocaml-lsp-server/src/ocaml_lsp_server.mli index 10db38edd..9fa5c0ce7 100644 --- a/ocaml-lsp-server/src/ocaml_lsp_server.mli +++ b/ocaml-lsp-server/src/ocaml_lsp_server.mli @@ -3,3 +3,4 @@ val run : Lsp.Cli.Channel.t -> read_dot_merlin:bool -> unit -> unit module Diagnostics = Diagnostics module Version = Version module Doc_to_md = Doc_to_md +module Testing = Testing diff --git a/ocaml-lsp-server/src/prefix_parser.ml b/ocaml-lsp-server/src/prefix_parser.ml new file mode 100644 index 000000000..d64552982 --- /dev/null +++ b/ocaml-lsp-server/src/prefix_parser.ml @@ -0,0 +1,50 @@ +open Import + +include struct + open Re + + (* Regex based parser *) + let white_space = set "\n\t " + + let name_char = + Re.alt [ rg 'a' 'z'; rg 'A' 'Z'; rg '0' '9'; char '_'; char '\'' ] + + let name_with_dot = + Re.seq [ name_char; white_space |> rep; char '.'; white_space |> rep ] + + let core_operator_str = {|$&*+-/=>@^||} + + let operator = core_operator_str ^ {|~!?%<:.|} + + let infix = set (operator ^ "#") + + let name_or_label = + compile + (seq + [ alt [ set "~?``"; str "let%"; str "and%" ] |> opt + ; alt [ name_char; name_with_dot ] |> rep1 + ; stop + ]) + + (** matches let%lwt and let* style expressions. See + here:https://v2.ocaml.org/manual/bindingops.html *) + let monadic_bind = + compile + (seq + [ alt [ str "let"; str "and" ] + ; alt [ infix |> rep1; seq [ name_char |> rep1; char '%' ] ] + ; stop + ]) + + let infix_operator = compile (seq [ infix |> rep1; stop ]) +end + +let parse ~pos ~len text = + (*Attempt to match each of our possible prefix types, the order is important + because there is some overlap between the regexs*) + let matched = + List.find_map + [ name_or_label; monadic_bind; infix_operator ] + ~f:(fun regex -> Re.exec_opt ~pos ~len regex text) + in + matched |> Option.map ~f:(fun x -> Re.Group.get x 0) diff --git a/ocaml-lsp-server/src/prefix_parser.mli b/ocaml-lsp-server/src/prefix_parser.mli new file mode 100644 index 000000000..8d586d98e --- /dev/null +++ b/ocaml-lsp-server/src/prefix_parser.mli @@ -0,0 +1,4 @@ +(** Tries the parse the incoming string for a prefix. The string should be the + source code ending at the prefix position. pos and len set the range for the + regex to operate on *) +val parse : pos:int -> len:int -> string -> string option diff --git a/ocaml-lsp-server/src/testing.ml b/ocaml-lsp-server/src/testing.ml new file mode 100644 index 000000000..a41133624 --- /dev/null +++ b/ocaml-lsp-server/src/testing.ml @@ -0,0 +1,5 @@ +(**WARNING: This is for internal use in testing only *) + +module Compl = Compl +module Merlin_kernel = Merlin_kernel +module Prefix_parser = Prefix_parser diff --git a/ocaml-lsp-server/test/dune b/ocaml-lsp-server/test/dune index 9eb6a25ab..e2c3b9866 100644 --- a/ocaml-lsp-server/test/dune +++ b/ocaml-lsp-server/test/dune @@ -1,7 +1,7 @@ (dirs :standard \ e2e) (library - (modules ocaml_lsp_tests) + (modules ocaml_lsp_tests position_prefix_tests) (name ocaml_lsp_tests) (enabled_if (>= %{ocaml_version} 4.08)) @@ -9,6 +9,7 @@ (libraries stdune ocaml_lsp_server + merlin-lib.kernel lsp yojson ;; This is because of the (implicit_transitive_deps false) diff --git a/ocaml-lsp-server/test/e2e-new/code_actions.ml b/ocaml-lsp-server/test/e2e-new/code_actions.ml index 41789e39e..1779db897 100644 --- a/ocaml-lsp-server/test/e2e-new/code_actions.ml +++ b/ocaml-lsp-server/test/e2e-new/code_actions.ml @@ -1,58 +1,13 @@ open Test.Import +open Lsp_helpers -let openDocument ~client ~uri ~source = - let textDocument = - TextDocumentItem.create ~uri ~languageId:"ocaml" ~version:0 ~text:source - in - Client.notification - client - (TextDocumentDidOpen (DidOpenTextDocumentParams.create ~textDocument)) - -let iter_code_actions ?(prep = fun _ -> Fiber.return ()) ?(path = "foo.ml") - ?(diagnostics = []) ~source range k = - let got_diagnostics = Fiber.Ivar.create () in - let handler = - Client.Handler.make - ~on_notification: - (fun _ -> function - | PublishDiagnostics _ -> ( - let* diag = Fiber.Ivar.peek got_diagnostics in - match diag with - | Some _ -> Fiber.return () - | None -> Fiber.Ivar.fill got_diagnostics ()) - | _ -> Fiber.return ()) - () - in - Test.run ~handler @@ fun client -> - let run_client () = - let capabilities = - let window = - let showDocument = - ShowDocumentClientCapabilities.create ~support:true - in - WindowClientCapabilities.create ~showDocument () - in - ClientCapabilities.create ~window () - in - Client.start client (InitializeParams.create ~capabilities ()) - in - let run = - let* (_ : InitializeResult.t) = Client.initialized client in - let uri = DocumentUri.of_path path in - let* () = prep client in - let* () = openDocument ~client ~uri ~source in - let+ resp = - let context = CodeActionContext.create ~diagnostics () in - let request = - let textDocument = TextDocumentIdentifier.create ~uri in - CodeActionParams.create ~textDocument ~range ~context () - in - Client.request client (CodeAction request) - in - k resp +let iter_code_actions ?prep ?path ?(diagnostics = []) ~source range = + let makeRequest textDocument = + let context = CodeActionContext.create ~diagnostics () in + Lsp.Client_request.CodeAction + (CodeActionParams.create ~textDocument ~range ~context ()) in - Fiber.fork_and_join_unit run_client (fun () -> - run >>> Fiber.Ivar.read got_diagnostics >>> Client.stop client) + iter_lsp_response ?prep ?path ~makeRequest ~source let print_code_actions ?(prep = fun _ -> Fiber.return ()) ?(path = "foo.ml") ?(filter = fun _ -> true) source range = @@ -562,7 +517,7 @@ let f (x : t) = x |ocaml} in let uri = DocumentUri.of_path "foo.ml" in - let prep client = openDocument ~client ~uri ~source:impl_source in + let prep client = open_document ~client ~uri ~source:impl_source in let intf_source = "" in let range = let start = Position.create ~line:0 ~character:0 in diff --git a/ocaml-lsp-server/test/e2e-new/completion.ml b/ocaml-lsp-server/test/e2e-new/completion.ml new file mode 100644 index 000000000..e5a5dc3c7 --- /dev/null +++ b/ocaml-lsp-server/test/e2e-new/completion.ml @@ -0,0 +1,1164 @@ +open Test.Import + +let iter_completions ?prep ?path ?(triggerCharacter = "") + ?(triggerKind = CompletionTriggerKind.Invoked) ~position = + let makeRequest textDocument = + let context = CompletionContext.create ~triggerCharacter ~triggerKind () in + Lsp.Client_request.TextDocumentCompletion + (CompletionParams.create ~textDocument ~position ~context ()) + in + Lsp_helpers.iter_lsp_response ?prep ?path ~makeRequest + +let print_completions ?(prep = fun _ -> Fiber.return ()) ?(path = "foo.ml") + ?(limit = 10) ?(pre_print = fun x -> x) source position = + iter_completions ~prep ~path ~source ~position (function + | None -> print_endline "No completion Items" + | Some completions -> ( + let items = + match completions with + | `CompletionList comp -> comp.items + | `List comp -> comp + in + items |> pre_print |> function + | [] -> print_endline "No completions" + | items -> + print_endline "Completions:"; + + let originalLength = List.length items in + items + |> List.take (min limit originalLength) + |> List.iter ~f:(fun item -> + item |> CompletionItem.yojson_of_t + |> Yojson.Safe.pretty_to_string ~std:false + |> print_endline); + if originalLength > limit then print_endline ".............")) + +let%expect_test "can start completion at arbitrary position (before the dot)" = + let source = {ocaml|Strin.func|ocaml} in + let position = Position.create ~line:0 ~character:5 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "", + "kind": 9, + "label": "String", + "sortText": "0000", + "textEdit": { + "newText": "String", + "range": { + "end": { "character": 5, "line": 0 }, + "start": { "character": 0, "line": 0 } + } + } + } + { + "detail": "", + "kind": 9, + "label": "StringLabels", + "sortText": "0001", + "textEdit": { + "newText": "StringLabels", + "range": { + "end": { "character": 5, "line": 0 }, + "start": { "character": 0, "line": 0 } + } + } + } |}] + +let%expect_test "can start completion at arbitrary position" = + let source = {ocaml|StringLabels|ocaml} in + let position = Position.create ~line:0 ~character:6 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "", + "kind": 9, + "label": "String", + "sortText": "0000", + "textEdit": { + "newText": "String", + "range": { + "end": { "character": 6, "line": 0 }, + "start": { "character": 0, "line": 0 } + } + } + } + { + "detail": "", + "kind": 9, + "label": "StringLabels", + "sortText": "0001", + "textEdit": { + "newText": "StringLabels", + "range": { + "end": { "character": 6, "line": 0 }, + "start": { "character": 0, "line": 0 } + } + } + } |}] + +let%expect_test "can start completion at arbitrary position 2" = + let source = {ocaml|StringLabels|ocaml} in + let position = Position.create ~line:0 ~character:7 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "", + "kind": 9, + "label": "StringLabels", + "sortText": "0000", + "textEdit": { + "newText": "StringLabels", + "range": { + "end": { "character": 7, "line": 0 }, + "start": { "character": 0, "line": 0 } + } + } + } |}] + +let%expect_test "can start completion after operator without space" = + let source = {ocaml|[1;2]|>List.ma|ocaml} in + let position = Position.create ~line:0 ~character:14 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "('a -> 'b) -> 'a list -> 'b list", + "kind": 12, + "label": "map", + "sortText": "0000", + "textEdit": { + "newText": "map", + "range": { + "end": { "character": 14, "line": 0 }, + "start": { "character": 12, "line": 0 } + } + } + } + { + "detail": "(int -> 'a -> 'b) -> 'a list -> 'b list", + "kind": 12, + "label": "mapi", + "sortText": "0001", + "textEdit": { + "newText": "mapi", + "range": { + "end": { "character": 14, "line": 0 }, + "start": { "character": 12, "line": 0 } + } + } + } + { + "detail": "('a -> 'b -> 'c) -> 'a list -> 'b list -> 'c list", + "kind": 12, + "label": "map2", + "sortText": "0002", + "textEdit": { + "newText": "map2", + "range": { + "end": { "character": 14, "line": 0 }, + "start": { "character": 12, "line": 0 } + } + } + } |}] + +let%expect_test "can start completion after operator with space" = + let source = {ocaml|[1;2] |> List.ma|ocaml} in + let position = Position.create ~line:0 ~character:16 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "('a -> 'b) -> 'a list -> 'b list", + "kind": 12, + "label": "map", + "sortText": "0000", + "textEdit": { + "newText": "map", + "range": { + "end": { "character": 16, "line": 0 }, + "start": { "character": 14, "line": 0 } + } + } + } + { + "detail": "(int -> 'a -> 'b) -> 'a list -> 'b list", + "kind": 12, + "label": "mapi", + "sortText": "0001", + "textEdit": { + "newText": "mapi", + "range": { + "end": { "character": 16, "line": 0 }, + "start": { "character": 14, "line": 0 } + } + } + } + { + "detail": "('a -> 'b -> 'c) -> 'a list -> 'b list -> 'c list", + "kind": 12, + "label": "map2", + "sortText": "0002", + "textEdit": { + "newText": "map2", + "range": { + "end": { "character": 16, "line": 0 }, + "start": { "character": 14, "line": 0 } + } + } + } + |}] + +let%expect_test "can start completion in dot chain with tab" = + let source = {ocaml|[1;2] |> List. ma|ocaml} in + let position = Position.create ~line:0 ~character:17 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "('a -> 'b) -> 'a list -> 'b list", + "kind": 12, + "label": "map", + "sortText": "0000", + "textEdit": { + "newText": "map", + "range": { + "end": { "character": 17, "line": 0 }, + "start": { "character": 15, "line": 0 } + } + } + } + { + "detail": "(int -> 'a -> 'b) -> 'a list -> 'b list", + "kind": 12, + "label": "mapi", + "sortText": "0001", + "textEdit": { + "newText": "mapi", + "range": { + "end": { "character": 17, "line": 0 }, + "start": { "character": 15, "line": 0 } + } + } + } + { + "detail": "('a -> 'b -> 'c) -> 'a list -> 'b list -> 'c list", + "kind": 12, + "label": "map2", + "sortText": "0002", + "textEdit": { + "newText": "map2", + "range": { + "end": { "character": 17, "line": 0 }, + "start": { "character": 15, "line": 0 } + } + } + } + |}] + +let%expect_test "can start completion in dot chain with newline" = + let source = {ocaml|[1;2] |> List. +ma|ocaml} in + let position = Position.create ~line:1 ~character:2 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "('a -> 'b) -> 'a list -> 'b list", + "kind": 12, + "label": "map", + "sortText": "0000", + "textEdit": { + "newText": "map", + "range": { + "end": { "character": 2, "line": 1 }, + "start": { "character": 0, "line": 1 } + } + } + } + { + "detail": "(int -> 'a -> 'b) -> 'a list -> 'b list", + "kind": 12, + "label": "mapi", + "sortText": "0001", + "textEdit": { + "newText": "mapi", + "range": { + "end": { "character": 2, "line": 1 }, + "start": { "character": 0, "line": 1 } + } + } + } + { + "detail": "('a -> 'b -> 'c) -> 'a list -> 'b list -> 'c list", + "kind": 12, + "label": "map2", + "sortText": "0002", + "textEdit": { + "newText": "map2", + "range": { + "end": { "character": 2, "line": 1 }, + "start": { "character": 0, "line": 1 } + } + } + } + |}] + +let%expect_test "can start completion in dot chain with space" = + let source = {ocaml|[1;2] |> List. ma|ocaml} in + let position = Position.create ~line:0 ~character:17 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "('a -> 'b) -> 'a list -> 'b list", + "kind": 12, + "label": "map", + "sortText": "0000", + "textEdit": { + "newText": "map", + "range": { + "end": { "character": 17, "line": 0 }, + "start": { "character": 15, "line": 0 } + } + } + } + { + "detail": "(int -> 'a -> 'b) -> 'a list -> 'b list", + "kind": 12, + "label": "mapi", + "sortText": "0001", + "textEdit": { + "newText": "mapi", + "range": { + "end": { "character": 17, "line": 0 }, + "start": { "character": 15, "line": 0 } + } + } + } + { + "detail": "('a -> 'b -> 'c) -> 'a list -> 'b list -> 'c list", + "kind": 12, + "label": "map2", + "sortText": "0002", + "textEdit": { + "newText": "map2", + "range": { + "end": { "character": 17, "line": 0 }, + "start": { "character": 15, "line": 0 } + } + } + } + |}] + +let%expect_test "can start completion after dereference" = + let source = {ocaml|let apple=ref 10 in +!ap|ocaml} in + let position = Position.create ~line:1 ~character:3 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "int ref", + "kind": 12, + "label": "apple", + "sortText": "0000", + "textEdit": { + "newText": "apple", + "range": { + "end": { "character": 3, "line": 1 }, + "start": { "character": 1, "line": 1 } + } + } + } + |}] + +let%expect_test "can complete symbol passed as a named argument" = + let source = {ocaml|let g ~f = f 0 in +g ~f:ig|ocaml} in + let position = Position.create ~line:1 ~character:7 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "'a -> unit", + "kind": 12, + "label": "ignore", + "sortText": "0000", + "textEdit": { + "newText": "ignore", + "range": { + "end": { "character": 7, "line": 1 }, + "start": { "character": 5, "line": 1 } + } + } + } + |}] + +let%expect_test "can complete symbol passed as a named argument - 2" = + let source = + {ocaml|module M = struct let igfoo _x = () end +let g ~f = f 0 in +g ~f:M.ig|ocaml} + in + let position = Position.create ~line:2 ~character:9 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "'a -> unit", + "kind": 12, + "label": "igfoo", + "sortText": "0000", + "textEdit": { + "newText": "igfoo", + "range": { + "end": { "character": 9, "line": 2 }, + "start": { "character": 7, "line": 2 } + } + } + } + |}] + +let%expect_test "can complete symbol passed as an optional argument" = + let source = {ocaml| +let g ?f = f in +g ?f:ig + |ocaml} in + let position = Position.create ~line:2 ~character:7 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "'a -> unit", + "kind": 12, + "label": "ignore", + "sortText": "0000", + "textEdit": { + "newText": "ignore", + "range": { + "end": { "character": 7, "line": 2 }, + "start": { "character": 5, "line": 2 } + } + } + } + |}] + +let%expect_test "can complete symbol passed as an optional argument - 2" = + let source = + {ocaml|module M = struct let igfoo _x = () end +let g ?f = f in +g ?f:M.ig|ocaml} + in + let position = Position.create ~line:2 ~character:9 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "'a -> unit", + "kind": 12, + "label": "igfoo", + "sortText": "0000", + "textEdit": { + "newText": "igfoo", + "range": { + "end": { "character": 9, "line": 2 }, + "start": { "character": 7, "line": 2 } + } + } + } + |}] + +let%expect_test "completes identifier after completion-triggering character" = + let source = + {ocaml| +module Test = struct + let somenum = 42 + let somestring = "hello" +end + +let x = Test. + |ocaml} + in + let position = Position.create ~line:6 ~character:13 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "int", + "kind": 12, + "label": "somenum", + "sortText": "0000", + "textEdit": { + "newText": "somenum", + "range": { + "end": { "character": 13, "line": 6 }, + "start": { "character": 13, "line": 6 } + } + } + } + { + "detail": "string", + "kind": 12, + "label": "somestring", + "sortText": "0001", + "textEdit": { + "newText": "somestring", + "range": { + "end": { "character": 13, "line": 6 }, + "start": { "character": 13, "line": 6 } + } + } + } + |}] + +let%expect_test "completes infix operators" = + let source = {ocaml| +let (>>|) = (+) +let y = 1 > +|ocaml} in + let position = Position.create ~line:2 ~character:11 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "int -> int -> int", + "kind": 12, + "label": ">>|", + "sortText": "0000", + "textEdit": { + "newText": ">>|", + "range": { + "end": { "character": 11, "line": 2 }, + "start": { "character": 10, "line": 2 } + } + } + } + { + "detail": "'a -> 'a -> bool", + "kind": 12, + "label": ">", + "sortText": "0001", + "textEdit": { + "newText": ">", + "range": { + "end": { "character": 11, "line": 2 }, + "start": { "character": 10, "line": 2 } + } + } + } + { + "detail": "'a -> 'a -> bool", + "kind": 12, + "label": ">=", + "sortText": "0002", + "textEdit": { + "newText": ">=", + "range": { + "end": { "character": 11, "line": 2 }, + "start": { "character": 10, "line": 2 } + } + } + } + |}] + +let%expect_test "completes without prefix" = + let source = + {ocaml| +let somenum = 42 +let somestring = "hello" + +let plus_42 (x:int) (y:int) = + somenum + +|ocaml} + in + let position = Position.create ~line:5 ~character:12 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "int", + "kind": 12, + "label": "somenum", + "sortText": "0000", + "textEdit": { + "newText": "somenum", + "range": { + "end": { "character": 12, "line": 5 }, + "start": { "character": 12, "line": 5 } + } + } + } + { + "detail": "int", + "kind": 12, + "label": "x", + "sortText": "0001", + "textEdit": { + "newText": "x", + "range": { + "end": { "character": 12, "line": 5 }, + "start": { "character": 12, "line": 5 } + } + } + } + { + "detail": "int", + "kind": 12, + "label": "y", + "sortText": "0002", + "textEdit": { + "newText": "y", + "range": { + "end": { "character": 12, "line": 5 }, + "start": { "character": 12, "line": 5 } + } + } + } + { + "detail": "int", + "kind": 12, + "label": "max_int", + "sortText": "0003", + "textEdit": { + "newText": "max_int", + "range": { + "end": { "character": 12, "line": 5 }, + "start": { "character": 12, "line": 5 } + } + } + } + { + "detail": "int", + "kind": 12, + "label": "min_int", + "sortText": "0004", + "textEdit": { + "newText": "min_int", + "range": { + "end": { "character": 12, "line": 5 }, + "start": { "character": 12, "line": 5 } + } + } + } + { + "detail": "int -> int", + "kind": 12, + "label": "abs", + "sortText": "0005", + "textEdit": { + "newText": "abs", + "range": { + "end": { "character": 12, "line": 5 }, + "start": { "character": 12, "line": 5 } + } + } + } + { + "detail": "in_channel -> int", + "kind": 12, + "label": "in_channel_length", + "sortText": "0006", + "textEdit": { + "newText": "in_channel_length", + "range": { + "end": { "character": 12, "line": 5 }, + "start": { "character": 12, "line": 5 } + } + } + } + { + "detail": "in_channel -> int", + "kind": 12, + "label": "input_binary_int", + "sortText": "0007", + "textEdit": { + "newText": "input_binary_int", + "range": { + "end": { "character": 12, "line": 5 }, + "start": { "character": 12, "line": 5 } + } + } + } + { + "detail": "in_channel -> int", + "kind": 12, + "label": "input_byte", + "sortText": "0008", + "textEdit": { + "newText": "input_byte", + "range": { + "end": { "character": 12, "line": 5 }, + "start": { "character": 12, "line": 5 } + } + } + } + { + "detail": "char -> int", + "kind": 12, + "label": "int_of_char", + "sortText": "0009", + "textEdit": { + "newText": "int_of_char", + "range": { + "end": { "character": 12, "line": 5 }, + "start": { "character": 12, "line": 5 } + } + } + } + ............. + |}] + +let%expect_test "completes labels" = + let source = {ocaml|let f = ListLabels.map ~|ocaml} in + let position = Position.create ~line:0 ~character:24 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "int -> int", + "kind": 12, + "label": "~+", + "sortText": "0000", + "textEdit": { + "newText": "~+", + "range": { + "end": { "character": 24, "line": 0 }, + "start": { "character": 23, "line": 0 } + } + } + } + { + "detail": "float -> float", + "kind": 12, + "label": "~+.", + "sortText": "0001", + "textEdit": { + "newText": "~+.", + "range": { + "end": { "character": 24, "line": 0 }, + "start": { "character": 23, "line": 0 } + } + } + } + { + "detail": "int -> int", + "kind": 12, + "label": "~-", + "sortText": "0002", + "textEdit": { + "newText": "~-", + "range": { + "end": { "character": 24, "line": 0 }, + "start": { "character": 23, "line": 0 } + } + } + } + { + "detail": "float -> float", + "kind": 12, + "label": "~-.", + "sortText": "0003", + "textEdit": { + "newText": "~-.", + "range": { + "end": { "character": 24, "line": 0 }, + "start": { "character": 23, "line": 0 } + } + } + } + { + "detail": "'a -> 'b", + "kind": 5, + "label": "~f", + "sortText": "0004", + "textEdit": { + "newText": "~f", + "range": { + "end": { "character": 24, "line": 0 }, + "start": { "character": 23, "line": 0 } + } + } + } + |}] + +let%expect_test "works for polymorphic variants - function application context \ + - 1" = + let source = + {ocaml| +let f (_a: [`String | `Int of int]) = () + +let u = f `Str + |ocaml} + in + let position = Position.create ~line:3 ~character:14 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "`String", + "kind": 20, + "label": "`String", + "sortText": "0000", + "textEdit": { + "newText": "`String", + "range": { + "end": { "character": 14, "line": 3 }, + "start": { "character": 10, "line": 3 } + } + } + } + |}] + +let%expect_test "works for polymorphic variants - function application context \ + - 2" = + let source = + {ocaml| +let f (_a: [`String | `Int of int]) = () + +let u = f `In + |ocaml} + in + let position = Position.create ~line:3 ~character:13 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "`Int of int", + "kind": 20, + "label": "`Int", + "sortText": "0000", + "textEdit": { + "newText": "`Int", + "range": { + "end": { "character": 13, "line": 3 }, + "start": { "character": 10, "line": 3 } + } + } + } + |}] + +let%expect_test "works for polymorphic variants" = + let source = {ocaml| +type t = [ `Int | `String ] + +let x : t = `I + |ocaml} in + let position = Position.create ~line:3 ~character:15 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "`Int", + "kind": 20, + "label": "`Int", + "sortText": "0000", + "textEdit": { + "newText": "`Int", + "range": { + "end": { "character": 15, "line": 3 }, + "start": { "character": 13, "line": 3 } + } + } + } + |}] + +let%expect_test "completion for holes" = + let source = {ocaml|let u : int = _|ocaml} in + let position = Position.create ~line:0 ~character:15 in + let filter = + List.filter ~f:(fun (item : CompletionItem.t) -> + not (String.starts_with ~prefix:"__" item.label)) + in + print_completions ~pre_print:filter source position; + [%expect + {| + Completions: + { + "filterText": "_0", + "kind": 1, + "label": "0", + "sortText": "0000", + "textEdit": { + "newText": "0", + "range": { + "end": { "character": 15, "line": 0 }, + "start": { "character": 14, "line": 0 } + } + } + } + |}] + +let%expect_test "completes identifier at top level" = + let source = + {ocaml| +let somenum = 42 +let somestring = "hello" + +let () = + some +|ocaml} + in + let position = Position.create ~line:5 ~character:6 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "int", + "kind": 12, + "label": "somenum", + "sortText": "0000", + "textEdit": { + "newText": "somenum", + "range": { + "end": { "character": 6, "line": 5 }, + "start": { "character": 2, "line": 5 } + } + } + } + { + "detail": "string", + "kind": 12, + "label": "somestring", + "sortText": "0001", + "textEdit": { + "newText": "somestring", + "range": { + "end": { "character": 6, "line": 5 }, + "start": { "character": 2, "line": 5 } + } + } + } + |}] + +let%expect_test "completes from a module" = + let source = {ocaml|let f = List.m|ocaml} in + let position = Position.create ~line:0 ~character:14 in + print_completions source position; + [%expect + {| + Completions: + { + "detail": "('a -> 'b) -> 'a list -> 'b list", + "kind": 12, + "label": "map", + "sortText": "0000", + "textEdit": { + "newText": "map", + "range": { + "end": { "character": 14, "line": 0 }, + "start": { "character": 13, "line": 0 } + } + } + } + { + "detail": "('a -> 'b -> 'c) -> 'a list -> 'b list -> 'c list", + "kind": 12, + "label": "map2", + "sortText": "0001", + "textEdit": { + "newText": "map2", + "range": { + "end": { "character": 14, "line": 0 }, + "start": { "character": 13, "line": 0 } + } + } + } + { + "detail": "(int -> 'a -> 'b) -> 'a list -> 'b list", + "kind": 12, + "label": "mapi", + "sortText": "0002", + "textEdit": { + "newText": "mapi", + "range": { + "end": { "character": 14, "line": 0 }, + "start": { "character": 13, "line": 0 } + } + } + } + { + "detail": "'a -> 'a list -> bool", + "kind": 12, + "label": "mem", + "sortText": "0003", + "textEdit": { + "newText": "mem", + "range": { + "end": { "character": 14, "line": 0 }, + "start": { "character": 13, "line": 0 } + } + } + } + { + "detail": "'a -> ('a * 'b) list -> bool", + "kind": 12, + "label": "mem_assoc", + "sortText": "0004", + "textEdit": { + "newText": "mem_assoc", + "range": { + "end": { "character": 14, "line": 0 }, + "start": { "character": 13, "line": 0 } + } + } + } + { + "detail": "'a -> ('a * 'b) list -> bool", + "kind": 12, + "label": "mem_assq", + "sortText": "0005", + "textEdit": { + "newText": "mem_assq", + "range": { + "end": { "character": 14, "line": 0 }, + "start": { "character": 13, "line": 0 } + } + } + } + { + "detail": "'a -> 'a list -> bool", + "kind": 12, + "label": "memq", + "sortText": "0006", + "textEdit": { + "newText": "memq", + "range": { + "end": { "character": 14, "line": 0 }, + "start": { "character": 13, "line": 0 } + } + } + } + { + "detail": "('a -> 'a -> int) -> 'a list -> 'a list -> 'a list", + "kind": 12, + "label": "merge", + "sortText": "0007", + "textEdit": { + "newText": "merge", + "range": { + "end": { "character": 14, "line": 0 }, + "start": { "character": 13, "line": 0 } + } + } + }|}] + +let%expect_test "completes a module name" = + let source = {ocaml|let f = L|ocaml} in + let position = Position.create ~line:0 ~character:9 in + print_completions ~pre_print:(List.take 5) source position; + [%expect + {| + Completions: + { + "detail": "", + "kind": 9, + "label": "LargeFile", + "sortText": "0000", + "textEdit": { + "newText": "LargeFile", + "range": { + "end": { "character": 9, "line": 0 }, + "start": { "character": 8, "line": 0 } + } + } + } + { + "detail": "", + "kind": 9, + "label": "Lazy", + "sortText": "0001", + "textEdit": { + "newText": "Lazy", + "range": { + "end": { "character": 9, "line": 0 }, + "start": { "character": 8, "line": 0 } + } + } + } + { + "detail": "", + "kind": 9, + "label": "Lexing", + "sortText": "0002", + "textEdit": { + "newText": "Lexing", + "range": { + "end": { "character": 9, "line": 0 }, + "start": { "character": 8, "line": 0 } + } + } + } + { + "detail": "", + "kind": 9, + "label": "List", + "sortText": "0003", + "textEdit": { + "newText": "List", + "range": { + "end": { "character": 9, "line": 0 }, + "start": { "character": 8, "line": 0 } + } + } + } + { + "detail": "", + "kind": 9, + "label": "ListLabels", + "sortText": "0004", + "textEdit": { + "newText": "ListLabels", + "range": { + "end": { "character": 9, "line": 0 }, + "start": { "character": 8, "line": 0 } + } + } + } + |}] + +let%expect_test "completion doesn't autocomplete record fields" = + let source = + {ocaml| + type r = { + x: int; + y: string + } + + let _ = + |ocaml} + in + let position = Position.create ~line:5 ~character:8 in + print_completions + ~pre_print: + (List.filter ~f:(fun (compl : CompletionItem.t) -> + compl.label = "x" || compl.label = "y")) + source + position; + + (* We expect 0 completions*) + [%expect {| No completions |}] diff --git a/ocaml-lsp-server/test/e2e-new/dune b/ocaml-lsp-server/test/e2e-new/dune index 094fad3af..130105b7d 100644 --- a/ocaml-lsp-server/test/e2e-new/dune +++ b/ocaml-lsp-server/test/e2e-new/dune @@ -47,6 +47,7 @@ action_inline action_mark_remove code_actions + completion doc_to_md document_flow for_ppx diff --git a/ocaml-lsp-server/test/e2e-new/lsp_helpers.ml b/ocaml-lsp-server/test/e2e-new/lsp_helpers.ml new file mode 100644 index 000000000..a56ba7d63 --- /dev/null +++ b/ocaml-lsp-server/test/e2e-new/lsp_helpers.ml @@ -0,0 +1,55 @@ +open Test.Import + +let open_document ~client ~uri ~source = + let textDocument = + TextDocumentItem.create ~uri ~languageId:"ocaml" ~version:0 ~text:source + in + Client.notification + client + (TextDocumentDidOpen (DidOpenTextDocumentParams.create ~textDocument)) + +let iter_lsp_response ?(prep = fun _ -> Fiber.return ()) ?(path = "foo.ml") + ~makeRequest ~source k = + let got_diagnostics = Fiber.Ivar.create () in + let handler = + Client.Handler.make + ~on_notification: + (fun _ -> function + | PublishDiagnostics _ -> ( + let* diag = Fiber.Ivar.peek got_diagnostics in + match diag with + | Some _ -> Fiber.return () + | None -> Fiber.Ivar.fill got_diagnostics ()) + | _ -> Fiber.return ()) + () + in + Test.run ~handler @@ fun client -> + let run_client () = + let capabilities = + let window = + let showDocument = + ShowDocumentClientCapabilities.create ~support:true + in + WindowClientCapabilities.create ~showDocument () + in + ClientCapabilities.create ~window () + in + Client.start client (InitializeParams.create ~capabilities ()) + in + let run = + let* (_ : InitializeResult.t) = Client.initialized client in + let uri = DocumentUri.of_path path in + let* () = prep client in + let* () = open_document ~client ~uri ~source in + let+ resp = + let request = + let textDocument = TextDocumentIdentifier.create ~uri in + makeRequest textDocument + in + + Client.request client request + in + k resp + in + Fiber.fork_and_join_unit run_client (fun () -> + run >>> Fiber.Ivar.read got_diagnostics >>> Client.stop client) diff --git a/ocaml-lsp-server/test/e2e-new/lsp_helpers.mli b/ocaml-lsp-server/test/e2e-new/lsp_helpers.mli new file mode 100644 index 000000000..33802cde5 --- /dev/null +++ b/ocaml-lsp-server/test/e2e-new/lsp_helpers.mli @@ -0,0 +1,16 @@ +open Test.Import + +(** Opens a document with the language server. This must be done before trying + to access it *) +val open_document : + client:'a Client.t -> uri:DocumentUri.t -> source:string -> unit Fiber.t + +(** Performs the request you return from the makeRequest function and then gives + it the the handler function you provide *) +val iter_lsp_response : + ?prep:(unit Client.t -> unit Fiber.t) + -> ?path:string + -> makeRequest:(TextDocumentIdentifier.t -> 'a Client.out_request) + -> source:string + -> ('a -> unit) + -> unit diff --git a/ocaml-lsp-server/test/e2e/__tests__/textDocument-completion.test.ts b/ocaml-lsp-server/test/e2e/__tests__/textDocument-completion.test.ts deleted file mode 100644 index f1ab58d79..000000000 --- a/ocaml-lsp-server/test/e2e/__tests__/textDocument-completion.test.ts +++ /dev/null @@ -1,701 +0,0 @@ -import outdent from "outdent"; -import * as LanguageServer from "./../src/LanguageServer"; -import * as Protocol from "vscode-languageserver-protocol"; - -import * as Types from "vscode-languageserver-types"; -import { Position } from "vscode-languageserver-types"; - -const describe_opt = LanguageServer.ocamlVersionGEq("4.08.0") - ? describe - : xdescribe; - -describe_opt("textDocument/completion", () => { - let languageServer: LanguageServer.LanguageServer; - - function openDocument(source: string) { - return languageServer.sendNotification( - Protocol.DidOpenTextDocumentNotification.type, - { - textDocument: Types.TextDocumentItem.create( - "file:///test.ml", - "ocaml", - 0, - source, - ), - }, - ); - } - - async function queryCompletion(position: Types.Position) { - let result = - (await languageServer.sendRequest(Protocol.CompletionRequest.type, { - textDocument: Types.TextDocumentIdentifier.create("file:///test.ml"), - position, - })) ?? []; - - if ("items" in result) { - return result.items.map((item) => { - return { - label: item.label, - textEdit: item.textEdit, - }; - }); - } else { - result.map((item) => { - return { - label: item.label, - textEdit: item.textEdit, - }; - }); - } - } - - beforeEach(async () => { - languageServer = await LanguageServer.startAndInitialize(); - }); - - afterEach(async () => { - await LanguageServer.exit(languageServer); - }); - - it("can start completion at arbitrary position (before the dot)", async () => { - openDocument(outdent` - Strin.func - `); - - let items = await queryCompletion(Types.Position.create(0, 5)); - expect(items).toMatchObject([ - { label: "String" }, - { label: "StringLabels" }, - ]); - }); - - it("can start completion at arbitrary position", async () => { - openDocument(outdent` - StringLabels - `); - - let items = await queryCompletion(Types.Position.create(0, 6)); - expect(items).toMatchObject([ - { label: "String" }, - { label: "StringLabels" }, - ]); - }); - - it("can start completion at arbitrary position 2", async () => { - openDocument(outdent` - StringLabels - `); - - let items = await queryCompletion(Types.Position.create(0, 7)); - expect(items).toMatchObject([{ label: "StringLabels" }]); - }); - - it("can complete symbol passed as a named argument", async () => { - openDocument(outdent` -let g ~f = f 0 in -g ~f:ig - `); - - let items = await queryCompletion(Types.Position.create(1, 7)); - expect(items).toMatchInlineSnapshot(` - Array [ - Object { - "label": "ignore", - "textEdit": Object { - "newText": "ignore", - "range": Object { - "end": Object { - "character": 7, - "line": 1, - }, - "start": Object { - "character": 5, - "line": 1, - }, - }, - }, - }, - ] - `); - }); - - it("can complete symbol passed as a named argument - 2", async () => { - openDocument(outdent` -module M = struct let igfoo _x = () end -let g ~f = f 0 in -g ~f:M.ig - `); - - let items = await queryCompletion(Types.Position.create(2, 9)); - expect(items).toMatchInlineSnapshot(` - Array [ - Object { - "label": "igfoo", - "textEdit": Object { - "newText": "igfoo", - "range": Object { - "end": Object { - "character": 9, - "line": 2, - }, - "start": Object { - "character": 7, - "line": 2, - }, - }, - }, - }, - ] - `); - }); - - it("can complete symbol passed as an optional argument", async () => { - openDocument(outdent` -let g ?f = f in -g ?f:ig - `); - - let items = await queryCompletion(Types.Position.create(1, 7)); - expect(items).toMatchInlineSnapshot(` - Array [ - Object { - "label": "ignore", - "textEdit": Object { - "newText": "ignore", - "range": Object { - "end": Object { - "character": 7, - "line": 1, - }, - "start": Object { - "character": 5, - "line": 1, - }, - }, - }, - }, - ] - `); - }); - - it("can complete symbol passed as a optional argument - 2", async () => { - openDocument(outdent` -module M = struct let igfoo _x = () end -let g ?f = f in -g ?f:M.ig - `); - - let items = await queryCompletion(Types.Position.create(2, 9)); - expect(items).toMatchInlineSnapshot(` - Array [ - Object { - "label": "igfoo", - "textEdit": Object { - "newText": "igfoo", - "range": Object { - "end": Object { - "character": 9, - "line": 2, - }, - "start": Object { - "character": 7, - "line": 2, - }, - }, - }, - }, - ] - `); - }); - - it("completes identifier at top level", async () => { - openDocument(outdent` - let somenum = 42 - let somestring = "hello" - - let () = - some - `); - - let items = await queryCompletion(Types.Position.create(4, 6)); - expect(items).toMatchObject([ - { label: "somenum" }, - { label: "somestring" }, - ]); - }); - - it("completes identifier after completion-triggering character", async () => { - openDocument(outdent` - module Test = struct - let somenum = 42 - let somestring = "hello" - end - - let x = Test. - `); - - let items = await queryCompletion(Types.Position.create(5, 13)); - - expect(items).toMatchInlineSnapshot(` - Array [ - Object { - "label": "somenum", - "textEdit": Object { - "newText": "somenum", - "range": Object { - "end": Object { - "character": 13, - "line": 5, - }, - "start": Object { - "character": 13, - "line": 5, - }, - }, - }, - }, - Object { - "label": "somestring", - "textEdit": Object { - "newText": "somestring", - "range": Object { - "end": Object { - "character": 13, - "line": 5, - }, - "start": Object { - "character": 13, - "line": 5, - }, - }, - }, - }, - ] - `); - }); - - it("completes infix operators", async () => { - openDocument(outdent` - let (>>|) = (+) - let y = 1 > - `); - - let items = await queryCompletion(Types.Position.create(1, 11)); - expect(items).toMatchInlineSnapshot(` - Array [ - Object { - "label": ">>|", - "textEdit": Object { - "newText": ">>|", - "range": Object { - "end": Object { - "character": 11, - "line": 1, - }, - "start": Object { - "character": 10, - "line": 1, - }, - }, - }, - }, - Object { - "label": ">", - "textEdit": Object { - "newText": ">", - "range": Object { - "end": Object { - "character": 11, - "line": 1, - }, - "start": Object { - "character": 10, - "line": 1, - }, - }, - }, - }, - Object { - "label": ">=", - "textEdit": Object { - "newText": ">=", - "range": Object { - "end": Object { - "character": 11, - "line": 1, - }, - "start": Object { - "character": 10, - "line": 1, - }, - }, - }, - }, - ] - `); - }); - - it("completes from a module", async () => { - openDocument(outdent` - let f = List.m - `); - - let items = await queryCompletion(Types.Position.create(0, 14)); - expect(items).toMatchObject([ - { label: "map" }, - { label: "map2" }, - { label: "mapi" }, - { label: "mem" }, - { label: "mem_assoc" }, - { label: "mem_assq" }, - { label: "memq" }, - { label: "merge" }, - ]); - }); - - it("completes a module name", async () => { - openDocument(outdent` - let f = L - `); - - let items = (await queryCompletion(Types.Position.create(0, 9))) ?? []; - let items_top5 = items.slice(0, 5); - expect(items_top5).toMatchObject([ - { label: "LargeFile" }, - { label: "Lazy" }, - { label: "Lexing" }, - { label: "List" }, - { label: "ListLabels" }, - ]); - }); - - it("completes without prefix", async () => { - openDocument(outdent` - let somenum = 42 - let somestring = "hello" - - let plus_42 (x:int) (y:int) = - somenum + `); - - let items = (await queryCompletion(Types.Position.create(4, 12))) ?? []; - let items_top5 = items.slice(0, 5); - expect(items_top5).toMatchInlineSnapshot(` - Array [ - Object { - "label": "somenum", - "textEdit": Object { - "newText": "somenum", - "range": Object { - "end": Object { - "character": 12, - "line": 4, - }, - "start": Object { - "character": 12, - "line": 4, - }, - }, - }, - }, - Object { - "label": "x", - "textEdit": Object { - "newText": "x", - "range": Object { - "end": Object { - "character": 12, - "line": 4, - }, - "start": Object { - "character": 12, - "line": 4, - }, - }, - }, - }, - Object { - "label": "y", - "textEdit": Object { - "newText": "y", - "range": Object { - "end": Object { - "character": 12, - "line": 4, - }, - "start": Object { - "character": 12, - "line": 4, - }, - }, - }, - }, - Object { - "label": "max_int", - "textEdit": Object { - "newText": "max_int", - "range": Object { - "end": Object { - "character": 12, - "line": 4, - }, - "start": Object { - "character": 12, - "line": 4, - }, - }, - }, - }, - Object { - "label": "min_int", - "textEdit": Object { - "newText": "min_int", - "range": Object { - "end": Object { - "character": 12, - "line": 4, - }, - "start": Object { - "character": 12, - "line": 4, - }, - }, - }, - }, - ] - `); - }); - - it("completes labels", async () => { - openDocument("let f = ListLabels.map ~"); - - let items = (await queryCompletion(Types.Position.create(0, 24))) ?? []; - let items_top5 = items.slice(0, 10); - expect(items_top5).toMatchInlineSnapshot(` - Array [ - Object { - "label": "~+", - "textEdit": Object { - "newText": "~+", - "range": Object { - "end": Object { - "character": 24, - "line": 0, - }, - "start": Object { - "character": 23, - "line": 0, - }, - }, - }, - }, - Object { - "label": "~+.", - "textEdit": Object { - "newText": "~+.", - "range": Object { - "end": Object { - "character": 24, - "line": 0, - }, - "start": Object { - "character": 23, - "line": 0, - }, - }, - }, - }, - Object { - "label": "~-", - "textEdit": Object { - "newText": "~-", - "range": Object { - "end": Object { - "character": 24, - "line": 0, - }, - "start": Object { - "character": 23, - "line": 0, - }, - }, - }, - }, - Object { - "label": "~-.", - "textEdit": Object { - "newText": "~-.", - "range": Object { - "end": Object { - "character": 24, - "line": 0, - }, - "start": Object { - "character": 23, - "line": 0, - }, - }, - }, - }, - Object { - "label": "~f", - "textEdit": Object { - "newText": "~f", - "range": Object { - "end": Object { - "character": 24, - "line": 0, - }, - "start": Object { - "character": 23, - "line": 0, - }, - }, - }, - }, - ] - `); - }); - - it("completion doesn't autocomplete record fields", async () => { - openDocument(outdent` - type r = { - x: int; - y: string - } - - let _ = - `); - - let items = (await queryCompletion(Types.Position.create(5, 8))) ?? []; - expect( - items.filter((compl) => compl.label === "x" || compl.label === "y"), - ).toHaveLength(0); - }); - - it("works for polymorphic variants - function application context - 1", async () => { - openDocument(outdent` -let f (_a: [\`String | \`Int of int]) = () - -let u = f \`Str - `); - - let items = await queryCompletion(Position.create(2, 15)); - - expect(items).toMatchInlineSnapshot(` - Array [ - Object { - "label": "\`String", - "textEdit": Object { - "newText": "\`String", - "range": Object { - "end": Object { - "character": 15, - "line": 2, - }, - "start": Object { - "character": 11, - "line": 2, - }, - }, - }, - }, - ] - `); - }); - - it("works for polymorphic variants - function application context - 2", async () => { - openDocument(outdent` -let f (_a: [\`String | \`Int of int]) = () - -let u = f \`In - `); - - let items = await queryCompletion(Position.create(2, 14)); - - expect(items).toMatchInlineSnapshot(` - Array [ - Object { - "label": "\`Int", - "textEdit": Object { - "newText": "\`Int", - "range": Object { - "end": Object { - "character": 14, - "line": 2, - }, - "start": Object { - "character": 11, - "line": 2, - }, - }, - }, - }, - ] - `); - }); - - it("works for polymorphic variants", async () => { - openDocument(outdent` -type t = [ \`Int | \`String ] - -let x : t = \`I - `); - - let items = await queryCompletion(Position.create(2, 15)); - - expect(items).toMatchInlineSnapshot(` - Array [ - Object { - "label": "\`Int", - "textEdit": Object { - "newText": "\`Int", - "range": Object { - "end": Object { - "character": 15, - "line": 2, - }, - "start": Object { - "character": 13, - "line": 2, - }, - }, - }, - }, - ] - `); - }); - - it("completion for holes", async () => { - openDocument(outdent` -let u : int = _ -`); - - let items = (await queryCompletion(Types.Position.create(0, 15))) ?? []; - - items = items.filter( - (completionItem) => !completionItem.label.startsWith("__"), - ); - - expect(items).toMatchInlineSnapshot(` - Array [ - Object { - "label": "0", - "textEdit": Object { - "newText": "0", - "range": Object { - "end": Object { - "character": 15, - "line": 0, - }, - "start": Object { - "character": 14, - "line": 0, - }, - }, - }, - }, - ] - `); - }); -}); diff --git a/ocaml-lsp-server/test/position_prefix_tests.ml b/ocaml-lsp-server/test/position_prefix_tests.ml new file mode 100644 index 000000000..2a66e4293 --- /dev/null +++ b/ocaml-lsp-server/test/position_prefix_tests.ml @@ -0,0 +1,86 @@ +open Ocaml_lsp_server + +(** An extensive set of tests to validation that the prefix_op_position function + correctly returns prefixes merlin is happy with for all the odd ocaml syntax + that exists *) + +let prefix_test ?(short_path = false) document position = + let document_source = Testing.Merlin_kernel.Msource.make document in + let prefix = + Testing.Compl.prefix_of_position ~short_path document_source position + in + Printf.printf "%s " prefix + +let%expect_test "varible in labelled pararm" = + prefix_test + "let map = ListLabels.map\n\nlet _ = map ~f:Int.abs\n" + (`Logical (3, 22)); + [%expect "Int.abs"] + +let%expect_test "labelled pararm" = + prefix_test "let mem = ListLabels.mem\n\nlet _ = mem ~se" (`Logical (3, 15)); + [%expect "~se"] + +let%expect_test "completion of enum" = + prefix_test "match kind with\n| `Va" (`Logical (2, 21)); + [%expect "`Va"] + +let%expect_test "labelled pararm" = + prefix_test "let mem = ListLabels.mem\n\nlet _ = mem ~" (`Logical (3, 13)); + [%expect "~"] + +let%expect_test "correctly handle typed hole for code action" = + prefix_test "let x = _" (`Logical (1, 9)); + [%expect "_"] + +let%expect_test "complete at infix" = + prefix_test "let x = 1|>." (`Logical (1, 11)); + [%expect "|>"] + +let%expect_test "complete at arbitrary position" = + prefix_test "Strin.func" (`Logical (1, 5)); + [%expect "Strin"] + +let%expect_test "completion prefix multiple dots test" = + prefix_test "[1;2]|>Core.List.ma\n" (`Logical (1, 19)); + [%expect "Core.List.ma"] + +let%expect_test "completion prefix touching infix test" = + prefix_test "[1;2]|>List.ma\n" (`Logical (1, 14)); + [%expect "List.ma"] + +let%expect_test "completion prefix dot infix test" = + prefix_test "[1;2]|>.List.ma\n" (`Logical (1, 15)); + [%expect "List.ma"] + +let%expect_test "completion against bracket" = + prefix_test "(List.ma)\n" (`Logical (1, 8)); + [%expect "List.ma"] + +let%expect_test "completion prefix with space test" = + prefix_test "[1;2] |> List.ma\n" (`Logical (1, 16)); + [%expect "List.ma"] + +let%expect_test "short path prefix" = + prefix_test ~short_path:true "[1;2] |> Core.List.ma\n" (`Logical (1, 22)); + [%expect "ma"] + +let%expect_test "Space in dot chain" = + prefix_test "[1;2] |> Other. Thing.Core .List . ma\n" (`Logical (1, 37)); + [%expect "Other.Thing.Core.List.ma"] + +let%expect_test "newline in dot chain" = + prefix_test "[1;2] |> Core.\nList.\nma\n" (`Logical (3, 2)); + [%expect "Core.List.ma"] + +let%expect_test "let%lwt thing" = + prefix_test "let%lwt" (`Logical (1, 7)); + [%expect "let%lwt"] + +let%expect_test "let+ thing" = + prefix_test "let+" (`Logical (1, 4)); + [%expect "let+"] + +let%expect_test "let+$% thing" = + prefix_test "let+$%" (`Logical (1, 6)); + [%expect "let+$%"]