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 59 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
defmodule ElixirLS.LanguageServer.Providers.FoldingRange do
@moduledoc """
A textDocument/foldingRange provider implementation.

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

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} = FoldingRange.provide(%{text: text})

ranges
# [
# %{startLine: 0, endLine: 3},
# %{startLine: 1, endLine: 2}
# ]
Copy link
Contributor

Choose a reason for hiding this comment

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

Might as well turn that into a doctest 🙂

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hm, I just tried and there's some magic incantation I need to invoke to get the doctest to respect heredocs. I kept getting:

warning: variable "text" does not exist and is being expanded to "text()", please use parentheses to remove the ambiguity or change the variable name
  (for doctest at) lib/language_server/providers/folding_range.ex:29: ElixirLS.LanguageServer.Providers.FoldingRangeTest."doctest ElixirLS.LanguageServer.Providers.FoldingRange.provide/1 (1)"/1

I tried some of the suggestions I found from googling, but nothing worked.

Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, that's weird. I might have a go at it some time later.

"""
@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)
{:ok, indentation_ranges} = input |> FoldingRange.Indentation.provide_ranges()
{:ok, comment_block_ranges} = input |> FoldingRange.CommentBlock.provide_ranges()
{:ok, token_pair_ranges} = input |> FoldingRange.TokenPair.provide_ranges()
{:ok, special_token_ranges} = input |> FoldingRange.SpecialToken.provide_ranges()

ranges =
merge_ranges_with_priorities([
{1, indentation_ranges},
{2, comment_block_ranges},
{3, token_pair_ranges},
{3, special_token_ranges}
])

{:ok, ranges}
end

def convert_text_to_input(text) do
%{
tokens: FoldingRange.Token.format_string(text),
lines: FoldingRange.Line.format_string(text)
}
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,58 @@
defmodule ElixirLS.LanguageServer.Providers.FoldingRange.CommentBlock do
@moduledoc """
Code folding based on comment blocks.

Note that this can create comment regions inside heredocs.
It's a little sloppy, but I don't think it's a big problem.
"""

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

@doc """
Provides ranges for the source text based on the indentation level.
Note that we trim trailing empy rows from regions.
"""
@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,81 @@
defmodule ElixirLS.LanguageServer.Providers.FoldingRange.Indentation do
@moduledoc """
Code folding based on indentation only.
"""

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

@doc """
Provides ranges for the source text based on the indentation level.
Note that we trim trailing empy rows from regions.
"""
@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
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
defmodule ElixirLS.LanguageServer.Providers.FoldingRange.Line do
@moduledoc """
FoldingRange helpers for lines.
"""

alias ElixirLS.LanguageServer.SourceFile

@type cell :: {non_neg_integer(), non_neg_integer() | nil}
@type t :: {String.t(), cell(), String.t()}

@spec format_string(String.t()) :: [cell()]
def format_string(text) do
text
|> SourceFile.lines()
|> embellish_lines_with_metadata()
end

# If we think of the code text as a grid, this function finds the cells whose
# columns are the start of each row (line).
# Empty rows are represented as {row, nil}.
# We also grab the first character for convenience elsewhere.
@spec embellish_lines_with_metadata([String.t()]) :: [t()]
defp embellish_lines_with_metadata(lines) do
lines
|> Enum.with_index()
|> Enum.map(fn {line, row} ->
full_length = line |> SourceFile.line_length_utf16()
trimmed = line |> String.trim_leading()
trimmed_length = trimmed |> SourceFile.line_length_utf16()
first = trimmed |> String.first()

col =
if {full_length, trimmed_length} == {0, 0} do
billylanchantin marked this conversation as resolved.
Show resolved Hide resolved
nil
else
full_length - trimmed_length
end

{line, {row, col}, first}
end)
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
defmodule ElixirLS.LanguageServer.Providers.FoldingRange.SpecialToken do
@moduledoc """
TODO: document this
"""

alias ElixirLS.LanguageServer.Providers.FoldingRange
alias ElixirLS.LanguageServer.Providers.FoldingRange.Token

@kinds [
:bin_heredoc,
:bin_string,
:list_heredoc,
:list_string,
:sigil
]

@spec provide_ranges([FoldingRange.input()]) :: {:ok, [FoldingRange.t()]}
def provide_ranges(%{tokens: tokens}) do
ranges =
tokens
|> group_tokens()
|> convert_groups_to_ranges()

{:ok, ranges}
end

@spec group_tokens([Token.t()]) :: [[Token.t()]]
defp group_tokens(tokens) do
tokens
|> Enum.reduce([], fn
{:identifier, _, identifier} = token, acc when identifier in [:doc, :moduledoc] ->
[[token] | acc]

{k, _, _} = token, [[{:identifier, _, _}] = head | tail] when k in @kinds ->
[[token | head] | tail]

{k, _, _} = token, acc when k in @kinds ->
[[token] | acc]

{:eol, _, _} = token, [[{k, _, _} | _] = head | tail] when k in @kinds ->
[[token | head] | tail]

_, acc ->
acc
end)
end

@spec convert_groups_to_ranges([[Token.t()]]) :: [FoldingRange.t()]
defp convert_groups_to_ranges(groups) do
groups
|> Enum.map(fn group ->
# Each group comes out of group_tokens/1 reversed
{last, first} = FoldingRange.Helpers.first_and_last_of_list(group)
classify_group(first, last)
end)
|> Enum.map(fn {start_line, end_line, kind} ->
%{
startLine: start_line,
endLine: end_line - 1,
kind?: kind
}
end)
|> Enum.filter(fn range -> range.endLine > range.startLine end)
end

defp classify_group({kind, {start_line, _, _}, _}, {_, {end_line, _, _}, _}) do
kind = if kind == :identifier, do: :comment, else: :region
{start_line, end_line, kind}
end
end
Loading