Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add textDocument/foldingRange Provider #492

Merged
merged 64 commits into from
Mar 20, 2021
Merged
Show file tree
Hide file tree
Changes from 60 commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
d17d22f
initial commit
billylanchantin Feb 9, 2021
01c966d
can fold heredoc w/ closing paren
billylanchantin Feb 9, 2021
64752dd
fix indentation
billylanchantin Feb 13, 2021
3e40c30
save progress (this is a mess...)
billylanchantin Feb 15, 2021
56fd54f
(potentially?) working version
billylanchantin Feb 15, 2021
004fd6d
the code caught a problem with a test!
billylanchantin Feb 15, 2021
8269e86
clean up a little
billylanchantin Feb 15, 2021
1b9c0c4
add some explanatory comments
billylanchantin Feb 15, 2021
84604ce
round out functionality
billylanchantin Feb 15, 2021
8289634
fix some off-by-1 nonsense
billylanchantin Feb 15, 2021
c7c56f6
grammar
billylanchantin Feb 15, 2021
bc8587f
remember to add cur to the stack in all cases!
billylanchantin Feb 15, 2021
1567d42
adjust test
billylanchantin Feb 15, 2021
270b57d
whoo! case reduction!
billylanchantin Feb 15, 2021
249b673
shorten comment
billylanchantin Feb 15, 2021
8ff26cf
tweaks
billylanchantin Feb 15, 2021
f79664c
better formatting
billylanchantin Feb 16, 2021
0f52639
add token-pairs module; start adding typespecs
billylanchantin Feb 16, 2021
2c9b18d
refactor token-pairs
billylanchantin Feb 16, 2021
5b876de
fix example
billylanchantin Feb 16, 2021
a40aea4
refactor indentation tests
billylanchantin Feb 16, 2021
593b2c3
refactor indentation tests
billylanchantin Feb 16, 2021
f39848f
remove unused function
billylanchantin Feb 16, 2021
9bbf34d
passing tests!
billylanchantin Feb 16, 2021
dc7349c
fix return types
billylanchantin Feb 16, 2021
ef95266
add unusual indentation and end-to-end tests
billylanchantin Feb 16, 2021
61b3c2a
fix merging logic
billylanchantin Feb 16, 2021
64d495b
update test names
billylanchantin Feb 16, 2021
2409d8a
remove todo
billylanchantin Feb 16, 2021
5304e25
add heredoc support
billylanchantin Feb 18, 2021
f39acb9
create a unified input
billylanchantin Feb 18, 2021
d8666d8
move function to helpers
billylanchantin Feb 18, 2021
2722023
add support for comment blocks
billylanchantin Feb 18, 2021
7c52c92
don't allow single line comment blocks
billylanchantin Feb 18, 2021
dbad74f
try not to sort multiple times; fix test
billylanchantin Feb 18, 2021
268c203
note issue with implementation
billylanchantin Feb 18, 2021
6861e09
include carriage returns in line-splitting logic
billylanchantin Feb 18, 2021
4458bdf
use get_source_file function
billylanchantin Feb 18, 2021
8d2c986
Merge branch 'folding-range-billy' of github.com:billylanchantin/elix…
billylanchantin Feb 18, 2021
6a921d9
combine Enum.maps; add comment
billylanchantin Feb 18, 2021
a8c54a2
add `for` and `case`; add comments for clarity
billylanchantin Feb 18, 2021
91252f0
attempt to deal with utf8/16 length calculations
billylanchantin Feb 18, 2021
195acca
fix misunderstanding and use :block_identifier
billylanchantin Feb 18, 2021
df73fab
speling is hardd
billylanchantin Feb 19, 2021
b67a9f5
make group_comments/1 a defp; add @specs
billylanchantin Feb 19, 2021
fe82f52
replace a nested-reduce with a flat_map + group_by
billylanchantin Feb 19, 2021
2d20671
drop kind_map, use @token_pairs directly
billylanchantin Feb 19, 2021
5c291d3
refactor, change name, add @specs
billylanchantin Feb 19, 2021
888814a
use pipes
billylanchantin Feb 19, 2021
6bb06f8
fix warning
billylanchantin Feb 19, 2021
254b093
add support for charlist heredocs
billylanchantin Feb 20, 2021
5b9be3b
add binary support
billylanchantin Feb 20, 2021
dc1a27a
tweak test approach to allow specifying range kind
billylanchantin Feb 20, 2021
21f1a7b
change heredoc pass to "special token" pass; add/modify tests
billylanchantin Feb 20, 2021
af1f545
change filename
billylanchantin Feb 20, 2021
0b72960
change to singular module name for consistency
billylanchantin Feb 20, 2021
29920e1
remove outdated comments
billylanchantin Feb 20, 2021
f8bf332
remove debug function
billylanchantin Feb 20, 2021
cc2fa59
(hopefully) cover older versions of tokenize
billylanchantin Feb 22, 2021
c3a3da4
documentation; harmless refactor
billylanchantin Feb 23, 2021
9e0cacf
Switch to doctests
axelson Feb 27, 2021
5fbdd6a
Merge pull request #1 from axelson/my-folding-range
billylanchantin Mar 20, 2021
5bced31
Merge remote-tracking branch 'upstream/master' into folding-range-billy
axelson Mar 20, 2021
35d3fd5
Update apps/language_server/lib/language_server/providers/folding_ran…
axelson Mar 20, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/language_server/lib/language_server/protocol.ex
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,16 @@ defmodule ElixirLS.LanguageServer.Protocol do
end
end

