Skip to content

Commit 6bc2627

Browse files
authored
Add extract code actions (#870)
* work on extract action * add changes * add documentation and tests * update test * formatting
1 parent 61f7f70 commit 6bc2627

14 files changed

+610
-69
lines changed

CHANGES.md

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

5050
- Add "Remove type annotation" code action. (#1039)
5151
- Support settings through `didChangeConfiguration` notification (#1103)
52+
- Add "Extract local" and "Extract function" code actions. (#870)
5253
- Depend directly on `merlin-lib` 4.9 (#1070)
5354

5455
# 1.15.1

ocaml-lsp-server/src/code_actions.ml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ let compute server (params : CodeActionParams.t) =
9292
; Action_mark_remove_unused.mark
9393
; Action_mark_remove_unused.remove
9494
; Action_inline.t
95+
; Action_extract.local
96+
; Action_extract.function_
9597
]
9698
in
9799
List.concat
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
open Import
2+
module H = Ocaml_parsing.Ast_helper
3+
4+
let range_contains_loc range loc =
5+
match Range.of_loc_opt loc with
6+
| Some range' -> Range.contains range range'
7+
| None -> false
8+
9+
let range_contained_by_loc range loc =
10+
match Range.of_loc_opt loc with
11+
| Some range' -> Range.contains range' range
12+
| None -> false
13+
14+
let largest_enclosed_expression typedtree range =
15+
let exception Found of Typedtree.expression in
16+
let module I = Ocaml_typing.Tast_iterator in
17+
let expr_iter (iter : I.iterator) (expr : Typedtree.expression) =
18+
if range_contains_loc range expr.exp_loc then raise (Found expr)
19+
else I.default_iterator.expr iter expr
20+
in
21+
let iterator = { I.default_iterator with expr = expr_iter } in
22+
try
23+
iterator.structure iterator typedtree;
24+
None
25+
with Found e -> Some e
26+
27+
let enclosing_structure_item typedtree range =
28+
let exception Found of Typedtree.structure_item in
29+
let module I = Ocaml_typing.Tast_iterator in
30+
let structure_item_iter (iter : I.iterator) (item : Typedtree.structure_item)
31+
=
32+
if range_contained_by_loc range item.str_loc then
33+
match item.str_desc with
34+
| Tstr_value _ -> raise (Found item)
35+
| _ -> I.default_iterator.structure_item iter item
36+
in
37+
let iterator =
38+
{ I.default_iterator with structure_item = structure_item_iter }
39+
in
40+
try
41+
iterator.structure iterator typedtree;
42+
None
43+
with Found e -> Some e
44+
45+
let tightest_enclosing_binder_position typedtree range =
46+
let exception Found of Position.t in
47+
let module I = Ocaml_typing.Tast_iterator in
48+
let found_loc loc =
49+
Position.of_lexical_position loc
50+
|> Option.iter ~f:(fun p -> raise (Found p))
51+
in
52+
let found_if_expr_contains (expr : Typedtree.expression) =
53+
let loc = expr.exp_loc in
54+
if range_contained_by_loc range loc then found_loc loc.loc_start
55+
in
56+
let found_if_case_contains cases =
57+
List.iter cases ~f:(fun (case : _ Typedtree.case) ->
58+
found_if_expr_contains case.c_rhs)
59+
in
60+
let expr_iter (iter : I.iterator) (expr : Typedtree.expression) =
61+
if range_contained_by_loc range expr.exp_loc then (
62+
I.default_iterator.expr iter expr;
63+
match expr.exp_desc with
64+
| Texp_let (_, _, body)
65+
| Texp_while (_, body)
66+
| Texp_for (_, _, _, _, _, body)
67+
| Texp_letmodule (_, _, _, _, body)
68+
| Texp_letexception (_, body)
69+
| Texp_open (_, body) -> found_if_expr_contains body
70+
| Texp_letop { body; _ } -> found_if_case_contains [ body ]
71+
| Texp_function { cases; _ } -> found_if_case_contains cases
72+
| Texp_match (_, cases, _) -> found_if_case_contains cases
73+
| Texp_try (_, cases) -> found_if_case_contains cases
74+
| _ -> ())
75+
in
76+
let structure_item_iter (iter : I.iterator) (item : Typedtree.structure_item)
77+
=
78+
if range_contained_by_loc range item.str_loc then (
79+
I.default_iterator.structure_item iter item;
80+
match item.str_desc with
81+
| Tstr_value (_, bindings) ->
82+
List.iter bindings ~f:(fun (binding : Typedtree.value_binding) ->
83+
found_if_expr_contains binding.vb_expr)
84+
| _ -> ())
85+
in
86+
let iterator =
87+
{ I.default_iterator with
88+
expr = expr_iter
89+
; structure_item = structure_item_iter
90+
}
91+
in
92+
try
93+
iterator.structure iterator typedtree;
94+
None
95+
with Found e -> Some e
96+
97+
module LongidentSet = Set.Make (struct
98+
type t = Longident.t
99+
100+
let compare = compare
101+
end)
102+
103+
(** [free expr] returns the free variables in [expr]. *)
104+
let free (expr : Typedtree.expression) =
105+
let module I = Ocaml_typing.Tast_iterator in
106+
let idents = ref [] in
107+
let expr_iter (iter : I.iterator) (expr : Typedtree.expression) =
108+
match expr.exp_desc with
109+
| Texp_ident (path, { txt = ident; _ }, _) ->
110+
idents := (ident, path) :: !idents
111+
| _ ->
112+
I.default_iterator.expr iter expr;
113+
114+
(* if a variable was bound but is no longer, it must be associated with a
115+
binder inside the expression *)
116+
idents :=
117+
List.filter !idents ~f:(fun (ident, path) ->
118+
match Env.find_value_by_name ident expr.exp_env with
119+
| path', _ -> Path.same path path'
120+
| exception Not_found -> false)
121+
in
122+
let iter = { I.default_iterator with expr = expr_iter } in
123+
iter.expr iter expr;
124+
!idents
125+
126+
let must_pass expr env =
127+
List.filter (free expr) ~f:(fun (ident, path) ->
128+
match Env.find_value_by_name ident env with
129+
| path', _ ->
130+
(* new environment binds ident to a different path than the old one *)
131+
not (Path.same path path')
132+
| exception Not_found -> true)
133+
|> List.map ~f:fst
134+
135+
let extract_local doc typedtree range =
136+
let open Option.O in
137+
let* to_extract = largest_enclosed_expression typedtree range in
138+
let* extract_range = Range.of_loc_opt to_extract.exp_loc in
139+
let* edit_pos = tightest_enclosing_binder_position typedtree range in
140+
let new_name = "var_name" in
141+
let* local_text = Document.substring doc extract_range in
142+
let newText = sprintf "let %s = %s in\n" new_name local_text in
143+
let insert_range = { Range.start = edit_pos; end_ = edit_pos } in
144+
Some
145+
[ TextEdit.create ~newText ~range:insert_range
146+
; TextEdit.create ~newText:new_name ~range:extract_range
147+
]
148+
149+
let extract_function doc typedtree range =
150+
let open Option.O in
151+
let* to_extract = largest_enclosed_expression typedtree range in
152+
let* extract_range = Range.of_loc_opt to_extract.exp_loc in
153+
let* parent_item = enclosing_structure_item typedtree range in
154+
let* edit_pos = Position.of_lexical_position parent_item.str_loc.loc_start in
155+
let new_name = "fun_name" in
156+
let* args_str =
157+
let free_vars = must_pass to_extract parent_item.str_env in
158+
let+ args =
159+
List.map free_vars ~f:(function
160+
| Longident.Lident id -> Some id
161+
| _ -> None)
162+
|> Option.List.all
163+
in
164+
let s = String.concat ~sep:" " args in
165+
if String.is_empty s then "()" else s
166+
in
167+
let* func_text = Document.substring doc extract_range in
168+
let new_function = sprintf "let %s %s = %s\n\n" new_name args_str func_text in
169+
let new_call = sprintf "%s %s" new_name args_str in
170+
let insert_range = { Range.start = edit_pos; end_ = edit_pos } in
171+
Some
172+
[ TextEdit.create ~newText:new_function ~range:insert_range
173+
; TextEdit.create ~newText:new_call ~range:extract_range
174+
]
175+
176+
let run_extract_local doc (params : CodeActionParams.t) =
177+
let open Option.O in
178+
Document.Merlin.with_pipeline_exn
179+
(Document.merlin_exn doc)
180+
Mpipeline.typer_result
181+
|> Fiber.map ~f:(fun typer ->
182+
let* typedtree =
183+
match Mtyper.get_typedtree typer with
184+
| `Interface _ -> None
185+
| `Implementation x -> Some x
186+
in
187+
let+ edits = extract_local doc typedtree params.range in
188+
CodeAction.create
189+
~title:"Extract local"
190+
~kind:CodeActionKind.RefactorExtract
191+
~edit:(Document.edit doc edits)
192+
~isPreferred:false
193+
())
194+
195+
let run_extract_function doc (params : CodeActionParams.t) =
196+
let open Option.O in
197+
Document.Merlin.with_pipeline_exn
198+
(Document.merlin_exn doc)
199+
Mpipeline.typer_result
200+
|> Fiber.map ~f:(fun typer ->
201+
let* typedtree =
202+
match Mtyper.get_typedtree typer with
203+
| `Interface _ -> None
204+
| `Implementation x -> Some x
205+
in
206+
let+ edits = extract_function doc typedtree params.range in
207+
CodeAction.create
208+
~title:"Extract function"
209+
~kind:CodeActionKind.RefactorExtract
210+
~edit:(Document.edit doc edits)
211+
~isPreferred:false
212+
())
213+
214+
let local = { Code_action.kind = RefactorExtract; run = run_extract_local }
215+
216+
let function_ =
217+
{ Code_action.kind = RefactorExtract; run = run_extract_function }
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
val local : Code_action.t
2+
3+
val function_ : Code_action.t

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

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

3-
let slice doc (range : Range.t) =
4-
let src = Document.source doc in
5-
let (`Offset start) = Msource.get_offset src @@ Position.logical range.start
6-
and (`Offset end_) = Msource.get_offset src @@ Position.logical range.end_ in
7-
String.sub (Msource.text src) ~pos:start ~len:(end_ - start)
8-
93
(* Return contexts enclosing `pos` in order from most specific to most
104
general. *)
115
let enclosing_pos pipeline pos =
@@ -72,14 +66,14 @@ let rec mark_value_unused_edit name contexts =
7266
let code_action_mark_value_unused doc (diagnostic : Diagnostic.t) =
7367
let open Option.O in
7468
Document.Merlin.with_pipeline_exn (Document.merlin_exn doc) (fun pipeline ->
75-
let var_name = slice doc diagnostic.range in
69+
let* var_name = Document.substring doc diagnostic.range in
7670
let pos = diagnostic.range.start in
7771
let+ text_edit =
7872
enclosing_pos pipeline pos
7973
|> List.rev_map ~f:(fun (_, x) -> x)
8074
|> mark_value_unused_edit var_name
8175
in
82-
let edit = Document.edit doc text_edit in
76+
let edit = Document.edit doc [ text_edit ] in
8377
CodeAction.create
8478
~diagnostics:[ diagnostic ]
8579
~title:"Mark as unused"
@@ -114,7 +108,7 @@ let enclosing_value_binding_range name =
114108

115109
(* Create a code action that removes [range] and refers to [diagnostic]. *)
116110
let code_action_remove_range doc (diagnostic : Diagnostic.t) range =
117-
let edit = Document.edit doc { range; newText = "" } in
111+
let edit = Document.edit doc [ { range; newText = "" } ] in
118112
CodeAction.create
119113
~diagnostics:[ diagnostic ]
120114
~title:"Remove unused"
@@ -125,8 +119,9 @@ let code_action_remove_range doc (diagnostic : Diagnostic.t) range =
125119

126120
(* Create a code action that removes the value mentioned in [diagnostic]. *)
127121
let code_action_remove_value doc pos (diagnostic : Diagnostic.t) =
122+
let open Option.O in
128123
Document.Merlin.with_pipeline_exn (Document.merlin_exn doc) (fun pipeline ->
129-
let var_name = slice doc diagnostic.range in
124+
let* var_name = Document.substring doc diagnostic.range in
130125
enclosing_pos pipeline pos |> List.map ~f:snd
131126
|> enclosing_value_binding_range var_name
132127
|> Option.map ~f:(fun range ->

ocaml-lsp-server/src/document.ml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,13 +314,15 @@ module Merlin = struct
314314
with_pipeline_exn doc (fun pipeline -> doc_comment pipeline pos)
315315
end
316316

317-
let edit t text_edit =
317+
let edit t text_edits =
318318
let version = version t in
319319
let textDocument =
320320
OptionalVersionedTextDocumentIdentifier.create ~uri:(uri t) ~version ()
321321
in
322322
let edit =
323-
TextDocumentEdit.create ~textDocument ~edits:[ `TextEdit text_edit ]
323+
TextDocumentEdit.create
324+
~textDocument
325+
~edits:(List.map text_edits ~f:(fun text_edit -> `TextEdit text_edit))
324326
in
325327
WorkspaceEdit.create ~documentChanges:[ `TextDocumentEdit edit ] ()
326328

@@ -379,3 +381,9 @@ let get_impl_intf_counterparts uri =
379381
| to_switch_to -> to_switch_to
380382
in
381383
List.map ~f:Uri.of_path files_to_switch_to
384+
385+
let substring doc range =
386+
let start, end_ = Text_document.absolute_range (tdoc doc) range in
387+
let text = text doc in
388+
if start < 0 || start > end_ || end_ > String.length text then None
389+
else Some (String.sub text ~pos:start ~len:(end_ - start))

ocaml-lsp-server/src/document.mli

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,12 @@ val close : t -> unit Fiber.t
9999
For instance, the counterparts of the file [/file.ml] are [/file.mli]. *)
100100
val get_impl_intf_counterparts : Uri.t -> Uri.t list
101101

102-
val edit : t -> TextEdit.t -> WorkspaceEdit.t
102+
(** [edits t edits] creates a [WorkspaceEdit.t] that applies edits [edits] to
103+
the document [t]. *)
104+
val edit : t -> TextEdit.t list -> WorkspaceEdit.t
105+
106+
(** [substring t range] returns the substring of the document [t] that
107+
corresponds to the range [range].
108+
109+
Returns [None] when there is no corresponding substring. *)
110+
val substring : t -> Range.t -> string option

ocaml-lsp-server/src/range.ml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ let to_dyn { start; end_ } =
1010
Dyn.record
1111
[ ("start", Position.to_dyn start); ("end_", Position.to_dyn end_) ]
1212

13+
let contains (x : t) (y : t) =
14+
let open Ordering in
15+
match (Position.compare x.start y.start, Position.compare x.end_ y.end_) with
16+
| (Lt | Eq), (Gt | Eq) -> true
17+
| _ -> false
18+
1319
(* Compares ranges by their lengths*)
1420
let compare_size (x : t) (y : t) =
1521
let dx = Position.(x.end_ - x.start) in

ocaml-lsp-server/src/range.mli

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ include module type of Lsp.Types.Range with type t = Lsp.Types.Range.t
66
positions. *)
77
val compare : t -> t -> Ordering.t
88

9+
(** [contains r1 r2] returns true if [r1] contains [r2]. *)
10+
val contains : t -> t -> bool
11+
912
val to_dyn : t -> Dyn.t
1013

1114
val compare_size : t -> t -> Ordering.t

0 commit comments

Comments
 (0)