Skip to content

Commit ea3013a

Browse files
committed
fix(code-actions): offer destruct-line on inline matches
Lex the source to locate an inline match and its first outer case while ignoring nested syntax, comments, strings, and record updates. Mask only the prefix so existing line-oriented destruct processing keeps correct offsets and can extend incomplete inline matches. Signed-off-by: Rudi Grinberg <me@rgrinberg.com>
1 parent 67228eb commit ea3013a

7 files changed

Lines changed: 635 additions & 19 deletions

File tree

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@
6060
(#1860, @rgrinberg)
6161
- Advertise and encode only semantic token modifiers supported by the client.
6262
(#1978, @rgrinberg)
63+
- Offer the `destruct-line` code action on inline match expressions with
64+
incomplete cases. (#1829, fixes #1595, @rgrinberg)
6365
- Replace a lone polymorphic-variant backtick when applying completions.
6466
(#1823, fixes #1427, @rgrinberg)
6567
- Report a missing project build-system executable as a diagnostic without

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

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

3+
module Search = struct
4+
module Lexer = Ocaml_preprocess.Lexer_raw
5+
module Parser = Ocaml_preprocess.Parser_raw
6+
7+
type t =
8+
{ match_start : int
9+
; case_start : int option
10+
}
11+
12+
type token =
13+
{ kind : Parser.token
14+
; start : int
15+
; end_ : int
16+
}
17+
18+
let lexer code =
19+
let lexbuf = Lexing.from_string code in
20+
let lexer = Lexer.make (Lexer.keywords []) in
21+
let rec finish = function
22+
| Lexer.Return token -> Some token
23+
| Refill refill -> finish (refill ())
24+
| Fail _ -> None
25+
in
26+
Staged.stage (fun () ->
27+
match finish (Lexer.token_without_comments lexer lexbuf) with
28+
| None | Some EOF -> None
29+
| Some kind ->
30+
Some { kind; start = Lexing.lexeme_start lexbuf; end_ = Lexing.lexeme_end lexbuf })
31+
;;
32+
33+
let find_case code ~start next =
34+
(* The stack tracks the innermost open constructs: [`Match] for a nested
35+
[match]/[try] and [`Brace] for a record-update brace. A [with] closes the
36+
innermost [`Match]; a [with] above a [`Brace] belongs to the record
37+
update; and a [with] with an empty stack belongs to the [match] we started
38+
from, whose first case (if any) immediately follows. *)
39+
(* The search is bounded to a few lines after the [match] to avoid lexing the
40+
rest of the file when the [match] has no cases yet. *)
41+
let max_lines = 100 in
42+
let lines = ref 0 in
43+
let prev_end = ref start in
44+
let budget_ok token =
45+
let len = token.start - !prev_end in
46+
if len > 0
47+
then (
48+
let skipped = String.sub code ~pos:!prev_end ~len in
49+
String.iter skipped ~f:(fun c -> if Char.equal c '\n' then incr lines));
50+
prev_end := token.end_;
51+
!lines <= max_lines
52+
in
53+
let rec loop stack =
54+
match next () with
55+
| None -> None
56+
| Some token when not (budget_ok token) -> None
57+
| Some { kind = MATCH | TRY; _ } -> loop (`Match :: stack)
58+
| Some { kind = LBRACE; _ } -> loop (`Brace :: stack)
59+
| Some { kind = RBRACE; _ } ->
60+
(match stack with
61+
| `Brace :: rest -> loop rest
62+
| _ -> loop stack)
63+
| Some { kind = WITH; _ } ->
64+
(match stack with
65+
| `Match :: rest -> loop rest
66+
| `Brace :: _ -> loop stack
67+
| [] ->
68+
(match next () with
69+
| Some ({ kind = BAR; start; _ } as token) when budget_ok token -> Some start
70+
| None | Some _ -> None))
71+
| Some _ -> loop stack
72+
in
73+
loop []
74+
;;
75+
76+
let find code ~position =
77+
let line_end =
78+
String.substr_index code ~pattern:"\n" |> Option.value ~default:(String.length code)
79+
in
80+
let next = Staged.unstage (lexer code) in
81+
match next () with
82+
| None -> None
83+
| Some first ->
84+
let rec loop token =
85+
if token.start >= line_end
86+
then None
87+
else (
88+
match token.kind with
89+
| MATCH
90+
when token.start = first.start
91+
|| (token.start <= position && position <= token.end_) ->
92+
Some
93+
{ match_start = token.start
94+
; case_start = find_case code ~start:token.end_ next
95+
}
96+
| MATCH | _ ->
97+
(match next () with
98+
| None -> None
99+
| Some token -> loop token))
100+
in
101+
loop first
102+
;;
103+
end
104+
3105
let action_kind = "destruct-line (enumerate cases, use existing match)"
4106
let kind = CodeActionKind.Other action_kind
5107

@@ -165,21 +267,45 @@ let adjust_reply_location ~(statement : destructable_statement) (loc : Loc.t) :
165267
{ loc with loc_start; loc_end }
166268
;;
167269

270+
let statement_of_code ~prefix_len code range =
271+
let code = String.make prefix_len ' ' ^ String.drop_prefix code prefix_len in
272+
match get_statement_kind code range with
273+
| None -> None
274+
| Some kind ->
275+
let query_range = get_query_range code kind range in
276+
let reply_range = get_reply_range code kind query_range in
277+
Some { code; kind; query_range; reply_range }
278+
;;
279+
280+
let statement_at_offset doc source offset =
281+
let (`Logical (line, character)) = Msource.get_logical source (`Offset offset) in
282+
let position = Position.create ~line:(line - 1) ~character in
283+
let range = Range.create ~start:position ~end_:position in
284+
statement_of_code ~prefix_len:character (get_line doc range) range
285+
;;
286+
168287
(** Tries to find a statement we know how to handle on the line where the range
169-
starts. *)
288+
starts. Inline matches are focused by masking their prefix, preserving all
289+
character offsets used by the existing line-oriented processing. *)
170290
let extract_statement (doc : Document.t) (ca_range : Range.t)
171291
: destructable_statement option
172292
=
173-
if not (Lsp.Range.is_single_line ca_range)
174-
then None
175-
else (
176-
let code = get_line doc ca_range in
177-
match get_statement_kind code ca_range with
178-
| None -> None
179-
| Some kind ->
180-
let query_range = get_query_range code kind ca_range in
181-
let reply_range = get_reply_range code kind query_range in
182-
Some { code; kind; query_range; reply_range })
293+
let multiline = not (Lsp.Range.is_single_line ca_range) in
294+
let line_range : Range.t =
295+
if multiline then { start = ca_range.start; end_ = ca_range.start } else ca_range
296+
in
297+
let code = get_line doc line_range in
298+
let source = Document.source doc in
299+
let (`Offset line_start) =
300+
Msource.get_offset source (`Logical (line_range.start.line + 1, 0))
301+
in
302+
let search_code = String.drop_prefix (Document.text doc) line_start in
303+
match Search.find search_code ~position:line_range.start.character with
304+
| None -> if multiline then None else statement_of_code ~prefix_len:0 code line_range
305+
| Some { case_start = Some case_start; _ } ->
306+
statement_at_offset doc source (line_start + case_start)
307+
| Some { match_start; case_start = None } ->
308+
statement_of_code ~prefix_len:match_start code line_range
183309
;;
184310

185311
(** Strips " -> _ " off the rhs and " | " off the lhs of a case-line if present. *)
@@ -267,3 +393,7 @@ let code_action
267393
let t ~dispatch state =
268394
{ Code_action.kind; run = `Non_batchable (code_action state dispatch) }
269395
;;
396+
397+
module Testing = struct
398+
module Search = Search
399+
end

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

Lines changed: 19 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 -> _
@@ -46,3 +47,16 @@ open Import
4647

4748
val kind : CodeActionKind.t
4849
val t : dispatch:Action_destruct.dispatch -> State.t -> Code_action.t
50+
51+
module Testing : sig
52+
module Search : sig
53+
type t =
54+
{ match_start : int
55+
; case_start : int option
56+
}
57+
58+
(** Locate a [match] on the first line and its first case, if any. The [match]
59+
must either be the first token or contain [position]. *)
60+
val find : string -> position:int -> t option
61+
end
62+
end

ocaml-lsp-server/src/testing.ml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
module Bin = Bin
44
module Compl = Compl
5+
module Action_destruct_line = Action_destruct_line
56
module Document_symbol = Document_symbol
67
module Merlin_kernel = Merlin_kernel
78
module Prefix_parser = Prefix_parser

0 commit comments

Comments
 (0)