-
Notifications
You must be signed in to change notification settings - Fork 196
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
axelson
merged 64 commits into
elixir-lsp:master
from
billylanchantin:folding-range-billy
Mar 20, 2021
Merged
Changes from 60 commits
Commits
Show all changes
64 commits
Select commit
Hold shift + click to select a range
d17d22f
initial commit
billylanchantin 01c966d
can fold heredoc w/ closing paren
billylanchantin 64752dd
fix indentation
billylanchantin 3e40c30
save progress (this is a mess...)
billylanchantin 56fd54f
(potentially?) working version
billylanchantin 004fd6d
the code caught a problem with a test!
billylanchantin 8269e86
clean up a little
billylanchantin 1b9c0c4
add some explanatory comments
billylanchantin 84604ce
round out functionality
billylanchantin 8289634
fix some off-by-1 nonsense
billylanchantin c7c56f6
grammar
billylanchantin bc8587f
remember to add cur to the stack in all cases!
billylanchantin 1567d42
adjust test
billylanchantin 270b57d
whoo! case reduction!
billylanchantin 249b673
shorten comment
billylanchantin 8ff26cf
tweaks
billylanchantin f79664c
better formatting
billylanchantin 0f52639
add token-pairs module; start adding typespecs
billylanchantin 2c9b18d
refactor token-pairs
billylanchantin 5b876de
fix example
billylanchantin a40aea4
refactor indentation tests
billylanchantin 593b2c3
refactor indentation tests
billylanchantin f39848f
remove unused function
billylanchantin 9bbf34d
passing tests!
billylanchantin dc7349c
fix return types
billylanchantin ef95266
add unusual indentation and end-to-end tests
billylanchantin 61b3c2a
fix merging logic
billylanchantin 64d495b
update test names
billylanchantin 2409d8a
remove todo
billylanchantin 5304e25
add heredoc support
billylanchantin f39acb9
create a unified input
billylanchantin d8666d8
move function to helpers
billylanchantin 2722023
add support for comment blocks
billylanchantin 7c52c92
don't allow single line comment blocks
billylanchantin dbad74f
try not to sort multiple times; fix test
billylanchantin 268c203
note issue with implementation
billylanchantin 6861e09
include carriage returns in line-splitting logic
billylanchantin 4458bdf
use get_source_file function
billylanchantin 8d2c986
Merge branch 'folding-range-billy' of github.com:billylanchantin/elix…
billylanchantin 6a921d9
combine Enum.maps; add comment
billylanchantin a8c54a2
add `for` and `case`; add comments for clarity
billylanchantin 91252f0
attempt to deal with utf8/16 length calculations
billylanchantin 195acca
fix misunderstanding and use :block_identifier
billylanchantin df73fab
speling is hardd
billylanchantin b67a9f5
make group_comments/1 a defp; add @specs
billylanchantin fe82f52
replace a nested-reduce with a flat_map + group_by
billylanchantin 2d20671
drop kind_map, use @token_pairs directly
billylanchantin 5c291d3
refactor, change name, add @specs
billylanchantin 888814a
use pipes
billylanchantin 6bb06f8
fix warning
billylanchantin 254b093
add support for charlist heredocs
billylanchantin 5b9be3b
add binary support
billylanchantin dc1a27a
tweak test approach to allow specifying range kind
billylanchantin 21f1a7b
change heredoc pass to "special token" pass; add/modify tests
billylanchantin af1f545
change filename
billylanchantin 0b72960
change to singular module name for consistency
billylanchantin 29920e1
remove outdated comments
billylanchantin f8bf332
remove debug function
billylanchantin cc2fa59
(hopefully) cover older versions of tokenize
billylanchantin c3a3da4
documentation; harmless refactor
billylanchantin 9e0cacf
Switch to doctests
axelson 5fbdd6a
Merge pull request #1 from axelson/my-folding-range
billylanchantin 5bced31
Merge remote-tracking branch 'upstream/master' into folding-range-billy
axelson 35d3fd5
Update apps/language_server/lib/language_server/providers/folding_ran…
axelson File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
148 changes: 148 additions & 0 deletions
148
apps/language_server/lib/language_server/providers/folding_range.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
- `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 |
80 changes: 80 additions & 0 deletions
80
apps/language_server/lib/language_server/providers/folding_range/comment_block.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
14 changes: 14 additions & 0 deletions
14
apps/language_server/lib/language_server/providers/folding_range/helpers.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
117 changes: 117 additions & 0 deletions
117
apps/language_server/lib/language_server/providers/folding_range/indentation.ex
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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)