Skip to content

Commit 794ed49

Browse files
billylanchantinlukaszsamsonÉtienne Lévesqueaxelson
authored
Add textDocument/foldingRange Provider (#492)
* initial commit * can fold heredoc w/ closing paren * fix indentation * save progress (this is a mess...) * (potentially?) working version * the code caught a problem with a test! * clean up a little * add some explanatory comments * round out functionality * fix some off-by-1 nonsense * grammar * remember to add cur to the stack in all cases! * adjust test * whoo! case reduction! * shorten comment * tweaks * better formatting * add token-pairs module; start adding typespecs * refactor token-pairs * fix example * refactor indentation tests * refactor indentation tests * remove unused function * passing tests! * fix return types * add unusual indentation and end-to-end tests * fix merging logic * update test names * remove todo * add heredoc support * create a unified input * move function to helpers * add support for comment blocks * don't allow single line comment blocks * try not to sort multiple times; fix test * note issue with implementation * include carriage returns in line-splitting logic Co-authored-by: Łukasz Samson <lukaszsamson@gmail.com> * use get_source_file function * combine Enum.maps; add comment * add `for` and `case`; add comments for clarity * attempt to deal with utf8/16 length calculations * fix misunderstanding and use :block_identifier * speling is hardd Co-authored-by: Étienne Lévesque <etienne.levesque16@gmail.com> * make group_comments/1 a defp; add @specs * replace a nested-reduce with a flat_map + group_by * drop kind_map, use @token_pairs directly * refactor, change name, add @specs * use pipes * fix warning * add support for charlist heredocs * add binary support * tweak test approach to allow specifying range kind * change heredoc pass to "special token" pass; add/modify tests * change filename * change to singular module name for consistency * remove outdated comments * remove debug function * (hopefully) cover older versions of tokenize * documentation; harmless refactor * Switch to doctests Also add a means to visualize folding range changes in the tests (only shows when there is a test failure) * Update apps/language_server/lib/language_server/providers/folding_range/token_pairs.ex Co-authored-by: Łukasz Samson <lukaszsamson@gmail.com> Co-authored-by: Étienne Lévesque <etienne.levesque16@gmail.com> Co-authored-by: Jason Axelson <jason.axelson@gmail.com> Co-authored-by: Jason Axelson <axelson@users.noreply.github.com>
1 parent a55f23e commit 794ed49

File tree

16 files changed

+1271
-6
lines changed

16 files changed

+1271
-6
lines changed

apps/language_server/lib/language_server/protocol.ex

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,16 @@ defmodule ElixirLS.LanguageServer.Protocol do
190190
end
191191
end
192192

193+
defmacro folding_range_req(id, uri) do
194+
quote do
195+
request(unquote(id), "textDocument/foldingRange", %{
196+
"textDocument" => %{
197+
"uri" => unquote(uri)
198+
}
199+
})
200+
end
201+
end
202+
193203
# TODO remove in ElixirLS 0.8
194204
defmacro macro_expansion(id, whole_buffer, selected_macro, macro_line) do
195205
quote do
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
defmodule ElixirLS.LanguageServer.Providers.FoldingRange do
2+
@moduledoc """
3+
A textDocument/foldingRange provider implementation.
4+
5+
## Background
6+
7+
See specification here:
8+
9+
https://microsoft.github.io/language-server-protocol/specifications/specification-3-15/#textDocument_foldingRange
10+
11+
## Methodology
12+
13+
### High level
14+
15+
We make multiple passes (currently 4) through the source text and create
16+
folding ranges from each pass.
17+
Then we merge the ranges from each pass to provide the final ranges.
18+
Each pass gets a priority to help break ties (the priority is an integer,
19+
higher integers win).
20+
21+
### Indentation pass (priority: 1)
22+
23+
We use the indentation level -- determined by the column of the first
24+
non-whitespace character on each line -- to provide baseline ranges.
25+
All ranges from this pass are `kind?: :region` ranges.
26+
27+
### Comment block pass (priority: 2)
28+
29+
We let "comment blocks", consecutive lines starting with `#`, from regions.
30+
All ranges from this pass are `kind?: :comment` ranges.
31+
32+
### Token-pairs pass (priority: 3)
33+
34+
We use pairs of tokens, e.g. `do` and `end`, to provide another pass of
35+
ranges.
36+
All ranges from this pass are `kind?: :region` ranges.
37+
38+
### Special tokens pass (priority: 3)
39+
40+
We find strings (regular/charlist strings/heredocs) and sigils in a pass as
41+
they're delimited by a few special tokens.
42+
Ranges from this pass are either
43+
- `kind?: :comment` if the token is paired with `@doc` or `@moduledoc`, or
44+
- `kind?: :region` otherwise.
45+
46+
## Notes
47+
48+
Each pass may return ranges in any order.
49+
But all ranges are valid, i.e. endLine > startLine.
50+
"""
51+
52+
alias __MODULE__
53+
54+
@type input :: %{
55+
tokens: [FoldingRange.Token.t()],
56+
lines: [FoldingRange.Line.t()]
57+
}
58+
59+
@type t :: %{
60+
required(:startLine) => non_neg_integer(),
61+
required(:endLine) => non_neg_integer(),
62+
optional(:startCharacter?) => non_neg_integer(),
63+
optional(:endCharacter?) => non_neg_integer(),
64+
optional(:kind?) => :comment | :imports | :region
65+
}
66+
67+
@doc """
68+
Provides folding ranges for a source file
69+
70+
## Example
71+
72+
iex> alias ElixirLS.LanguageServer.Providers.FoldingRange
73+
iex> text = \"""
74+
...> defmodule A do # 0
75+
...> def hello() do # 1
76+
...> :world # 2
77+
...> end # 3
78+
...> end # 4
79+
...> \"""
80+
iex> FoldingRange.provide(%{text: text})
81+
{:ok, [
82+
%{startLine: 0, endLine: 3, kind?: :region},
83+
%{startLine: 1, endLine: 2, kind?: :region}
84+
]}
85+
86+
"""
87+
@spec provide(%{text: String.t()}) :: {:ok, [t()]} | {:error, String.t()}
88+
def provide(%{text: text}) do
89+
do_provide(text)
90+
end
91+
92+
def provide(not_a_source_file) do
93+
{:error, "Expected a source file, found: #{inspect(not_a_source_file)}"}
94+
end
95+
96+
defp do_provide(text) do
97+
input = convert_text_to_input(text)
98+
99+
passes_with_priority = [
100+
{1, FoldingRange.Indentation},
101+
{2, FoldingRange.CommentBlock},
102+
{3, FoldingRange.TokenPair},
103+
{3, FoldingRange.SpecialToken}
104+
]
105+
106+
ranges =
107+
passes_with_priority
108+
|> Enum.map(fn {priority, pass} ->
109+
ranges = ranges_from_pass(pass, input)
110+
{priority, ranges}
111+
end)
112+
|> merge_ranges_with_priorities()
113+
114+
{:ok, ranges}
115+
end
116+
117+
def convert_text_to_input(text) do
118+
%{
119+
tokens: FoldingRange.Token.format_string(text),
120+
lines: FoldingRange.Line.format_string(text)
121+
}
122+
end
123+
124+
defp ranges_from_pass(pass, input) do
125+
with {:ok, ranges} <- pass.provide_ranges(input) do
126+
ranges
127+
else
128+
_ -> []
129+
end
130+
end
131+
132+
defp merge_ranges_with_priorities(range_lists_with_priorities) do
133+
range_lists_with_priorities
134+
|> Enum.flat_map(fn {priority, ranges} -> Enum.zip(Stream.cycle([priority]), ranges) end)
135+
|> Enum.group_by(fn {_priority, range} -> range.startLine end)
136+
|> Enum.map(fn {_start, ranges_with_priority} ->
137+
{_priority, range} =
138+
ranges_with_priority
139+
|> Enum.max_by(fn {priority, range} -> {priority, range.endLine} end)
140+
141+
range
142+
end)
143+
|> Enum.sort_by(& &1.startLine)
144+
end
145+
end
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
defmodule ElixirLS.LanguageServer.Providers.FoldingRange.CommentBlock do
2+
@moduledoc """
3+
Code folding based on comment blocks
4+
5+
Note that this implementation can create comment ranges inside heredocs.
6+
It's a little sloppy, but it shouldn't be very impactful.
7+
We'd have to merge the token and line representations of the source text to
8+
mitigate this issue, so we've left it as is for now.
9+
"""
10+
11+
alias ElixirLS.LanguageServer.Providers.FoldingRange
12+
alias ElixirLS.LanguageServer.Providers.FoldingRange.Line
13+
14+
@doc """
15+
Provides ranges for the source text based on comment blocks.
16+
17+
## Example
18+
19+
iex> alias ElixirLS.LanguageServer.Providers.FoldingRange
20+
iex> text = \"""
21+
...> defmodule SomeModule do # 0
22+
...> def some_function() do # 1
23+
...> # I'm # 2
24+
...> # a # 3
25+
...> # comment block # 4
26+
...> nil # 5
27+
...> end # 6
28+
...> end # 7
29+
...> \"""
30+
iex> FoldingRange.convert_text_to_input(text)
31+
iex> |> CommentBlock.provide_ranges()
32+
{:ok, [
33+
%{startLine: 2, endLine: 4, kind?: :comment}
34+
]}
35+
"""
36+
@spec provide_ranges(FoldingRange.input()) :: {:ok, [FoldingRange.t()]}
37+
def provide_ranges(%{lines: lines}) do
38+
ranges =
39+
lines
40+
|> group_comments()
41+
|> Enum.map(&convert_comment_group_to_range/1)
42+
43+
{:ok, ranges}
44+
end
45+
46+
@spec group_comments([Line.t()]) :: [{Line.cell(), String.t()}]
47+
defp group_comments(lines) do
48+
lines
49+
|> Enum.reduce([[]], fn
50+
{_, cell, "#"}, [[{_, "#"} | _] = head | tail] ->
51+
[[{cell, "#"} | head] | tail]
52+
53+
{_, cell, "#"}, [[] | tail] ->
54+
[[{cell, "#"}] | tail]
55+
56+
_, [[{_, "#"} | _] | _] = acc ->
57+
[[] | acc]
58+
59+
_, acc ->
60+
acc
61+
end)
62+
|> Enum.filter(fn group -> length(group) > 1 end)
63+
end
64+
65+
@spec group_comments([{Line.cell(), String.t()}]) :: [FoldingRange.t()]
66+
defp convert_comment_group_to_range(group) do
67+
{{{end_line, _}, _}, {{start_line, _}, _}} =
68+
group |> FoldingRange.Helpers.first_and_last_of_list()
69+
70+
%{
71+
startLine: start_line,
72+
# We're not doing end_line - 1 on purpose.
73+
# It seems weird to show the first _and_ last line of a comment block.
74+
endLine: end_line,
75+
kind?: :comment
76+
}
77+
end
78+
end
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
defmodule ElixirLS.LanguageServer.Providers.FoldingRange.Helpers do
2+
@moduledoc false
3+
4+
def first_and_last_of_list([]), do: :empty_list
5+
6+
def first_and_last_of_list([head | tail]) do
7+
tail
8+
|> List.last()
9+
|> case do
10+
nil -> {head, head}
11+
last -> {head, last}
12+
end
13+
end
14+
end
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
defmodule ElixirLS.LanguageServer.Providers.FoldingRange.Indentation do
2+
@moduledoc """
3+
Code folding based on indentation level
4+
5+
Note that we trim trailing empty rows from regions.
6+
See the example.
7+
"""
8+
9+
alias ElixirLS.LanguageServer.Providers.FoldingRange
10+
alias ElixirLS.LanguageServer.Providers.FoldingRange.Line
11+
12+
@doc """
13+
Provides ranges for the source text based on the indentation level.
14+
15+
## Example
16+
17+
iex> alias ElixirLS.LanguageServer.Providers.FoldingRange
18+
iex> text = \"""
19+
...> defmodule A do # 0
20+
...> def get_info(args) do # 1
21+
...> org = # 2
22+
...> args # 3
23+
...> |> Ecto.assoc(:organization) # 4
24+
...> |> Repo.one!() # 5
25+
...>
26+
...> user = # 7
27+
...> org # 8
28+
...> |> Organization.user!() # 9
29+
...>
30+
...> {:ok, %{org: org, user: user}} # 11
31+
...> end # 12
32+
...> end # 13
33+
...> \"""
34+
iex> FoldingRange.convert_text_to_input(text)
35+
...> |> FoldingRange.Indentation.provide_ranges()
36+
{:ok, [
37+
%{startLine: 0, endLine: 12, kind?: :region},
38+
%{startLine: 1, endLine: 11, kind?: :region},
39+
%{startLine: 7, endLine: 9, kind?: :region},
40+
%{startLine: 2, endLine: 5, kind?: :region},
41+
]}
42+
43+
Note that the empty lines 6 and 10 do not appear in the inner most ranges.
44+
"""
45+
@spec provide_ranges(FoldingRange.input()) :: {:ok, [FoldingRange.t()]}
46+
def provide_ranges(%{lines: lines}) do
47+
ranges =
48+
lines
49+
|> Enum.map(&extract_cell/1)
50+
|> pair_cells()
51+
|> pairs_to_ranges()
52+
53+
{:ok, ranges}
54+
end
55+
56+
defp extract_cell({_line, cell, _first}), do: cell
57+
58+
@doc """
59+
Pairs cells into {start, end} tuples of regions
60+
Public function for testing
61+
"""
62+
@spec pair_cells([Line.cell()]) :: [{Line.cell(), Line.cell()}]
63+
def pair_cells(cells) do
64+
do_pair_cells(cells, [], [], [])
65+
end
66+
67+
# Base case
68+
defp do_pair_cells([], _, _, pairs) do
69+
pairs
70+
|> Enum.map(fn
71+
{cell1, cell2, []} -> {cell1, cell2}
72+
{cell1, _, empties} -> {cell1, List.last(empties)}
73+
end)
74+
|> Enum.reject(fn {{r1, _}, {r2, _}} -> r1 + 1 >= r2 end)
75+
end
76+
77+
# Empty row
78+
defp do_pair_cells([{_, nil} = head | tail], stack, empties, pairs) do
79+
do_pair_cells(tail, stack, [head | empties], pairs)
80+
end
81+
82+
# Empty stack
83+
defp do_pair_cells([head | tail], [], empties, pairs) do
84+
do_pair_cells(tail, [head], empties, pairs)
85+
end
86+
87+
# Non-empty stack: head is to the right of the top of the stack
88+
defp do_pair_cells([{_, x} = head | tail], [{_, y} | _] = stack, _, pairs) when x > y do
89+
do_pair_cells(tail, [head | stack], [], pairs)
90+
end
91+
92+
# Non-empty stack: head is equal to or to the left of the top of the stack
93+
defp do_pair_cells([{_, x} = head | tail], stack, empties, pairs) do
94+
# If the head is <= to the top of the stack, then we need to pair it with
95+
# everything on the stack to the right of it.
96+
# The head can also start a new region, so it's pushed onto the stack.
97+
{leftovers, new_tail_stack} = stack |> Enum.split_while(fn {_, y} -> x <= y end)
98+
new_pairs = leftovers |> Enum.map(&{&1, head, empties})
99+
do_pair_cells(tail, [head | new_tail_stack], [], new_pairs ++ pairs)
100+
end
101+
102+
@spec pairs_to_ranges([{Line.cell(), Line.cell()}]) :: [FoldingRange.t()]
103+
defp pairs_to_ranges(pairs) do
104+
pairs
105+
|> Enum.map(fn {{r1, _}, {r2, _}} ->
106+
%{
107+
startLine: r1,
108+
endLine: r2 - 1,
109+
kind?: :region
110+
}
111+
end)
112+
end
113+
end

0 commit comments

Comments
 (0)