Skip to content

Commit df81ac2

Browse files
committed
fix(code-actions): offer destruct-line on inline matches
Signed-off-by: Rudi Grinberg <me@rgrinberg.com>
1 parent a84c6ed commit df81ac2

10 files changed

Lines changed: 476 additions & 20 deletions

File tree

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
(#1851, @rgrinberg)
1212
- Advertise semantic-token support only to clients that support the relative
1313
token format. (#1853, @rgrinberg)
14+
- Offer the `destruct-line` code action on inline match expressions with
15+
incomplete cases. (#1829, fixes #1595, @rgrinberg)
1416
- Replace a lone polymorphic-variant backtick when applying completions.
1517
(#1823, fixes #1427, @rgrinberg)
1618
- Advertise all code-action kinds that the server may return. (#1803, @rgrinberg)

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

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -166,21 +166,53 @@ let adjust_reply_location ~(statement : destructable_statement) (loc : Loc.t) :
166166
{ loc with loc_start; loc_end }
167167
;;
168168

169+
let point_range ~line ~character =
170+
let position = Position.create ~line ~character in
171+
Range.create ~start:position ~end_:position
172+
;;
173+
174+
let statement_of_code code range =
175+
match get_statement_kind code range with
176+
| None -> None
177+
| Some kind ->
178+
let query_range = get_query_range code kind range in
179+
let reply_range = get_reply_range code kind query_range in
180+
Some { code; kind; query_range; reply_range }
181+
;;
182+
183+
let find_existing_case doc ~line ~match_start =
184+
let source = Document.source doc in
185+
let (`Offset line_start) = Msource.get_offset source (`Logical (line + 1, 0)) in
186+
let match_offset = line_start + match_start in
187+
let code = Base.String.drop_prefix (Document.text doc) match_offset in
188+
match Destruct_line_search.find_case code ~match_start:0 with
189+
| None -> None
190+
| Some case_offset ->
191+
let case_offset = match_offset + case_offset in
192+
let (`Logical (line, character)) = Msource.get_logical source (`Offset case_offset) in
193+
let range = point_range ~line:(line - 1) ~character in
194+
let code = Destruct_line_search.mask_prefix (get_line doc range) character in
195+
statement_of_code code range
196+
;;
197+
169198
(** Tries to find a statement we know how to handle on the line where the range
170-
starts. *)
199+
starts. Inline matches are focused by masking their prefix, preserving all
200+
character offsets used by the existing line-oriented processing. *)
171201
let extract_statement (doc : Document.t) (ca_range : Range.t)
172202
: destructable_statement option
173203
=
174-
if ca_range.start.line <> ca_range.end_.line
175-
then None
176-
else (
177-
let code = get_line doc ca_range in
178-
match get_statement_kind code ca_range with
179-
| None -> None
180-
| Some kind ->
181-
let query_range = get_query_range code kind ca_range in
182-
let reply_range = get_reply_range code kind query_range in
183-
Some { code; kind; query_range; reply_range })
204+
let multiline = ca_range.start.line <> ca_range.end_.line in
205+
let line_range : Range.t =
206+
if multiline then { start = ca_range.start; end_ = ca_range.start } else ca_range
207+
in
208+
let code = get_line doc line_range in
209+
match Destruct_line_search.find_match code ~position:line_range.start.character with
210+
| None -> if multiline then None else statement_of_code code line_range
211+
| Some match_start ->
212+
(match find_existing_case doc ~match_start ~line:line_range.start.line with
213+
| Some _ as statement -> statement
214+
| None ->
215+
statement_of_code (Destruct_line_search.mask_prefix code match_start) line_range)
184216
;;
185217

186218
(** Strips " -> _ " off the rhs and " | " off the lhs of a case-line if present. *)

ocaml-lsp-server/src/code_actions/action_destruct_line.mli

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
open Import
22

33
(** This code action allows the user to invoke Merlin-destruct to enumerate
4-
cases from various lines of a partial match statement. If the line is of any
5-
of these forms: [match x] [match x with] [| x -> y] then the pre-processing
6-
will extract [x] and invoke Merlin-destruct on it. Some post-processing is
7-
applied to Merlin's response to make it more useful for adding subsequent
8-
code: extraneous tokens are stripped and cases are split across lines. For
4+
cases from various lines of a partial match statement. If a line contains
5+
one of these forms: [match x] [match x with] [| x -> y] then the
6+
pre-processing will extract [x] and invoke Merlin-destruct on it. Existing
7+
cases are reused when the action is requested on [match]. Merlin's response
8+
is post-processed to make it more useful for adding subsequent code:
9+
extraneous tokens are stripped and cases are split across lines. For
910
example, supposing [x] is a [bool], then the line [match x with] expands to
1011
[match x with
1112
| false -> _
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
open Import
2+
module Lexer = Ocaml_preprocess.Lexer_raw
3+
module Parser = Ocaml_preprocess.Parser_raw
4+
5+
type token =
6+
{ kind : Parser.token
7+
; start : int
8+
; end_ : int
9+
}
10+
11+
let lexer code =
12+
let lexbuf = Lexing.from_string code in
13+
let lexer = Lexer.make (Lexer.keywords []) in
14+
let rec finish = function
15+
| Lexer.Return token -> Some token
16+
| Refill refill -> finish (refill ())
17+
| Fail _ -> None
18+
in
19+
fun () ->
20+
match finish (Lexer.token_without_comments lexer lexbuf) with
21+
| None | Some Parser.EOF -> None
22+
| Some kind ->
23+
Some { kind; start = Lexing.lexeme_start lexbuf; end_ = Lexing.lexeme_end lexbuf }
24+
;;
25+
26+
let find_match code ~position =
27+
let next = lexer code in
28+
match next () with
29+
| None -> None
30+
| Some first ->
31+
let rec loop token =
32+
match token.kind with
33+
| Parser.MATCH
34+
when token.start = first.start
35+
|| (token.start <= position && position <= token.end_) -> Some token.start
36+
| Parser.MATCH | _ ->
37+
(match next () with
38+
| None -> None
39+
| Some token -> loop token)
40+
in
41+
loop first
42+
;;
43+
44+
let find_case code ~match_start =
45+
let next = lexer code in
46+
let rec after_match () =
47+
match next () with
48+
| None -> None
49+
| Some { kind = Parser.MATCH; start; _ } when start = match_start -> loop 0 0
50+
| Some _ -> after_match ()
51+
and loop nested_branches braces =
52+
match next () with
53+
| None -> None
54+
| Some { kind = Parser.MATCH | Parser.TRY; _ } -> loop (nested_branches + 1) braces
55+
| Some { kind = Parser.LBRACE; _ } -> loop nested_branches (braces + 1)
56+
| Some { kind = Parser.RBRACE; _ } -> loop nested_branches (max 0 (braces - 1))
57+
| Some { kind = Parser.WITH; _ } when nested_branches > 0 ->
58+
loop (nested_branches - 1) braces
59+
| Some { kind = Parser.WITH; _ } when braces > 0 -> loop nested_branches braces
60+
| Some { kind = Parser.WITH; _ } ->
61+
(match next () with
62+
| Some { kind = Parser.BAR; start; _ } -> Some start
63+
| None | Some _ -> None)
64+
| Some _ -> loop nested_branches braces
65+
in
66+
after_match ()
67+
;;
68+
69+
let mask_prefix code prefix_len =
70+
Stdlib.String.make prefix_len ' ' ^ Base.String.drop_prefix code prefix_len
71+
;;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
(** Locate a [match] token when it starts the line or contains [position]. *)
2+
val find_match : string -> position:int -> int option
3+
4+
(** Locate the first case belonging to the [match] at [match_start]. *)
5+
val find_case : string -> match_start:int -> int option
6+
7+
(** Replace the prefix with spaces without changing offsets. *)
8+
val mask_prefix : string -> int -> string

ocaml-lsp-server/src/dune

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
merlin-lib.analysis
2020
merlin-lib.kernel
2121
merlin-lib.ocaml_parsing
22+
merlin-lib.ocaml_preprocess
2223
merlin-lib.query_commands
2324
merlin-lib.query_protocol
2425
merlin-lib.ocaml_merlin_specific

ocaml-lsp-server/src/testing.ml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
(**WARNING: This is for internal use in testing only *)
22

33
module Compl = Compl
4+
module Destruct_line = Destruct_line_search
45
module Document_symbol = Document_symbol
56
module Merlin_kernel = Merlin_kernel
67
module Prefix_parser = Prefix_parser
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
open Base
2+
open Base_quickcheck
3+
module Destruct_line = Ocaml_lsp_server.Testing.Destruct_line
4+
5+
let bounded value bound = Int.rem (value land Stdlib.max_int) bound
6+
7+
let whitespace ?(allow_empty = false) selector =
8+
let length = bounded selector 5 + if allow_empty then 0 else 1 in
9+
let char = if selector land 1 = 0 then ' ' else '\t' in
10+
Stdlib.String.make length char
11+
;;
12+
13+
let identifier selector = Stdlib.String.make (bounded selector 8 + 1) 'x'
14+
let check label condition = if not condition then failwith label
15+
16+
let check_equal label expected actual =
17+
if not (Option.equal Int.equal expected actual)
18+
then
19+
failwith
20+
(Printf.sprintf
21+
"%s: expected %s"
22+
label
23+
(Option.value_map expected ~default:"none" ~f:Int.to_string))
24+
;;
25+
26+
type context =
27+
| Line_start
28+
| Inline
29+
[@@deriving quickcheck, sexp_of]
30+
31+
module Match_case = struct
32+
type t =
33+
{ context : context
34+
; indentation : int
35+
; identifier_length : int
36+
; cursor : int
37+
; suffix : int
38+
}
39+
[@@deriving quickcheck, sexp_of]
40+
end
41+
42+
let check_match { Match_case.context; indentation; identifier_length; cursor; suffix } =
43+
let indentation = whitespace ~allow_empty:true indentation in
44+
let prefix =
45+
match context with
46+
| Line_start -> indentation
47+
| Inline -> indentation ^ "let " ^ identifier identifier_length ^ " = "
48+
in
49+
let match_start = String.length prefix in
50+
let source = prefix ^ "match" ^ whitespace suffix ^ "value" in
51+
let position =
52+
match context with
53+
| Line_start -> 0
54+
| Inline -> match_start + bounded cursor (String.length "match" + 1)
55+
in
56+
check_equal
57+
"match location"
58+
(Some match_start)
59+
(Destruct_line.find_match source ~position);
60+
let focused = Destruct_line.mask_prefix source match_start in
61+
check "focused length" (String.length focused = String.length source);
62+
check
63+
"focused suffix"
64+
(String.equal
65+
(Base.String.drop_prefix focused match_start)
66+
(Base.String.drop_prefix source match_start));
67+
match context with
68+
| Line_start -> ()
69+
| Inline ->
70+
check_equal
71+
"position outside inline match"
72+
None
73+
(Destruct_line.find_match source ~position:0)
74+
;;
75+
76+
let%test_unit "find_match preserves offsets in line-leading and inline matches" =
77+
Base_quickcheck.Test.run_exn (module Match_case) ~f:check_match
78+
;;
79+
80+
type expression =
81+
| Simple
82+
| Record_update
83+
| Nested_match
84+
| Nested_try
85+
| String_literal
86+
| Comment
87+
[@@deriving quickcheck, sexp_of]
88+
89+
module Existing_case = struct
90+
type t =
91+
{ indentation : int
92+
; identifier_length : int
93+
; before_with : int
94+
; after_with : int
95+
; expression : expression
96+
}
97+
[@@deriving quickcheck, sexp_of]
98+
end
99+
100+
let check_existing_case
101+
{ Existing_case.indentation
102+
; identifier_length
103+
; before_with
104+
; after_with
105+
; expression
106+
}
107+
=
108+
let prefix =
109+
whitespace ~allow_empty:true indentation
110+
^ "let "
111+
^ identifier identifier_length
112+
^ " = "
113+
in
114+
let expression =
115+
match expression with
116+
| Simple -> "A"
117+
| Record_update -> "{ r with value = A }.value"
118+
| Nested_match -> "(match A with | A -> B)"
119+
| Nested_try -> "(try A with | _ -> A)"
120+
| String_literal -> "f \"with |\""
121+
| Comment -> "f (* with | *)"
122+
in
123+
let before_with = whitespace before_with in
124+
let after_with = whitespace ~allow_empty:true after_with in
125+
let before_case = prefix ^ "match " ^ expression ^ before_with ^ "with" ^ after_with in
126+
let source = before_case ^ "| A -> _" in
127+
let match_start = String.length prefix in
128+
check_equal
129+
"case location"
130+
(Some (String.length before_case))
131+
(Destruct_line.find_case source ~match_start)
132+
;;
133+
134+
let%test_unit "find_case handles prefixes, whitespace, and nested syntax" =
135+
Base_quickcheck.Test.run_exn (module Existing_case) ~f:check_existing_case
136+
;;
137+
138+
let%test_unit "keywords embedded in identifiers are ignored" =
139+
check_equal
140+
"match identifier"
141+
None
142+
(Destruct_line.find_match "let matcher = 0" ~position:4);
143+
check_equal
144+
"match identifier with apostrophe"
145+
None
146+
(Destruct_line.find_match "let match' = 0" ~position:4);
147+
check_equal
148+
"with identifier"
149+
None
150+
(Destruct_line.find_case "match value somewith | A -> _" ~match_start:0)
151+
;;
152+
153+
let%test_unit "find_case crosses comments and line breaks" =
154+
let before_case = "match A with\n(* existing case *)\n " in
155+
check_equal
156+
"case after comment"
157+
(Some (String.length before_case))
158+
(Destruct_line.find_case (before_case ^ "| A -> _") ~match_start:0)
159+
;;
160+
161+
let%test_unit "matches without cases do not consume a later match" =
162+
check_equal
163+
"later match"
164+
None
165+
(Destruct_line.find_case "match A with\nlet y = match B with | B -> _" ~match_start:0)
166+
;;
167+
168+
let%test_unit "record updates without a case are ignored" =
169+
check_equal
170+
"record update"
171+
None
172+
(Destruct_line.find_case "match { r with value = A } with" ~match_start:0)
173+
;;

ocaml-lsp-server/test/dune

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
(library
22
(modules
3+
destruct_line_quickcheck_tests
34
ocaml_lsp_tests
45
position_prefix_tests
56
range_relations_quickcheck_tests

0 commit comments

Comments
 (0)