diff --git a/apps/language_server/lib/language_server/protocol.ex b/apps/language_server/lib/language_server/protocol.ex index b5864f652..b5c2b639c 100644 --- a/apps/language_server/lib/language_server/protocol.ex +++ b/apps/language_server/lib/language_server/protocol.ex @@ -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 + # TODO remove in ElixirLS 0.8 defmacro macro_expansion(id, whole_buffer, selected_macro, macro_line) do quote do diff --git a/apps/language_server/lib/language_server/providers/folding_range.ex b/apps/language_server/lib/language_server/providers/folding_range.ex new file mode 100644 index 000000000..493946d15 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/folding_range.ex @@ -0,0 +1,145 @@ +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 + } + + @doc """ + Provides folding ranges for a source file + + ## Example + + iex> alias ElixirLS.LanguageServer.Providers.FoldingRange + iex> text = \""" + ...> defmodule A do # 0 + ...> def hello() do # 1 + ...> :world # 2 + ...> end # 3 + ...> end # 4 + ...> \""" + iex> FoldingRange.provide(%{text: text}) + {:ok, [ + %{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 diff --git a/apps/language_server/lib/language_server/providers/folding_range/comment_block.ex b/apps/language_server/lib/language_server/providers/folding_range/comment_block.ex new file mode 100644 index 000000000..de5db3587 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/folding_range/comment_block.ex @@ -0,0 +1,78 @@ +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 + + iex> alias ElixirLS.LanguageServer.Providers.FoldingRange + iex> text = \""" + ...> defmodule SomeModule do # 0 + ...> def some_function() do # 1 + ...> # I'm # 2 + ...> # a # 3 + ...> # comment block # 4 + ...> nil # 5 + ...> end # 6 + ...> end # 7 + ...> \""" + iex> FoldingRange.convert_text_to_input(text) + iex> |> CommentBlock.provide_ranges() + {:ok, [ + %{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 diff --git a/apps/language_server/lib/language_server/providers/folding_range/helpers.ex b/apps/language_server/lib/language_server/providers/folding_range/helpers.ex new file mode 100644 index 000000000..019b190d2 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/folding_range/helpers.ex @@ -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 diff --git a/apps/language_server/lib/language_server/providers/folding_range/indentation.ex b/apps/language_server/lib/language_server/providers/folding_range/indentation.ex new file mode 100644 index 000000000..6894ecfe8 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/folding_range/indentation.ex @@ -0,0 +1,113 @@ +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 + + iex> alias ElixirLS.LanguageServer.Providers.FoldingRange + iex> 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 + ...> \""" + iex> FoldingRange.convert_text_to_input(text) + ...> |> FoldingRange.Indentation.provide_ranges() + {:ok, [ + %{startLine: 0, endLine: 12, kind?: :region}, + %{startLine: 1, endLine: 11, kind?: :region}, + %{startLine: 7, endLine: 9, kind?: :region}, + %{startLine: 2, endLine: 5, 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 diff --git a/apps/language_server/lib/language_server/providers/folding_range/line.ex b/apps/language_server/lib/language_server/providers/folding_range/line.ex new file mode 100644 index 000000000..e2b25976e --- /dev/null +++ b/apps/language_server/lib/language_server/providers/folding_range/line.ex @@ -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 + nil + else + full_length - trimmed_length + end + + {line, {row, col}, first} + end) + end +end diff --git a/apps/language_server/lib/language_server/providers/folding_range/special_token.ex b/apps/language_server/lib/language_server/providers/folding_range/special_token.ex new file mode 100644 index 000000000..3254e22d7 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/folding_range/special_token.ex @@ -0,0 +1,99 @@ +defmodule ElixirLS.LanguageServer.Providers.FoldingRange.SpecialToken do + @moduledoc """ + Code folding based on "special" tokens. + + Several tokens, like `"..."`s, define ranges all on their own. + This module converts these tokens to ranges. + These ranges can be either `kind?: :comment` or `kind?: :region`. + """ + + alias ElixirLS.LanguageServer.Providers.FoldingRange + alias ElixirLS.LanguageServer.Providers.FoldingRange.Token + + @kinds [ + :bin_heredoc, + :bin_string, + :list_heredoc, + :list_string, + :sigil + ] + + @doc """ + Provides ranges based on "special" tokens + + ## Example + + iex> alias ElixirLS.LanguageServer.Providers.FoldingRange + iex> text = \""" + ...> defmodule A do # 0 + ...> def hello() do # 1 + ...> " + ...> regular string # 3 + ...> " + ...> ' + ...> charlist string # 6 + ...> ' + ...> end # 8 + ...> end # 9 + ...> \""" + iex> FoldingRange.convert_text_to_input(text) + ...> |> FoldingRange.SpecialToken.provide_ranges() + {:ok, [ + %{startLine: 5, endLine: 6, kind?: :region}, + %{startLine: 2, endLine: 3, kind?: :region}, + ]} + """ + @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 diff --git a/apps/language_server/lib/language_server/providers/folding_range/token.ex b/apps/language_server/lib/language_server/providers/folding_range/token.ex new file mode 100644 index 000000000..cdf2dad6e --- /dev/null +++ b/apps/language_server/lib/language_server/providers/folding_range/token.ex @@ -0,0 +1,55 @@ +defmodule ElixirLS.LanguageServer.Providers.FoldingRange.Token do + @moduledoc """ + This module normalizes the tokens provided by + + `ElixirSense.Core.Normalized.Tokenizer` + """ + + alias ElixirSense.Core.Normalized.Tokenizer + + @type t :: {atom(), {non_neg_integer(), non_neg_integer(), any()}, any()} + + @doc """ + Make pattern-matching easier by forcing all token tuples to be 3-tuples. + Also convert start_info to 0-indexing as ranges are 0-indexed. + """ + @spec format_string(String.t()) :: [t()] + def format_string(text) do + reversed_tokens = text |> Tokenizer.tokenize() + + reversed_tokens + # This reverses the tokens, but they come out of Tokenizer.tokenize/1 + # already reversed. + |> Enum.reduce_while({:ok, []}, fn tuple, {:ok, acc} -> + tuple = + case tuple do + {a, {b1, b2, b3}} -> + {a, {b1 - 1, b2 - 1, b3}, nil} + + {a, {b1, b2, b3}, c} -> + {a, {b1 - 1, b2 - 1, b3}, c} + + {:sigil, {b1, b2, b3}, _, _, _, _, delimiter} -> + {:sigil, {b1 - 1, b2 - 1, b3}, delimiter} + + # Older versions of Tokenizer.tokenize/1 + {:sigil, {b1, b2, b3}, _, _, _, delimiter} -> + {:sigil, {b1 - 1, b2 - 1, b3}, delimiter} + + # raise here? + _ -> + :error + end + + if tuple == :error do + {:halt, :error} + else + {:cont, {:ok, [tuple | acc]}} + end + end) + |> case do + {:ok, formatted_tokens} -> formatted_tokens + _ -> [] + end + end +end diff --git a/apps/language_server/lib/language_server/providers/folding_range/token_pairs.ex b/apps/language_server/lib/language_server/providers/folding_range/token_pairs.ex new file mode 100644 index 000000000..2e481b1bb --- /dev/null +++ b/apps/language_server/lib/language_server/providers/folding_range/token_pairs.ex @@ -0,0 +1,112 @@ +defmodule ElixirLS.LanguageServer.Providers.FoldingRange.TokenPair do + @moduledoc """ + Code folding based on pairs of tokens + + Certain pairs of tokens, like `do` and `end`, natrually define ranges. + These ranges all have `kind?: :region`. + + Note that we exclude the line that the 2nd of the pair, e.g. `end`, is on. + This is so that when collapsed, both tokens are visible. + """ + + alias ElixirLS.LanguageServer.Providers.FoldingRange + alias ElixirLS.LanguageServer.Providers.FoldingRange.Token + + @token_pairs %{ + "(": [:")"], + "[": [:"]"], + "{": [:"}"], + "<<": [:">>"], + # do blocks + do: [:block_identifier, :end], + block_identifier: [:block_identifier, :end], + # other special forms that are not covered by :block_identifier + with: [:do], + for: [:do], + case: [:do], + fn: [:end] + } + + @doc """ + Provides ranges based on token pairs + + ## Example + + iex> alias ElixirLS.LanguageServer.Providers.FoldingRange + iex> text = \""" + ...> defmodule Module do # 0 + ...> def some_function() do # 1 + ...> 4 # 2 + ...> end # 3 + ...> end # 4 + ...> \""" + iex> FoldingRange.convert_text_to_input(text) + ...> |> TokenPair.provide_ranges() + {:ok, [ + %{startLine: 0, endLine: 3, kind?: :region}, + %{startLine: 1, endLine: 2, kind?: :region} + ]} + """ + @spec provide_ranges([FoldingRange.input()]) :: {:ok, [FoldingRange.t()]} + def provide_ranges(%{tokens: tokens}) do + ranges = + tokens + |> pair_tokens() + |> convert_token_pairs_to_ranges() + + {:ok, ranges} + end + + @spec pair_tokens([Token.t()]) :: [{Token.t(), Token.t()}] + defp pair_tokens(tokens) do + do_pair_tokens(tokens, [], []) + end + + # Note + # Tokenizer.tokenize/1 doesn't differentiate between successful and failed + # attempts to tokenize the string. + # This could mean the returned tokens are unbalanced. + # Therefore, the stack may not be empty when the base clause is hit. + # We're choosing to return the successfully paired tokens rather than to + # return an error if not all tokens could be paired. + defp do_pair_tokens([], _stack, pairs), do: pairs + + defp do_pair_tokens([{head_kind, _, _} = head | tail_tokens], [], pairs) do + new_stack = if @token_pairs |> Map.has_key?(head_kind), do: [head], else: [] + do_pair_tokens(tail_tokens, new_stack, pairs) + end + + defp do_pair_tokens( + [{head_kind, _, _} = head | tail_tokens], + [{top_kind, _, _} = top | tail_stack] = stack, + pairs + ) do + head_matches_any? = @token_pairs |> Map.has_key?(head_kind) + # Map.get/2 will always succeed because we only push matches to the stack. + head_matches_top? = @token_pairs |> Map.get(top_kind) |> Enum.member?(head_kind) + + {new_stack, new_pairs} = + case {head_matches_any?, head_matches_top?} do + {false, false} -> {stack, pairs} + {false, true} -> {tail_stack, [{top, head} | pairs]} + {true, false} -> {[head | stack], pairs} + {true, true} -> {[head | tail_stack], [{top, head} | pairs]} + end + + do_pair_tokens(tail_tokens, new_stack, new_pairs) + end + + @spec convert_token_pairs_to_ranges([{Token.t(), Token.t()}]) :: [FoldingRange.t()] + defp convert_token_pairs_to_ranges(token_pairs) do + token_pairs + |> Enum.map(fn {{_, {start_line, _, _}, _}, {_, {end_line, _, _}, _}} -> + # -1 for end_line because the range should stop 1 short + # e.g. both "do" and "end" should be visible when collapsed + {start_line, end_line - 1} + end) + |> Enum.filter(fn {start_line, end_line} -> end_line > start_line end) + |> Enum.map(fn {start_line, end_line} -> + %{startLine: start_line, endLine: end_line, kind?: :region} + end) + end +end diff --git a/apps/language_server/lib/language_server/server.ex b/apps/language_server/lib/language_server/server.ex index 97bd5ac55..d502d3f07 100644 --- a/apps/language_server/lib/language_server/server.ex +++ b/apps/language_server/lib/language_server/server.ex @@ -30,7 +30,8 @@ defmodule ElixirLS.LanguageServer.Server do WorkspaceSymbols, OnTypeFormatting, CodeLens, - ExecuteCommand + ExecuteCommand, + FoldingRange } alias ElixirLS.Utils.Launch @@ -729,6 +730,17 @@ defmodule ElixirLS.LanguageServer.Server do end, state} end + defp handle_request(folding_range_req(_id, uri), state) do + case get_source_file(state, uri) do + nil -> + {:error, :server_error, "Missing source file", state} + + source_file -> + fun = fn -> FoldingRange.provide(source_file) end + {:async, fun, state} + end + end + # TODO remove in ElixirLS 0.8 defp handle_request(macro_expansion(_id, whole_buffer, selected_macro, macro_line), state) do IO.warn( @@ -792,7 +804,8 @@ defmodule ElixirLS.LanguageServer.Server do }, "workspace" => %{ "workspaceFolders" => %{"supported" => false, "changeNotifications" => false} - } + }, + "foldingRangeProvider" => true } end diff --git a/apps/language_server/lib/language_server/source_file.ex b/apps/language_server/lib/language_server/source_file.ex index dc0df0d3b..3929a8425 100644 --- a/apps/language_server/lib/language_server/source_file.ex +++ b/apps/language_server/lib/language_server/source_file.ex @@ -159,12 +159,11 @@ defmodule ElixirLS.LanguageServer.SourceFile do def full_range(source_file) do lines = lines(source_file) - last_line = List.last(lines) utf16_size = - :unicode.characters_to_binary(last_line, :utf8, :utf16) - |> byte_size() - |> div(2) + lines + |> List.last() + |> line_length_utf16() %{ "start" => %{"line" => 0, "character" => 0}, @@ -172,6 +171,13 @@ defmodule ElixirLS.LanguageServer.SourceFile do } end + def line_length_utf16(line) do + line + |> :unicode.characters_to_binary(:utf8, :utf16) + |> byte_size() + |> div(2) + end + defp prepend_line(line, nil, acc), do: [line | acc] defp prepend_line(line, ending, acc), do: [[line, ending] | acc] diff --git a/apps/language_server/test/language_server/providers/folding_range/comment_block_test.exs b/apps/language_server/test/language_server/providers/folding_range/comment_block_test.exs new file mode 100644 index 000000000..6d7deff32 --- /dev/null +++ b/apps/language_server/test/language_server/providers/folding_range/comment_block_test.exs @@ -0,0 +1,7 @@ +defmodule ElixirLS.LanguageServer.Providers.FoldingRange.CommentBlockTest do + use ExUnit.Case + + alias ElixirLS.LanguageServer.Providers.FoldingRange.CommentBlock + + doctest(CommentBlock) +end diff --git a/apps/language_server/test/language_server/providers/folding_range/indentation_test.exs b/apps/language_server/test/language_server/providers/folding_range/indentation_test.exs new file mode 100644 index 000000000..a4decfbbe --- /dev/null +++ b/apps/language_server/test/language_server/providers/folding_range/indentation_test.exs @@ -0,0 +1,7 @@ +defmodule ElixirLS.LanguageServer.Providers.FoldingRange.IndentationTest do + use ExUnit.Case + + alias ElixirLS.LanguageServer.Providers.FoldingRange.Indentation + + doctest(Indentation) +end diff --git a/apps/language_server/test/language_server/providers/folding_range/special_token_test.exs b/apps/language_server/test/language_server/providers/folding_range/special_token_test.exs new file mode 100644 index 000000000..abccf2bbf --- /dev/null +++ b/apps/language_server/test/language_server/providers/folding_range/special_token_test.exs @@ -0,0 +1,7 @@ +defmodule ElixirLS.LanguageServer.Providers.FoldingRange.SpecialTokenTest do + use ExUnit.Case + + alias ElixirLS.LanguageServer.Providers.FoldingRange.SpecialToken + + doctest(SpecialToken) +end diff --git a/apps/language_server/test/language_server/providers/folding_range/token_pairs_test.exs b/apps/language_server/test/language_server/providers/folding_range/token_pairs_test.exs new file mode 100644 index 000000000..a4e092c91 --- /dev/null +++ b/apps/language_server/test/language_server/providers/folding_range/token_pairs_test.exs @@ -0,0 +1,7 @@ +defmodule ElixirLS.LanguageServer.Providers.FoldingRange.TokenPairTest do + use ExUnit.Case + + alias ElixirLS.LanguageServer.Providers.FoldingRange.TokenPair + + doctest(TokenPair) +end diff --git a/apps/language_server/test/providers/folding_range_test.exs b/apps/language_server/test/providers/folding_range_test.exs new file mode 100644 index 000000000..5bcf92a98 --- /dev/null +++ b/apps/language_server/test/providers/folding_range_test.exs @@ -0,0 +1,550 @@ +defmodule ElixirLS.LanguageServer.Providers.FoldingRangeTest do + use ExUnit.Case + + alias ElixirLS.LanguageServer.Providers.FoldingRange + + doctest(FoldingRange) + + test "returns an :error tuple if input is not a source file" do + assert {:error, _} = %{} |> FoldingRange.provide() + end + + describe "indentation" do + setup [:fold_via_indentation] + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + :world # 2 + end # 3 + end # 4 + """ + test "basic test", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 3}, {1, 2}], text) + end + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + # world # 2 + if true do # 3 + :world # 4 + end # 5 + end # 6 + end # 7 + """ + test "consecutive matching levels", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 6}, {1, 5}, {3, 4}], text) + end + + @tag text: """ + defmodule A do # 0 + def f(%{"key" => value} = map) do # 1 + case NaiveDateTime.from_iso8601(value) do # 2 + {:ok, ndt} -> # 3 + dt = # 4 + ndt # 5 + |> DateTime.from_naive!("Etc/UTC") # 6 + |> Map.put(:microsecond, {0, 6}) # 7 + + %{map | "key" => dt} # 9 + + e -> # 11 + Logger.warn(\"\"\" + Could not use data map from #\{inspect(value)\} # 13 + #\{inspect(e)\} # 14 + \"\"\") + + :could_not_parse_value # 17 + end # 18 + end # 19 + end # 20 + """ + test "complicated function", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + expected = [{0, 19}, {1, 18}, {2, 17}, {3, 9}, {4, 7}, {11, 17}] + assert compare_condensed_ranges(ranges, expected, text) + end + + @tag 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 + """ + test "different complicated function", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 12}, {1, 11}, {2, 5}, {7, 9}], text) + end + + defp fold_via_indentation(%{text: text} = context) do + ranges_result = + text + |> FoldingRange.convert_text_to_input() + |> FoldingRange.Indentation.provide_ranges() + + {:ok, Map.put(context, :ranges_result, ranges_result)} + end + end + + describe "token pairs" do + setup [:fold_via_token_pairs] + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + :world # 2 + end # 3 + end # 4 + """ + test "basic test", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 3}, {1, 2}], text) + end + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + :world # 2 + end # 3 + end # 4 + """ + test "unusual indentation", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 3}, {1, 2}], text) + end + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + if true do # 2 + :hello # 3 + else # 4 + :error # 5 + end # 6 + end # 7 + end # 8 + """ + test "if-do-else-end", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 7}, {1, 6}, {2, 3}, {4, 5}], text) + end + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + try do # 2 + :hello # 3 + rescue # 4 + ArgumentError -> # 5 + IO.puts("rescue") # 6 + catch # 7 + value -> # 8 + IO.puts("catch") # 9 + else # 10 + value -> # 11 + IO.puts("else") # 12 + after # 13 + IO.puts("after") # 14 + end # 15 + end # 16 + end # 17 + """ + test "try block", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + expected = [{0, 16}, {1, 15}, {2, 3}, {4, 6}, {7, 9}, {10, 12}, {13, 14}] + assert compare_condensed_ranges(ranges, expected, text) + end + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + a = 20 # 2 + + case a do # 4 + 20 -> # 5 + :ok # 6 + + _ -> # 8 + :error # 9 + end # 10 + end # 11 + end # 12 + """ + test "1 defmodule, 1 def, 1 case", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 11}, {1, 10}, {4, 9}], text) + end + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + <<0>> # 2 + << # 3 + 1, 2, 3, # 4 + 4, 5, 6 # 5 + >> # 6 + end # 7 + end # 8 + """ + test "binaries", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 7}, {1, 6}, {3, 5}], text) + end + + @tag text: """ + defmodule A do # 0 + @moduledoc "This is module A" # 1 + end # 2 + + defmodule B do # 4 + @moduledoc "This is module B" # 5 + end # 6 + """ + test "2 defmodules in the top-level of file", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 1}, {4, 5}], text) + end + + @tag text: """ + defmodule A do # 0 + def compare_and_hello(list) do # 1 + assert list == [ # 2 + %{"a" => 1, "b" => 2}, # 3 + %{"a" => 3, "b" => 4}, # 4 + ] # 5 + + :world # 7 + end # 8 + end # 9 + """ + test "1 defmodule, 1 def, 1 list", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 8}, {1, 7}, {2, 4}], text) + end + + @tag text: """ + defmodule A do # 0 + def f(%{"key" => value} = map) do # 1 + case NaiveDateTime.from_iso8601(value) do # 2 + {:ok, ndt} -> # 3 + dt = # 4 + ndt # 5 + |> DateTime.from_naive!("Etc/UTC") # 6 + |> Map.put(:microsecond, {0, 6}) # 7 + + %{map | "key" => dt} # 9 + + e -> # 11 + Logger.warn(\"\"\" + Could not use data map from #\{inspect(value)\} # 13 + #\{inspect(e)\} # 14 + \"\"\") + + :could_not_parse_value # 17 + end # 18 + end # 19 + end # 20 + """ + test "complicated function", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 19}, {1, 18}, {2, 17}, {12, 14}], text) + end + + defp fold_via_token_pairs(%{text: text} = context) do + ranges_result = + text + |> FoldingRange.convert_text_to_input() + |> FoldingRange.TokenPair.provide_ranges() + + {:ok, Map.put(context, :ranges_result, ranges_result)} + end + end + + describe "special tokens" do + setup [:fold_via_special_tokens] + + @tag text: """ + defmodule A do # 0 + @moduledoc \"\"\" + @moduledoc heredoc # 2 + \"\"\" + + @doc \"\"\" + @doc heredoc # 6 + \"\"\" + def hello() do # 8 + \"\"\" + regular heredoc # 10 + \"\"\" + end # 12 + end # 13 + """ + test "@moduledoc, @doc, and stand-alone heredocs", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + expected = [{1, 2, :comment}, {5, 6, :comment}, {9, 10, :region}] + assert compare_condensed_ranges(ranges, expected, text) + end + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + " + regular string # 3 + " + ' + charlist string # 6 + ' + \"\"\" + regular heredoc # 9 + \"\"\" + ''' + charlist heredoc # 12 + ''' + end # 14 + end # 15 + """ + test "charlist heredocs", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{2, 3}, {5, 6}, {8, 9}, {11, 12}], text) + end + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + ~r/ + hello # 3 + / + ~r| + hello # 6 + | + ~r" + hello # 9 + " + ~r' + hello # 12 + ' + ~r( + hello # 15 + ) + ~r[ + hello # 18 + ] + ~r{ + hello # 21 + } + ~r< + hello # 24 + > + end # 26 + end # 27 + """ + test "sigil delimiters", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + expected = [{2, 3}, {5, 6}, {8, 9}, {11, 12}, {14, 15}, {17, 18}, {20, 21}, {23, 24}] + assert compare_condensed_ranges(ranges, expected, text) + end + + @tag text: """ + defmodule A do # 0 + @module doc ~S\"\"\" + sigil @moduledoc # 2 + \"\"\" + + @doc ~S\"\"\" + sigil @doc # 6 + \"\"\" + def hello() do # 8 + :world # 9 + end # 10 + end # 11 + """ + test "@doc with ~S sigil", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{1, 2, :comment}, {5, 6, :comment}], text) + end + + defp fold_via_special_tokens(%{text: text} = context) do + ranges_result = + text + |> FoldingRange.convert_text_to_input() + |> FoldingRange.SpecialToken.provide_ranges() + + {:ok, Map.put(context, :ranges_result, ranges_result)} + end + end + + describe "comment blocks" do + setup [:fold_via_comment_blocks] + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + # single comment # 2 + do_hello() # 3 + end # 4 + end # 5 + """ + test "no single line comment blocks", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [], text) + end + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + do_hello() # 2 + end # 3 + + # comment block 0 # 5 + # comment block 1 # 6 + # comment block 2 # 7 + defp do_hello(), do: :world # 8 + end # 9 + """ + test "@moduledoc, @doc, and stand-alone heredocs", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{5, 7}], text) + end + + defp fold_via_comment_blocks(%{text: text} = context) do + ranges_result = + text + |> FoldingRange.convert_text_to_input() + |> FoldingRange.CommentBlock.provide_ranges() + + {:ok, Map.put(context, :ranges_result, ranges_result)} + end + end + + describe "end to end" do + setup [:fold_text] + + @tag text: """ + defmodule A do # 0 + @moduledoc ~S\"\"\" + I'm a @moduledoc heredoc. # 2 + \"\"\" + + def f(%{"key" => value} = map) do # 5 + # comment block 0 # 6 + # comment block 1 # 7 + case NaiveDateTime.from_iso8601(value) do # 8 + {:ok, ndt} -> # 9 + dt = # 10 + ndt # 11 + |> DateTime.from_naive!("Etc/UTC") # 12 + |> Map.put(:microsecond, {0, 6}) # 13 + + %{map | "key" => dt} # 15 + + e -> # 17 + Logger.warn(\"\"\" + Could not use data map from #\{inspect(value)\} # 19 + #\{inspect(e)\} # 20 + \"\"\") + + :could_not_parse_value # 23 + end # 24 + end # 25 + end # 26 + """ + test "complicated function", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + + expected = [ + {0, 25}, + {1, 2}, + {5, 24}, + {6, 7}, + {8, 23}, + {9, 15}, + {10, 13}, + {17, 23}, + {18, 20} + ] + + assert compare_condensed_ranges(ranges, expected, text) + end + + defp fold_text(%{text: _text} = context) do + ranges_result = FoldingRange.provide(context) + {:ok, Map.put(context, :ranges_result, ranges_result)} + end + end + + defp compare_condensed_ranges(result, expected_condensed, text) do + result_condensed = + result + |> Enum.map(fn + %{startLine: start_line, endLine: end_line, kind?: kind} -> + {start_line, end_line, kind} + + %{startLine: start_line, endLine: end_line} -> + {start_line, end_line, :any} + end) + |> Enum.sort() + + expected_condensed = + expected_condensed + |> Enum.map(fn + {start_line, end_line, kind} -> + {start_line, end_line, kind} + + {start_line, end_line} -> + {start_line, end_line, :any} + end) + |> Enum.sort() + + {result_condensed, expected_condensed} = + Enum.zip(result_condensed, expected_condensed) + |> Enum.map(fn + {{rs, re, rk}, {es, ee, ek}} when rk == :any or ek == :any -> + {{rs, re, :any}, {es, ee, :any}} + + otherwise -> + otherwise + end) + |> Enum.unzip() + + if result_condensed != expected_condensed do + visualize_folding(text, result_condensed) + end + + assert result_condensed == expected_condensed + end + + def visualize_folding(nil, _), do: :ok + + def visualize_folding(text, result_condensed) do + lines = + String.split(text, "\n") + |> Enum.with_index() + |> Enum.map(fn {line, index} -> + String.pad_leading(to_string(index), 2, " ") <> ": " <> line + end) + + result_condensed + |> Enum.map(fn {line_start, line_end, :any} -> + out = + Enum.slice(lines, line_start, line_end - line_start + 2) + |> Enum.join("\n") + + IO.puts("Folding lines #{line_start}, #{line_end}:") + IO.puts(out) + IO.puts("\n") + end) + end +end