Skip to content

Commit e9df689

Browse files
authored
Fix jump to hole on destruct/construct to jump only within the new text (#559)
* add `Document.new_range_on_replace` * jump to a hole on destruct/construct only if the hole is within the generated text * make `Compl.complete` independent, ie it takes state and completion params and returns completion response * send jump to next hole only if the client has an experimental capability "jumpToNextHole" * add support for experimental client capabilities * jump to next hole for any client that has an experimental capability "jumpToNextHole" * rename `CustomCommands` to `Custom_commands`
1 parent a46315b commit e9df689

File tree

11 files changed

+165
-70
lines changed

11 files changed

+165
-70
lines changed

ocaml-lsp-server/src/client.ml

+35-23
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,45 @@
11
open Import
22

3+
module Experimental_capabilities = struct
4+
type t = bool
5+
6+
let of_opt_json (json : Json.t option) =
7+
match json with
8+
| Some (`Assoc fields) ->
9+
Json.field fields "jumpToNextHole" Json.Conv.bool_of_yojson
10+
|> Option.value ~default:false
11+
| _ -> false
12+
13+
let supportsJumpToNextHole t = t
14+
end
15+
316
module Vscode = struct
417
module Commands = struct
518
let triggerSuggest =
619
Command.create ~title:"Trigger Suggest"
720
~command:"editor.action.triggerSuggest" ()
21+
end
22+
end
823

9-
module Custom = struct
10-
let next_hole ?start_position ~notify_if_no_hole () =
11-
let arguments =
12-
let arg_obj_fields =
13-
let notif_json =
14-
Some ("notify-if-no-hole", Json.bool notify_if_no_hole)
15-
in
16-
let pos_json =
17-
Option.map start_position ~f:(fun p ->
18-
("position", Position.yojson_of_t p))
19-
in
20-
List.filter_opt [ pos_json; notif_json ]
21-
in
22-
match arg_obj_fields with
23-
| [] -> [] (* no arguments -- the extension uses defaults *)
24-
| fields ->
25-
(* the use of a (json) object as the first and single argument to
26-
the command is intended *)
27-
[ `Assoc fields ]
24+
module Custom_commands = struct
25+
let next_hole ?in_range ~notify_if_no_hole () =
26+
let arguments =
27+
let arg_obj_fields =
28+
let notif_json =
29+
Some ("shouldNotifyIfNoHole", Json.bool notify_if_no_hole)
2830
in
29-
Command.create ~title:"Jump to Next Hole" ~command:"ocaml.next-hole"
30-
~arguments ()
31-
end
32-
end
31+
let in_range_json =
32+
Option.map in_range ~f:(fun r -> ("inRange", Range.yojson_of_t r))
33+
in
34+
List.filter_opt [ in_range_json; notif_json ]
35+
in
36+
match arg_obj_fields with
37+
| [] -> [] (* no arguments -- the extension uses defaults *)
38+
| fields ->
39+
(* the use of a (json) object as the first and single argument to the
40+
command is intended *)
41+
[ `Assoc fields ]
42+
in
43+
Command.create ~title:"Jump to Next Hole" ~command:"ocaml.next-hole"
44+
~arguments ()
3345
end

ocaml-lsp-server/src/client.mli

+25-17
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@ open Import
33
(** This module is a collection of client-specific functionality (client =
44
editor) *)
55

6+
module Experimental_capabilities : sig
7+
(** Module to store experimental client capabilities *)
8+
9+
type t
10+
11+
val of_opt_json : Json.t option -> t
12+
13+
val supportsJumpToNextHole : t -> bool
14+
end
15+
616
module Vscode : sig
717
(** A collection of VS Code editor commands.
818
@@ -12,24 +22,22 @@ module Vscode : sig
1222
(** [editor.action.triggerSuggest] is a vscode-specific command, which
1323
triggers the completion request on all completion providers *)
1424
val triggerSuggest : Command.t
25+
end
26+
end
1527

16-
(** Represents custom commands, i.e., commands added by a certain extension.
17-
18-
Currently, the module includes custom commands introduced by "OCaml
19-
Platform" extension *)
20-
module Custom : sig
21-
(** Request client cursor to jump to the next hole.
28+
(** Represents custom commands, i.e., commands added by a certain extension. *)
29+
module Custom_commands : sig
30+
(** Request client cursor to jump to the next hole.
2231
23-
Looks for a hole starting at position [start_position], if provided;
24-
otherwise, uses the cursor position.
32+
See the documentation for this command in [vscode-ocaml-platform] for
33+
details.
2534
26-
Will not show a pop-up notification if [notify-if-no-hole] is set to
27-
[false] (the default value is [true]) *)
28-
val next_hole :
29-
?start_position:Position.t
30-
-> notify_if_no_hole:bool
31-
-> unit
32-
-> Command.t
33-
end
34-
end
35+
@param in_range
36+
to pick a hole only in a given range; if omitted, the whole document is
37+
used
38+
@param notify_if_no_hole
39+
specifies whether we want the client to show the user a message if there
40+
is no hole to jump to *)
41+
val next_hole :
42+
?in_range:Range.t -> notify_if_no_hole:bool -> unit -> Command.t
3543
end

ocaml-lsp-server/src/code_actions/action_destruct.ml

+18-6
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ let action_kind = "destruct"
55

66
let kind = CodeActionKind.Other action_kind
77

8-
let code_action_of_case_analysis doc uri (loc, newText) =
8+
let code_action_of_case_analysis ~supportsJumpToNextHole doc uri (loc, newText)
9+
=
910
let range : Range.t = Range.of_loc loc in
11+
let textedit : TextEdit.t = { range; newText } in
1012
let edit : WorkspaceEdit.t =
11-
let textedit : TextEdit.t = { range; newText } in
1213
let version = Document.version doc in
1314
let textDocument =
1415
OptionalVersionedTextDocumentIdentifier.create ~uri ~version ()
@@ -20,11 +21,16 @@ let code_action_of_case_analysis doc uri (loc, newText) =
2021
in
2122
let title = String.capitalize_ascii action_kind in
2223
let command =
23-
Client.Vscode.Commands.Custom.next_hole ~start_position:range.start
24-
~notify_if_no_hole:false ()
24+
if supportsJumpToNextHole then
25+
Some
26+
(Client.Custom_commands.next_hole
27+
~in_range:(Range.resize_for_edit textedit)
28+
~notify_if_no_hole:false ())
29+
else
30+
None
2531
in
2632
CodeAction.create ~title ~kind:(CodeActionKind.Other action_kind) ~edit
27-
~command ~isPreferred:false ()
33+
?command ~isPreferred:false ()
2834

2935
let code_action (state : State.t) doc (params : CodeActionParams.t) =
3036
let uri = params.textDocument.uri in
@@ -47,7 +53,13 @@ let code_action (state : State.t) doc (params : CodeActionParams.t) =
4753
| Ok formatted_text -> formatted_text
4854
| Error _ -> newText
4955
in
50-
Some (code_action_of_case_analysis doc uri (loc, newText))
56+
let supportsJumpToNextHole =
57+
State.experimental_client_capabilities state
58+
|> Client.Experimental_capabilities.supportsJumpToNextHole
59+
in
60+
Some
61+
(code_action_of_case_analysis ~supportsJumpToNextHole doc uri
62+
(loc, newText))
5163
| Error
5264
{ exn =
5365
( Merlin_analysis.Destruct.Wrong_parent _ | Query_commands.No_nodes

ocaml-lsp-server/src/compl.ml

+21-9
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ module Complete_with_construct = struct
215215
None
216216
| Error exn -> Exn_with_backtrace.reraise exn
217217

218-
let process_dispatch_resp = function
218+
let process_dispatch_resp ~supportsJumpToNextHole = function
219219
| None -> []
220220
| Some (loc, constructed_exprs) ->
221221
let range = Range.of_loc loc in
@@ -231,19 +231,26 @@ module Complete_with_construct = struct
231231
in
232232
let completionItem_of_constructed_expr idx expr =
233233
let expr_wo_parens = deparen_constr_expr expr in
234-
let textEdit = `TextEdit { TextEdit.range; newText = expr } in
234+
let edit = { TextEdit.range; newText = expr } in
235235
let command =
236-
Client.Vscode.Commands.Custom.next_hole ~start_position:range.start
237-
~notify_if_no_hole:false ()
236+
if supportsJumpToNextHole then
237+
Some
238+
(Client.Custom_commands.next_hole
239+
~in_range:(Range.resize_for_edit edit)
240+
~notify_if_no_hole:false ())
241+
else
242+
None
238243
in
239-
CompletionItem.create ~label:expr_wo_parens ~textEdit
244+
CompletionItem.create ~label:expr_wo_parens ~textEdit:(`TextEdit edit)
240245
~filterText:("_" ^ expr) ~kind:CompletionItemKind.Text
241-
~sortText:(sortText_of_index idx) ~command ()
246+
~sortText:(sortText_of_index idx) ?command ()
242247
in
243248
List.mapi constructed_exprs ~f:completionItem_of_constructed_expr
244249
end
245250

246-
let complete doc pos =
251+
let complete (state : State.t)
252+
({ textDocument = { uri }; position = pos; _ } : CompletionParams.t) =
253+
let doc = Document_store.get state.store uri in
247254
let+ items =
248255
let position = Position.logical pos in
249256
let prefix =
@@ -272,15 +279,20 @@ let complete doc pos =
272279
(construct_cmd_resp, compl_by_prefix_resp))
273280
in
274281
let construct_completionItems =
275-
Complete_with_construct.process_dispatch_resp construct_cmd_resp
282+
let supportsJumpToNextHole =
283+
State.experimental_client_capabilities state
284+
|> Client.Experimental_capabilities.supportsJumpToNextHole
285+
in
286+
Complete_with_construct.process_dispatch_resp ~supportsJumpToNextHole
287+
construct_cmd_resp
276288
in
277289
let compl_by_prefix_completionItems =
278290
Complete_by_prefix.process_dispatch_resp doc pos compl_by_prefix_resp
279291
in
280292
construct_completionItems @ compl_by_prefix_completionItems
281293
|> reindex_sortText |> preselect_first
282294
in
283-
`CompletionList (CompletionList.create ~isIncomplete:false ~items)
295+
Some (`CompletionList (CompletionList.create ~isIncomplete:false ~items))
284296

285297
let format_doc ~markdown doc =
286298
match markdown with

ocaml-lsp-server/src/compl.mli

+3-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ module Resolve : sig
1313
end
1414

1515
val complete :
16-
Document.t -> Position.t -> [> `CompletionList of CompletionList.t ] Fiber.t
16+
State.t
17+
-> CompletionParams.t
18+
-> [> `CompletionList of CompletionList.t ] option Fiber.t
1719

1820
(** creates a server response for ["completionItem/resolve"] *)
1921
val resolve :

ocaml-lsp-server/src/ocaml_lsp_server.ml

+2-7
Original file line numberDiff line numberDiff line change
@@ -837,13 +837,8 @@ let ocaml_on_request :
837837
definition_query rpc state uri position (fun pos ->
838838
Query_protocol.Locate_type pos))
839839
()
840-
| TextDocumentCompletion { textDocument = { uri }; position; _ } ->
841-
later
842-
(fun _ () ->
843-
let doc = Document_store.get store uri in
844-
let+ resp = Compl.complete doc position in
845-
Some resp)
846-
()
840+
| TextDocumentCompletion params ->
841+
later (fun _ () -> Compl.complete state params) ()
847842
| TextDocumentPrepareRename { textDocument = { uri }; position } ->
848843
later
849844
(fun _ () ->

ocaml-lsp-server/src/range.ml

+18
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,21 @@ let of_loc (loc : Loc.t) : t =
2424
let+ end_ = Position.of_lexical_position loc.loc_end in
2525
{ start; end_ })
2626
|> Option.value ~default:first_line
27+
28+
let resize_for_edit { TextEdit.range; newText } =
29+
let lines = String.split_lines newText in
30+
match lines with
31+
| [] -> { range with end_ = range.start }
32+
| several_lines ->
33+
let end_ =
34+
let start = range.start in
35+
let line = start.line + List.length several_lines - 1 in
36+
let character =
37+
let last_line_len =
38+
List.last several_lines |> Option.value_exn |> String.length
39+
in
40+
start.character + last_line_len
41+
in
42+
{ Position.line; character }
43+
in
44+
{ range with end_ }

ocaml-lsp-server/src/range.mli

+6
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,9 @@ val compare_size : t -> t -> Ordering.t
1111
val first_line : t
1212

1313
val of_loc : Loc.t -> t
14+
15+
(** [resize_for_edit edit] returns shrunk, unchanged, or extended [edit.range]
16+
depending on the size of [edit.newText], e.g., if [edit.newText] contains
17+
less characters than [edit.range], the new range is shrunk to fit
18+
[edit.newText] only. *)
19+
val resize_for_edit : TextEdit.t -> t

ocaml-lsp-server/src/state.ml

+17-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ type init =
66
{ params : InitializeParams.t
77
; workspaces : Workspaces.t
88
; dune : Dune.t
9+
; exp_client_caps : Client.Experimental_capabilities.t
910
}
1011

1112
type t =
@@ -64,7 +65,17 @@ let dune t =
6465

6566
let initialize t params workspaces dune =
6667
assert (t.init = Uninitialized);
67-
{ t with init = Initialized { params; workspaces; dune } }
68+
{ t with
69+
init =
70+
Initialized
71+
{ params
72+
; workspaces
73+
; dune
74+
; exp_client_caps =
75+
Client.Experimental_capabilities.of_opt_json
76+
params.capabilities.experimental
77+
}
78+
}
6879

6980
let modify_workspaces t ~f =
7081
let init =
@@ -77,6 +88,11 @@ let modify_workspaces t ~f =
7788

7889
let client_capabilities t = (initialize_params t).capabilities
7990

91+
let experimental_client_capabilities t =
92+
match t.init with
93+
| Uninitialized -> assert false
94+
| Initialized { exp_client_caps; _ } -> exp_client_caps
95+
8096
let log_msg server ~type_ ~message =
8197
let state = Server.state server in
8298
task_if_running state.detached ~f:(fun () ->

ocaml-lsp-server/src/state.mli

+7-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ type init =
66
{ params : InitializeParams.t
77
; workspaces : Workspaces.t
88
; dune : Dune.t
9+
; exp_client_caps : Client.Experimental_capabilities.t
910
}
1011

1112
type t =
@@ -47,9 +48,14 @@ val dune : t -> Dune.t
4748

4849
val modify_workspaces : t -> f:(Workspaces.t -> Workspaces.t) -> t
4950

50-
(** @return client capabilities passed from the client in [InitializeParams]
51+
(** @return
52+
client capabilities passed from the client in [InitializeParams]; use
53+
[exp_client_caps] to get {i experimental} client capabilities.
5154
@raise Assertion_failure if the [t.init] is [Uninitialized] *)
5255
val client_capabilities : t -> ClientCapabilities.t
5356

57+
(** @return experimental client capabilities *)
58+
val experimental_client_capabilities : t -> Client.Experimental_capabilities.t
59+
5460
val log_msg :
5561
t Server.t -> type_:MessageType.t -> message:string -> unit Fiber.t

ocaml-lsp-server/test/e2e/__tests__/textDocument-codeAction.test.ts

+13-5
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ describe("textDocument/codeAction", () => {
6060
}
6161

6262
beforeEach(async () => {
63-
languageServer = await LanguageServer.startAndInitialize();
63+
languageServer = await LanguageServer.startAndInitialize({
64+
capabilities: { experimental: { jumpToNextHole: true } },
65+
});
6466
});
6567

6668
afterEach(async () => {
@@ -102,11 +104,17 @@ let f (x : t) = x
102104
"command": Object {
103105
"arguments": Array [
104106
Object {
105-
"notify-if-no-hole": false,
106-
"position": Object {
107-
"character": 16,
108-
"line": 2,
107+
"inRange": Object {
108+
"end": Object {
109+
"character": 54,
110+
"line": 2,
111+
},
112+
"start": Object {
113+
"character": 16,
114+
"line": 2,
115+
},
109116
},
117+
"shouldNotifyIfNoHole": false,
110118
},
111119
],
112120
"command": "ocaml.next-hole",

0 commit comments

Comments
 (0)