defmacro folding_range_req(id, uri) do
quote do
request(unquote(id), "textDocument/foldingRange", %{
"textDocument" => %{
"uri" => unquote(uri)
}
})
end
end

defmacro macro_expansion(id, whole_buffer, selected_macro, macro_line) do
quote do
request(unquote(id), "elixirDocument/macroExpansion", %{
Expand Down
148 changes: 148 additions & 0 deletions apps/language_server/lib/language_server/providers/folding_range.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
defmodule ElixirLS.LanguageServer.Providers.FoldingRange do
@moduledoc """
A textDocument/foldingRange provider implementation.

## Background

See specification here:

https://microsoft.github.io/language-server-protocol/specifications/specification-3-15/#textDocument_foldingRange

## Methodology

### High level

We make multiple passes (currently 4) through the source text and create
folding ranges from each pass.
Then we merge the ranges from each pass to provide the final ranges.
Each pass gets a priority to help break ties (the priority is an integer,
higher integers win).

### Indentation pass (priority: 1)

We use the indentation level -- determined by the column of the first
non-whitespace character on each line -- to provide baseline ranges.
All ranges from this pass are `kind?: :region` ranges.

### Comment block pass (priority: 2)

We let "comment blocks", consecutive lines starting with `#`, from regions.
All ranges from this pass are `kind?: :comment` ranges.

### Token-pairs pass (priority: 3)

We use pairs of tokens, e.g. `do` and `end`, to provide another pass of
ranges.
All ranges from this pass are `kind?: :region` ranges.

### Special tokens pass (priority: 3)

We find strings (regular/charlist strings/heredocs) and sigils in a pass as
they're delimited by a few special tokens.
Ranges from this pass are either
- `kind?: :comment` if the token is paired with `@doc` or `@moduledoc`, or
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we include @typedoc here as well? (and in the matching code)

- `kind?: :region` otherwise.

## Notes

Each pass may return ranges in any order.
But all ranges are valid, i.e. endLine > startLine.
"""

alias __MODULE__

@type input :: %{
tokens: [FoldingRange.Token.t()],
lines: [FoldingRange.Line.t()]
}

@type t :: %{
required(:startLine) => non_neg_integer(),
required(:endLine) => non_neg_integer(),
optional(:startCharacter?) => non_neg_integer(),
optional(:endCharacter?) => non_neg_integer(),
optional(:kind?) => :comment | :imports | :region
billylanchantin marked this conversation as resolved.
Show resolved Hide resolved
}

@doc """
Provides folding ranges for a source file

## Example

text = \"\"\"
defmodule A do # 0
def hello() do # 1
:world # 2
end # 3
end # 4
\"\"\"

{:ok, ranges} =
text
|> convert_text_to_input()
|> provide()

# ranges == [
# %{startLine: 0, endLine: 3, kind?: :region},
# %{startLine: 1, endLine: 2, kind?: :region}
# ]
"""
@spec provide(%{text: String.t()}) :: {:ok, [t()]} | {:error, String.t()}
def provide(%{text: text}) do
do_provide(text)
end

def provide(not_a_source_file) do
{:error, "Expected a source file, found: #{inspect(not_a_source_file)}"}
end

defp do_provide(text) do
input = convert_text_to_input(text)

passes_with_priority = [
{1, FoldingRange.Indentation},
{2, FoldingRange.CommentBlock},
{3, FoldingRange.TokenPair},
{3, FoldingRange.SpecialToken}
]

ranges =
passes_with_priority
|> Enum.map(fn {priority, pass} ->
ranges = ranges_from_pass(pass, input)
{priority, ranges}
end)
|> merge_ranges_with_priorities()

{:ok, ranges}
end

def convert_text_to_input(text) do
%{
tokens: FoldingRange.Token.format_string(text),
lines: FoldingRange.Line.format_string(text)
}
end

defp ranges_from_pass(pass, input) do
with {:ok, ranges} <- pass.provide_ranges(input) do
ranges
else
_ -> []
end
end

defp merge_ranges_with_priorities(range_lists_with_priorities) do
range_lists_with_priorities
|> Enum.flat_map(fn {priority, ranges} -> Enum.zip(Stream.cycle([priority]), ranges) end)
|> Enum.group_by(fn {_priority, range} -> range.startLine end)
|> Enum.map(fn {_start, ranges_with_priority} ->
{_priority, range} =
ranges_with_priority
|> Enum.max_by(fn {priority, range} -> {priority, range.endLine} end)

range
end)
|> Enum.sort_by(& &1.startLine)
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
defmodule ElixirLS.LanguageServer.Providers.FoldingRange.CommentBlock do
@moduledoc """
Code folding based on comment blocks

Note that this implementation can create comment ranges inside heredocs.
It's a little sloppy, but it shouldn't be very impactful.
We'd have to merge the token and line representations of the source text to
mitigate this issue, so we've left it as is for now.
"""

alias ElixirLS.LanguageServer.Providers.FoldingRange
alias ElixirLS.LanguageServer.Providers.FoldingRange.Line

@doc """
Provides ranges for the source text based on comment blocks.

## Example

text =
\"\"\"
defmodule SomeModule do # 0
def some_function() do # 1
# I'm # 2
# a # 3
# comment block # 4
nil # 5
end # 6
end # 7
\"\"\"

{:ok, ranges} =
text
|> FoldingRange.convert_text_to_input()
|> CommentBlock.provide_ranges()

# ranges == [%{startLine: 2, endLine: 4, kind?: :comment}]
"""
@spec provide_ranges(FoldingRange.input()) :: {:ok, [FoldingRange.t()]}
def provide_ranges(%{lines: lines}) do
ranges =
lines
|> group_comments()
|> Enum.map(&convert_comment_group_to_range/1)

{:ok, ranges}
end

@spec group_comments([Line.t()]) :: [{Line.cell(), String.t()}]
defp group_comments(lines) do
lines
|> Enum.reduce([[]], fn
{_, cell, "#"}, [[{_, "#"} | _] = head | tail] ->
[[{cell, "#"} | head] | tail]

{_, cell, "#"}, [[] | tail] ->
[[{cell, "#"}] | tail]

_, [[{_, "#"} | _] | _] = acc ->
[[] | acc]

_, acc ->
acc
end)
|> Enum.filter(fn group -> length(group) > 1 end)
end

@spec group_comments([{Line.cell(), String.t()}]) :: [FoldingRange.t()]
defp convert_comment_group_to_range(group) do
{{{end_line, _}, _}, {{start_line, _}, _}} =
group |> FoldingRange.Helpers.first_and_last_of_list()

%{
startLine: start_line,
# We're not doing end_line - 1 on purpose.
# It seems weird to show the first _and_ last line of a comment block.
endLine: end_line,
kind?: :comment
}
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
defmodule ElixirLS.LanguageServer.Providers.FoldingRange.Helpers do
@moduledoc false

def first_and_last_of_list([]), do: :empty_list

def first_and_last_of_list([head | tail]) do
tail
|> List.last()
|> case do
nil -> {head, head}
last -> {head, last}
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
defmodule ElixirLS.LanguageServer.Providers.FoldingRange.Indentation do
@moduledoc """
Code folding based on indentation level

Note that we trim trailing empty rows from regions.
See the example.
"""

alias ElixirLS.LanguageServer.Providers.FoldingRange
alias ElixirLS.LanguageServer.Providers.FoldingRange.Line

@doc """
Provides ranges for the source text based on the indentation level.

## Example

text =
\"\"\"
defmodule A do # 0
def get_info(args) do # 1
org = # 2
args # 3
|> Ecto.assoc(:organization) # 4
|> Repo.one!() # 5

user = # 7
org # 8
|> Organization.user!() # 9

{:ok, %{org: org, user: user}} # 11
end # 12
end # 13
\"\"\"

{:ok, ranges} =
text
|> FoldingRange.convert_text_to_input()
|> Indentation.provide_ranges()

# ranges == [
# %{startLine: 0, endLine: 12, kind?: :region},
# %{startLine: 1, endLine: 11, kind?: :region},
# %{startLine: 2, endLine: 5, kind?: :region},
# %{startLine: 7, endLine: 9, kind?: :region},
# ]

Note that the empty lines 6 and 10 do not appear in the inner most ranges.
"""
@spec provide_ranges(FoldingRange.input()) :: {:ok, [FoldingRange.t()]}
def provide_ranges(%{lines: lines}) do
ranges =
lines
|> Enum.map(&extract_cell/1)
|> pair_cells()
|> pairs_to_ranges()

{:ok, ranges}
end

defp extract_cell({_line, cell, _first}), do: cell

@doc """
Pairs cells into {start, end} tuples of regions
Public function for testing
"""
@spec pair_cells([Line.cell()]) :: [{Line.cell(), Line.cell()}]
def pair_cells(cells) do
do_pair_cells(cells, [], [], [])
end

# Base case
defp do_pair_cells([], _, _, pairs) do
pairs
|> Enum.map(fn
{cell1, cell2, []} -> {cell1, cell2}
{cell1, _, empties} -> {cell1, List.last(empties)}
end)
|> Enum.reject(fn {{r1, _}, {r2, _}} -> r1 + 1 >= r2 end)
end

# Empty row
defp do_pair_cells([{_, nil} = head | tail], stack, empties, pairs) do
do_pair_cells(tail, stack, [head | empties], pairs)
end

# Empty stack
defp do_pair_cells([head | tail], [], empties, pairs) do
do_pair_cells(tail, [head], empties, pairs)
end

# Non-empty stack: head is to the right of the top of the stack
defp do_pair_cells([{_, x} = head | tail], [{_, y} | _] = stack, _, pairs) when x > y do
do_pair_cells(tail, [head | stack], [], pairs)
end

# Non-empty stack: head is equal to or to the left of the top of the stack
defp do_pair_cells([{_, x} = head | tail], stack, empties, pairs) do
# If the head is <= to the top of the stack, then we need to pair it with
# everything on the stack to the right of it.
# The head can also start a new region, so it's pushed onto the stack.
{leftovers, new_tail_stack} = stack |> Enum.split_while(fn {_, y} -> x <= y end)
new_pairs = leftovers |> Enum.map(&{&1, head, empties})
do_pair_cells(tail, [head | new_tail_stack], [], new_pairs ++ pairs)
end

@spec pairs_to_ranges([{Line.cell(), Line.cell()}]) :: [FoldingRange.t()]
defp pairs_to_ranges(pairs) do
pairs
|> Enum.map(fn {{r1, _}, {r2, _}} ->
%{
startLine: r1,
endLine: r2 - 1,
kind?: :region
}
end)
end
end
Loading