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+$%"]