diff --git a/apps/language_server/lib/language_server/protocol.ex b/apps/language_server/lib/language_server/protocol.ex index faf5af976..e30a8442f 100644 --- a/apps/language_server/lib/language_server/protocol.ex +++ b/apps/language_server/lib/language_server/protocol.ex @@ -181,6 +181,15 @@ defmodule ElixirLS.LanguageServer.Protocol do end end + defmacro selection_range_req(id, uri, positions) do + quote do + request(unquote(id), "textDocument/selectionRange", %{ + "textDocument" => %{"uri" => unquote(uri)}, + "positions" => unquote(positions) + }) + end + end + defmacro execute_command_req(id, command, arguments) do quote do request(unquote(id), "workspace/executeCommand", %{ 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 index 78e533f6e..ec1265e55 100644 --- 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 @@ -38,13 +38,14 @@ defmodule ElixirLS.LanguageServer.Providers.FoldingRange.CommentBlock do ranges = lines |> group_comments() + |> Enum.filter(fn group -> length(group) > 1 end) |> 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 + def group_comments(lines) do lines |> Enum.reduce([[]], fn {_, cell, "#"}, [[{_, "#"} | _] = head | tail] -> @@ -59,7 +60,6 @@ defmodule ElixirLS.LanguageServer.Providers.FoldingRange.CommentBlock do _, acc -> acc end) - |> Enum.filter(fn group -> length(group) > 1 end) end @spec convert_comment_group_to_range([[{Line.cell(), String.t()}]]) :: FoldingRange.t() 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 index 6894ecfe8..b5697447a 100644 --- a/apps/language_server/lib/language_server/providers/folding_range/indentation.ex +++ b/apps/language_server/lib/language_server/providers/folding_range/indentation.ex @@ -53,7 +53,7 @@ defmodule ElixirLS.LanguageServer.Providers.FoldingRange.Indentation do {:ok, ranges} end - defp extract_cell({_line, cell, _first}), do: cell + def extract_cell({_line, cell, _first}), do: cell @doc """ Pairs cells into {start, end} tuples of regions 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 index 35691b5e5..14243b426 100644 --- 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 @@ -56,27 +56,34 @@ defmodule ElixirLS.LanguageServer.Providers.FoldingRange.SpecialToken do end @spec group_tokens([Token.t()]) :: [[Token.t()]] - defp group_tokens(tokens) do + def group_tokens(tokens) do do_group_tokens(tokens, []) end defp do_group_tokens([], acc), do: acc - # Don't create folding ranges for docs - defp do_group_tokens([{:identifier, _, doc_identifier}, {false, _, _} | rest], acc) + # Don't create folding ranges for @doc false + # TODO why? + defp do_group_tokens( + [{:at_op, _, _}, {:identifier, _, doc_identifier}, {false, _, _} | rest] = tokens, + acc + ) when doc_identifier in @docs do do_group_tokens(rest, acc) end # Start a folding range for `@doc` and `@moduledoc` - defp do_group_tokens([{:identifier, _, doc_identifier} = token | rest], acc) + defp do_group_tokens( + [{:at_op, _, _} = at_op, {:identifier, _, doc_identifier} = token | rest] = tokens, + acc + ) when doc_identifier in @docs do - acc = [[token] | acc] + acc = [[token, at_op] | acc] do_group_tokens(rest, acc) end # Amend the folding range - defp do_group_tokens([{k, _, _} = token | rest], [[{:identifier, _, _}] = head | tail]) + defp do_group_tokens([{k, _, _} = token | rest], [[{:identifier, _, _} | _] = head | tail]) when k in @kinds do acc = [[token | head] | tail] do_group_tokens(rest, acc) @@ -118,7 +125,7 @@ defmodule ElixirLS.LanguageServer.Providers.FoldingRange.SpecialToken do end defp classify_group({kind, {start_line, _, _}, _}, {_, {end_line, _, _}, _}) do - kind = if kind == :identifier, do: :comment, else: :region + kind = if kind == :at_op, do: :comment, else: :region {start_line, end_line, kind} 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 index 4ef75c0c9..572237740 100644 --- 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 @@ -58,7 +58,7 @@ defmodule ElixirLS.LanguageServer.Providers.FoldingRange.TokenPair do end @spec pair_tokens([Token.t()]) :: [{Token.t(), Token.t()}] - defp pair_tokens(tokens) do + def pair_tokens(tokens) do do_pair_tokens(tokens, [], []) end @@ -82,8 +82,8 @@ defmodule ElixirLS.LanguageServer.Providers.FoldingRange.TokenPair do 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) + # Map.fetch!/2 will always succeed because we only push matches to the stack. + head_matches_top? = @token_pairs |> Map.fetch!(top_kind) |> Enum.member?(head_kind) {new_stack, new_pairs} = case {head_matches_any?, head_matches_top?} do diff --git a/apps/language_server/lib/language_server/providers/selection_ranges.ex b/apps/language_server/lib/language_server/providers/selection_ranges.ex new file mode 100644 index 000000000..710433cea --- /dev/null +++ b/apps/language_server/lib/language_server/providers/selection_ranges.ex @@ -0,0 +1,483 @@ +defmodule ElixirLS.LanguageServer.Providers.SelectionRanges do + @moduledoc """ + This module provides document/selectionRanges support + + https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_selectionRange + """ + + alias ElixirLS.LanguageServer.{SourceFile} + alias ElixirLS.LanguageServer.Providers.FoldingRange + import ElixirLS.LanguageServer.Protocol + + defp token_length(:end), do: 3 + defp token_length(token) when token in [:"(", :"[", :"{", :")", :"]", :"}"], do: 1 + defp token_length(token) when token in [:"<<", :">>", :do, :fn], do: 2 + defp token_length(_), do: 0 + + def selection_ranges(text, positions) do + lines = SourceFile.lines(text) + full_file_range = full_range(lines) + + tokens = FoldingRange.Token.format_string(text) + + token_pairs = FoldingRange.TokenPair.pair_tokens(tokens) + + special_token_groups = + for group <- FoldingRange.SpecialToken.group_tokens(tokens) do + FoldingRange.Helpers.first_and_last_of_list(group) + end + + formatted_lines = FoldingRange.Line.format_string(text) + + comment_groups = + formatted_lines + |> FoldingRange.CommentBlock.group_comments() + + parse_result = + Code.string_to_quoted(text, + token_metadata: true, + columns: true, + literal_encoder: fn literal, meta -> + {:ok, {literal, meta, nil}} + end + ) + + cell_pairs = + formatted_lines + |> Enum.map(&FoldingRange.Indentation.extract_cell/1) + |> FoldingRange.Indentation.pair_cells() + + for %{"line" => line, "character" => character} <- positions do + {line, character} = SourceFile.lsp_position_to_elixir(lines, {line, character}) + # for convenance the code in this module uses 0 based indexing + {line, character} = {line - 1, character - 1} + + cell_pair_ranges = + ([full_file_range] ++ + for {{start_line, start_character}, {end_line, _end_line_start_character}} <- + cell_pairs |> dbg, + (start_line < line or (start_line == line and start_character <= character)) and + end_line > line do + line_length = lines |> Enum.at(end_line - 1) |> String.length() + second_line = lines |> Enum.at(start_line + 1) + + second_line_indent = + String.length(second_line) - String.length(String.trim_leading(second_line)) + + [range(start_line, start_character, end_line - 1, line_length)] + |> Kernel.++( + if(line >= start_line + 1, + do: [range(start_line + 1, 0, end_line - 1, line_length)], + else: [] + ) + ) + |> Kernel.++( + if( + line > start_line + 1 or + (line == start_line + 1 and character >= second_line_indent), + do: [range(start_line + 1, second_line_indent, end_line - 1, line_length)], + else: [] + ) + ) + end) + |> List.flatten() + + cell_pair_ranges = sort_ranges(cell_pair_ranges) + + token_pair_ranges = + token_pairs + |> Enum.filter(fn {{_, {start_line, start_character, _}, _}, + {end_token, {end_line, end_character, _}, _}} -> + end_token_length = token_length(end_token) + + (start_line < line or (start_line == line and start_character <= character)) and + (end_line > line or + (end_line == line and end_character + end_token_length >= character)) + end) + |> Enum.reduce([full_file_range], fn {{start_token, {start_line, start_character, _}, _}, + {end_token, {end_line, end_character, _}, _}}, + acc -> + start_token_length = token_length(start_token) + end_token_length = token_length(end_token) + + outer_range = + range(start_line, start_character, end_line, end_character + end_token_length) + + case end_token do + :end -> + if line < start_line + 1 or line > end_line - 1 do + # do not include inner range if cursor is outside, e.g. + # do + # ^ + [outer_range | acc] + else + line_length = lines |> Enum.at(end_line - 1) |> String.length() + [range(start_line + 1, 0, end_line - 1, line_length), outer_range | acc] + end + + _ -> + if (start_line == line and start_character + start_token_length > character) or + (end_line == line and end_character < character) do + # do not include inner range if cursor is outside, e.g. + # << 123 >> + # ^ ^ + [outer_range | acc] + else + [ + range( + start_line, + start_character + start_token_length, + end_line, + end_character + ), + outer_range | acc + ] + end + end + end) + |> Enum.reverse() + + special_token_group_ranges = + [full_file_range] ++ + for {{_end_token, {end_line, end_character, _}, _}, + {_start_token, {start_line, start_character, _}, _}} <- special_token_groups, + end_token_length = 0, + (start_line < line or (start_line == line and start_character <= character)) and + (end_line > line or + (end_line == line and end_character + end_token_length >= character)) do + range(start_line, start_character, end_line, end_character) + end + + comment_block_ranges = + [full_file_range] ++ + (for group <- comment_groups, + group != [], + {{{end_line, end_line_start_character}, _}, {{start_line, start_character}, _}} = + FoldingRange.Helpers.first_and_last_of_list(group), + (start_line < line or (start_line == line and start_character <= character)) and + (end_line > line or (end_line == line and end_line_start_character <= character)) do + case group do + [_] -> + line_length = lines |> Enum.at(start_line) |> String.length() + full_line_range = range(start_line, 0, start_line, line_length) + [full_line_range, range(start_line, start_character, start_line, line_length)] + + _ -> + end_line_length = lines |> Enum.at(end_line) |> String.length() + full_block_full_line_range = range(start_line, 0, end_line, end_line_length) + full_block_range = range(start_line, start_character, end_line, end_line_length) + + [full_block_full_line_range, full_block_range] ++ + Enum.find_value(group, fn {{cursor_line, cursor_line_character}, _} -> + if cursor_line == line do + cursor_line_length = lines |> Enum.at(cursor_line) |> String.length() + + line_range = + range( + cursor_line, + cursor_line_character, + cursor_line, + cursor_line_length + ) + + if cursor_line > start_line do + full_line_range = range(cursor_line, 0, cursor_line, cursor_line_length) + [full_line_range, line_range] + else + # do not include full line range if cursor is on the first line of the block as it will conflict with full_block_range + [line_range] + end + end + end) + end + end + |> List.flatten()) + + ast_ranges = + case parse_result do + {:ok, ast} -> + {_new_ast, {acc, []}} = + Macro.traverse( + ast, + {[full_file_range], []}, + fn + {node, meta, _} = ast, {acc, parent_meta} -> + parent_meta_from_stack = + case parent_meta do + [] -> [] + [item | _] -> item + end + + {start_line, start_character} = + {Keyword.get(meta, :line, 0) - 1, Keyword.get(meta, :column, 0) - 1} + + {end_line, end_character} = + cond do + end_location = meta[:end_of_expression] -> + {end_location[:line] - 1, end_location[:column] - 1} + + end_location = meta[:end] -> + {end_location[:line] - 1, end_location[:column] - 1 + 3} + + end_location = meta[:closing] -> + closing_length = + case node do + :<<>> -> 2 + _ -> 1 + end + + {end_location[:line] - 1, end_location[:column] - 1 + closing_length} + + token = meta[:token] -> + {start_line, start_character + String.length(token)} + + # is_atom(node) -> + # {start_line, start_character + String.length(to_string(node))} + + meta[:delimiter] && (is_list(node) or is_binary(node)) -> + {start_line, start_character + String.length(to_string(node))} + + # TODO a few other cases + + # parent_end_line = + # parent_meta_from_stack + # |> dbg() + # |> Keyword.get(:end, []) + # |> Keyword.get(:line) -> + # # last expression in block does not have end_of_expression + # parent_do_line = parent_meta_from_stack[:do][:line] + + # if parent_end_line > parent_do_line do + # # take end location from parent and assume end_of_expression is last char in previous line + # end_of_expression = + # Enum.at(lines, max(parent_end_line - 2, 0)) + # |> String.length() + + # SourceFile.elixir_position_to_lsp( + # lines, + # {parent_end_line - 1, end_of_expression + 1} + # ) + # else + # # take end location from parent and assume end_of_expression is last char before final ; trimmed + # line = Enum.at(lines, parent_end_line - 1) + # parent_end_column = parent_meta_from_stack[:end][:column] + + # end_of_expression = + # line + # |> String.slice(0..(parent_end_column - 2)) + # |> String.trim_trailing() + # |> String.replace_trailing(";", "") + # |> String.length() + + # SourceFile.elixir_position_to_lsp( + # lines, + # {parent_end_line, end_of_expression + 1} + # ) + # end + true -> + {start_line, start_character} + end + + if (start_line < line or (start_line == line and start_character <= character)) and + (end_line > line or (end_line == line and end_character >= character)) do + # dbg(ast) + {ast, + {[range(start_line, start_character, end_line, end_character) | acc], + [meta | parent_meta]}} + else + {ast, {acc, [meta | parent_meta]}} + end + + other, {acc, parent_meta} -> + {other, {acc, parent_meta}} + end, + fn + {_, _meta, _} = ast, {acc, [_ | tail]} -> + {ast, {acc, tail}} + + other, {acc, stack} -> + {other, {acc, stack}} + end + ) + + acc + |> sort_ranges() + + _ -> + [full_file_range] + end + |> IO.inspect(label: "ast ranges") + + surround_context_ranges = + [full_file_range] ++ + case Code.Fragment.surround_context(text, {line + 1, character + 1}) do + :none -> + [] + + %{begin: {start_line, start_character}, end: {end_line, end_character}} -> + [range(start_line - 1, start_character - 1, end_line - 1, end_character - 1)] + end + + token_pair_ranges + |> merge_ranges(cell_pair_ranges |> dbg) + |> merge_ranges(special_token_group_ranges |> dbg) + |> merge_ranges(comment_block_ranges |> dbg) + |> merge_ranges(surround_context_ranges |> dbg) + |> merge_ranges(ast_ranges |> dbg) + |> dbg + |> Enum.reduce(nil, fn selection_range, parent -> + range(start_line_elixir, start_character_elixir, end_line_elixir, end_character_elixir) = + selection_range + + # positions are 0-based + {start_line_lsp, start_character_lsp} = + SourceFile.elixir_position_to_lsp( + lines, + {start_line_elixir + 1, start_character_elixir + 1} + ) + + {end_line_lsp, end_character_lsp} = + SourceFile.elixir_position_to_lsp( + lines, + {end_line_elixir + 1, end_character_elixir + 1} + ) + + %{ + "range" => range(start_line_lsp, start_character_lsp, end_line_lsp, end_character_lsp), + "parent" => parent + } + end) + |> IO.inspect() + + # cursor_location = SourceFile.lsp_position_to_elixir(text, {line, character}) + end + end + + def merge_ranges(range_1, range_2) do + do_merge_ranges(range_1, range_2, []) + |> Enum.reverse() + end + + def do_merge_ranges([], [], acc) do + acc + end + + def do_merge_ranges([range | rest_1], [], acc) do + do_merge_ranges(rest_1, [], [range | acc]) + end + + def do_merge_ranges([], [range | rest_2], acc) do + do_merge_ranges([], rest_2, [range | acc]) + end + + def do_merge_ranges([range | rest_1], [range | rest_2], acc) do + do_merge_ranges(rest_1, rest_2, [range | acc]) + end + + def do_merge_ranges([range_1 | rest_1], [range_2 | rest_2], acc) do + IO.inspect({range_1, range_2}, label: "merging") + IO.inspect(acc, label: "acc") + + range_2 = + case acc do + [] -> + range_2 + + [last_range | _] -> + # we might have added a narrower range by favoring range_1 in the previous iteration + # compute intersection + intersection(last_range, range_2) + end + + cond do + left_in_right?(range_2, range_1) -> + # range_2 in range_1 + IO.puts("range_2 in range_1") + do_merge_ranges(rest_1, [range_2 | rest_2], [range_1 | acc]) + + left_in_right?(range_1, range_2) -> + # range_1 in range_2 + IO.puts("range_1 in range_2") + do_merge_ranges([range_1 | rest_1], rest_2, [range_2 | acc]) + + true -> + # ranges intersect - add union and favor range_1 + union_range = union(range_1, range_2) + IO.inspect(union_range, label: "union") + do_merge_ranges(rest_1, rest_2, [range_1, union_range | acc]) + end + end + + # this function differs from the one in SourceFile - it returns utf8 ranges + defp full_range(lines) do + utf8_size = + lines + |> List.last() + |> String.length() + + range(0, 0, Enum.count(lines) - 1, utf8_size) + end + + defp sort_ranges(ranges) do + ranges + |> Enum.sort_by(fn range(start_line, start_character, end_line, end_character) -> + {start_line - end_line, start_character - end_character} + end) + end + + defp union( + range(start_line_1, start_character_1, end_line_1, end_character_1), + range(start_line_2, start_character_2, end_line_2, end_character_2) + ) do + {start_line, start_character} = + cond do + start_line_1 < start_line_2 -> {start_line_1, start_character_1} + start_line_1 > start_line_2 -> {start_line_2, start_character_2} + true -> {start_line_1, min(start_character_1, start_character_2)} + end + + {end_line, end_character} = + cond do + end_line_1 < end_line_2 -> {end_line_2, end_character_2} + end_line_1 > end_line_2 -> {end_line_1, end_character_1} + true -> {end_line_1, max(end_character_1, end_character_2)} + end + + range(start_line, start_character, end_line, end_character) + end + + defp intersection( + range(start_line_1, start_character_1, end_line_1, end_character_1), + range(start_line_2, start_character_2, end_line_2, end_character_2) + ) do + {start_line, start_character} = + cond do + start_line_1 < start_line_2 -> {start_line_2, start_character_2} + start_line_1 > start_line_2 -> {start_line_1, start_character_1} + true -> {start_line_1, max(start_character_1, start_character_2)} + end + + {end_line, end_character} = + cond do + end_line_1 < end_line_2 -> {end_line_1, end_character_1} + end_line_1 > end_line_2 -> {end_line_2, end_character_2} + true -> {end_line_1, min(end_character_1, end_character_2)} + end + + if start_line > end_line or (start_line == end_line and start_character > end_character) do + raise ArgumentError, message: "no intersection" + end + + range(start_line, start_character, end_line, end_character) + end + + defp left_in_right?( + range(start_line_1, start_character_1, end_line_1, end_character_1), + range(start_line_2, start_character_2, end_line_2, end_character_2) + ) do + (start_line_1 > start_line_2 or + (start_line_1 == start_line_2 and start_character_1 >= start_character_2)) and + (end_line_1 < end_line_2 or + (end_line_1 == end_line_2 and end_character_1 <= end_character_2)) + end +end diff --git a/apps/language_server/lib/language_server/server.ex b/apps/language_server/lib/language_server/server.ex index be1042238..745107e35 100644 --- a/apps/language_server/lib/language_server/server.ex +++ b/apps/language_server/lib/language_server/server.ex @@ -42,7 +42,8 @@ defmodule ElixirLS.LanguageServer.Server do OnTypeFormatting, CodeLens, ExecuteCommand, - FoldingRange + FoldingRange, + SelectionRanges } alias ElixirLS.Utils.Launch @@ -939,6 +940,7 @@ defmodule ElixirLS.LanguageServer.Server do ) when not is_initialized(server_instance_id) do show_version_warnings() + IO.inspect(client_capabilities) server_instance_id = :crypto.strong_rand_bytes(32) |> Base.url_encode64() |> binary_part(0, 32) @@ -1217,6 +1219,22 @@ defmodule ElixirLS.LanguageServer.Server do {:async, fun, state} end + defp handle_request(selection_range_req(_id, uri, positions), state = %__MODULE__{}) do + source_file = get_source_file(state, uri) + + fun = fn -> + if String.ends_with?(uri, [".ex", ".exs"]) or source_file.language_id in ["elixir"] do + ranges = SelectionRanges.selection_ranges(source_file.text, positions) + {:ok, ranges} + else + # TODO no support for eex + {:ok, []} + end + end + + {:async, fun, state} + end + defp handle_request(%{"method" => "$/" <> _}, state = %__MODULE__{}) do # "$/" requests that the server doesn't support must return method_not_found {:error, :method_not_found, nil, false, state} @@ -1265,6 +1283,7 @@ defmodule ElixirLS.LanguageServer.Server do "workspaceSymbolProvider" => true, "documentOnTypeFormattingProvider" => %{"firstTriggerCharacter" => "\n"}, "codeLensProvider" => %{"resolveProvider" => false}, + "selectionRangeProvider" => true, "executeCommandProvider" => %{ "commands" => ExecuteCommand.get_commands(server_instance_id) }, diff --git a/apps/language_server/lib/language_server/source_file.ex b/apps/language_server/lib/language_server/source_file.ex index a4a990af3..96c5ecf35 100644 --- a/apps/language_server/lib/language_server/source_file.ex +++ b/apps/language_server/lib/language_server/source_file.ex @@ -39,6 +39,30 @@ defmodule ElixirLS.LanguageServer.SourceFile do do_lines_with_endings(rest, line <> <>) end + def text_before(text, position_line, position_character) do + text + |> lines + |> Enum.with_index() + |> Enum.reduce_while([], fn + {line, count}, acc when count < position_line -> + {:cont, [line, ?\n | acc]} + + {line, count}, acc when count == position_line -> + slice = + characters_to_binary!(line, :utf8, :utf16) + |> (&binary_part( + &1, + 0, + min(position_character * 2, byte_size(&1)) + )).() + |> characters_to_binary!(:utf16, :utf8) + + {:halt, [slice, ?\n | acc]} + end) + |> Enum.reverse() + |> IO.iodata_to_binary() + end + def apply_content_changes(%__MODULE__{} = source_file, []) do source_file end diff --git a/apps/language_server/test/providers/folding_range_test.exs b/apps/language_server/test/providers/folding_range_test.exs index 5d35147e7..47a5226aa 100644 --- a/apps/language_server/test/providers/folding_range_test.exs +++ b/apps/language_server/test/providers/folding_range_test.exs @@ -355,7 +355,7 @@ defmodule ElixirLS.LanguageServer.Providers.FoldingRangeTest do @tag text: """ defmodule A do # 0 - @module doc ~S\"\"\" + @moduledoc ~S\"\"\" sigil @moduledoc # 2 \"\"\" diff --git a/apps/language_server/test/providers/selection_ranges_test.exs b/apps/language_server/test/providers/selection_ranges_test.exs new file mode 100644 index 000000000..db522f8c1 --- /dev/null +++ b/apps/language_server/test/providers/selection_ranges_test.exs @@ -0,0 +1,450 @@ +defmodule ElixirLS.LanguageServer.Providers.SelectionRangesTest do + use ExUnit.Case + + alias ElixirLS.LanguageServer.Providers.SelectionRanges + alias ElixirLS.LanguageServer.{SourceFile} + import ElixirLS.LanguageServer.Protocol + + defp get_ranges(text, line, character) do + SelectionRanges.selection_ranges(text, [%{"line" => line, "character" => character}]) + |> hd + |> flatten + end + + defp flatten(range) do + flatten(range, []) + end + + defp flatten(nil, acc), do: acc + + defp flatten(%{"range" => range, "parent" => parent}, acc) do + flatten(parent, [range | acc]) + end + + describe "token pair ranges" do + test "brackets nested cursor inside" do + text = """ + [{1, 2}, 3] + """ + + ranges = get_ranges(text, 0, 3) + + # full range + assert Enum.at(ranges, 0) == range(0, 0, 1, 0) + # [] outside + assert Enum.at(ranges, 1) == range(0, 0, 0, 11) + # [] inside + assert Enum.at(ranges, 2) == range(0, 1, 0, 10) + # {} outside + assert Enum.at(ranges, 3) == range(0, 1, 0, 7) + # {} inside + assert Enum.at(ranges, 4) == range(0, 2, 0, 6) + end + + test "brackets cursor inside left" do + text = """ + {1, 2} + """ + + ranges = get_ranges(text, 0, 1) + + # full range + assert Enum.at(ranges, 0) == range(0, 0, 1, 0) + # {} outside + assert Enum.at(ranges, 1) == range(0, 0, 0, 6) + # {} inside + assert Enum.at(ranges, 2) == range(0, 1, 0, 5) + end + + test "brackets cursor inside right" do + text = """ + {1, 2} + """ + + ranges = get_ranges(text, 0, 5) + + # full range + assert Enum.at(ranges, 0) == range(0, 0, 1, 0) + # {} outside + assert Enum.at(ranges, 1) == range(0, 0, 0, 6) + # {} inside + assert Enum.at(ranges, 2) == range(0, 1, 0, 5) + end + + test "brackets cursor outside left" do + text = """ + {1, 2} + """ + + ranges = get_ranges(text, 0, 0) + + # full range + assert Enum.at(ranges, 0) == range(0, 0, 1, 0) + # {} outside + assert Enum.at(ranges, 1) == range(0, 0, 0, 6) + end + + test "brackets cursor outside right" do + text = """ + {1, 2} + """ + + ranges = get_ranges(text, 0, 0) + + # full range + assert Enum.at(ranges, 0) == range(0, 0, 1, 0) + # {} outside + assert Enum.at(ranges, 1) == range(0, 0, 0, 6) + end + end + + test "alias" do + text = """ + Some.Module.Foo + """ + + ranges = get_ranges(text, 0, 1) + + # full range + assert Enum.at(ranges, 0) == range(0, 0, 1, 0) + # full alias + assert Enum.at(ranges, 1) == range(0, 0, 0, 15) + end + + test "remote call" do + text = """ + Some.Module.Foo.some_fun() + """ + + ranges = get_ranges(text, 0, 17) + + # full range + assert Enum.at(ranges, 0) == range(0, 0, 1, 0) + # full remote call + assert Enum.at(ranges, 1) == range(0, 0, 0, 26) + # full remote call + assert Enum.at(ranges, 2) == range(0, 0, 0, 24) + end + + describe "comments" do + test "single comment" do + text = """ + # some comment + """ + + ranges = get_ranges(text, 0, 5) + + # full range + assert Enum.at(ranges, 0) == range(0, 0, 1, 0) + # full line + assert Enum.at(ranges, 1) == range(0, 0, 0, 16) + # from # + assert Enum.at(ranges, 2) == range(0, 2, 0, 16) + end + + test "comment block on first line" do + text = """ + # some comment + # continues here + # ends here + """ + + ranges = get_ranges(text, 0, 5) + + # full range + assert Enum.at(ranges, 0) == range(0, 0, 3, 0) + # full lines + assert Enum.at(ranges, 1) == range(0, 0, 2, 13) + # from # + assert Enum.at(ranges, 2) == range(0, 2, 2, 13) + # from # first line + assert Enum.at(ranges, 3) == range(0, 2, 0, 16) + end + + test "comment block on middle line" do + text = """ + # some comment + # continues here + # ends here + """ + + ranges = get_ranges(text, 1, 5) + + # full range + assert Enum.at(ranges, 0) == range(0, 0, 3, 0) + # full lines + assert Enum.at(ranges, 1) == range(0, 0, 2, 13) + # from # + assert Enum.at(ranges, 2) == range(0, 2, 2, 13) + # full # middle line + assert Enum.at(ranges, 3) == range(1, 0, 1, 18) + # from # middle line + assert Enum.at(ranges, 4) == range(1, 2, 1, 18) + end + + test "comment block on last line" do + text = """ + # some comment + # continues here + # ends here + """ + + ranges = get_ranges(text, 2, 5) + + # full range + assert Enum.at(ranges, 0) == range(0, 0, 3, 0) + # full lines + assert Enum.at(ranges, 1) == range(0, 0, 2, 13) + # from # + assert Enum.at(ranges, 2) == range(0, 2, 2, 13) + # full # last line + assert Enum.at(ranges, 3) == range(2, 0, 2, 13) + # from # last line + assert Enum.at(ranges, 4) == range(2, 2, 2, 13) + end + end + + describe "do-end" do + test "inside" do + text = """ + do + 1 + 24 + end + """ + + ranges = get_ranges(text, 1, 1) + # full range + assert Enum.at(ranges, 0) == range(0, 0, 4, 0) + # outside do-end + assert Enum.at(ranges, 1) == range(0, 0, 3, 3) + # inside do-end + assert Enum.at(ranges, 3) == range(1, 0, 2, 4) + end + + test "left from do" do + text = """ + do + 1 + 24 + end + """ + + ranges = get_ranges(text, 0, 0) + # full range + assert Enum.at(ranges, 0) == range(0, 0, 4, 0) + # outside do-end + assert Enum.at(ranges, 1) == range(0, 0, 3, 3) + # do + assert Enum.at(ranges, 3) == range(0, 0, 0, 2) + end + + test "right from do" do + text = """ + do + 1 + 24 + end + """ + + ranges = get_ranges(text, 0, 2) + # full range + assert Enum.at(ranges, 0) == range(0, 0, 4, 0) + # outside do-end + assert Enum.at(ranges, 1) == range(0, 0, 3, 3) + end + + test "left from end" do + text = """ + do + 1 + 24 + end + """ + + ranges = get_ranges(text, 3, 0) + # full range + assert Enum.at(ranges, 0) == range(0, 0, 4, 0) + # outside do-end + assert Enum.at(ranges, 1) == range(0, 0, 3, 3) + # end + assert Enum.at(ranges, 2) == range(3, 0, 3, 3) + end + + test "right from end" do + text = """ + do + 1 + 24 + end + """ + + ranges = get_ranges(text, 3, 3) + # full range + assert Enum.at(ranges, 0) == range(0, 0, 4, 0) + # outside do-end + assert Enum.at(ranges, 1) == range(0, 0, 3, 3) + end + end + + test "module and def" do + text = """ + defmodule Abc do + def some() do + :ok + end + end + """ + + ranges = get_ranges(text, 2, 4) + # full range + assert Enum.at(ranges, 0) == range(0, 0, 5, 0) + # defmodule + assert Enum.at(ranges, 1) == range(0, 0, 4, 3) + # def + assert Enum.at(ranges, 4) == range(1, 2, 3, 5) + end + + describe "doc" do + test "sigil" do + text = """ + @doc ~S\""" + This is a doc + \""" + """ + + ranges = get_ranges(text, 1, 0) + # full range + assert Enum.at(ranges, 0) == range(0, 0, 3, 0) + # full @doc + assert Enum.at(ranges, 1) == range(0, 0, 2, 3) + end + + test "heredoc" do + text = """ + @doc \""" + This is a doc + \""" + """ + + ranges = get_ranges(text, 1, 0) + # full range + assert Enum.at(ranges, 0) == range(0, 0, 3, 0) + # full @doc + assert Enum.at(ranges, 1) == range(0, 0, 2, 3) + end + + test "charlist heredoc" do + text = """ + @doc ''' + This is a doc + ''' + """ + + ranges = get_ranges(text, 1, 0) + # full range + assert Enum.at(ranges, 0) == range(0, 0, 3, 0) + # full @doc + assert Enum.at(ranges, 1) == range(0, 0, 2, 3) + end + end + + describe "literals" do + test "heredoc" do + text = """ + \""" + This is a doc + \""" + """ + + ranges = get_ranges(text, 1, 0) + # full range + assert Enum.at(ranges, 0) == range(0, 0, 3, 0) + # full literal + assert Enum.at(ranges, 1) == range(0, 2, 2, 3) + end + + test "number" do + text = """ + 1234 + 43 + """ + + ranges = get_ranges(text, 0, 0) + # full range + assert Enum.at(ranges, 0) == range(0, 0, 1, 0) + # full literal + assert Enum.at(ranges, 1) == range(0, 0, 0, 4) + end + + test "atom" do + text = """ + :asdfghj + """ + + ranges = get_ranges(text, 0, 1) + # full range + assert Enum.at(ranges, 0) == range(0, 0, 1, 0) + # full literal + assert Enum.at(ranges, 1) == range(0, 0, 0, 8) + end + + test "interpolated string" do + text = """ + "asdf\#{inspect([1, 2])}gfds" + """ + + ranges = get_ranges(text, 0, 17) + # full range + assert Enum.at(ranges, 0) == range(0, 0, 1, 0) + # full literal + assert Enum.at(ranges, 1) == range(0, 0, 0, 28) + # full interpolation + assert Enum.at(ranges, 2) == range(0, 5, 0, 23) + # inside #{} + assert Enum.at(ranges, 3) == range(0, 7, 0, 22) + # inside () + assert Enum.at(ranges, 4) == range(0, 15, 0, 21) + # literal + # NOTE AST only matching - no tokens inside interpolation + assert Enum.at(ranges, 5) == range(0, 16, 0, 17) + end + end + + test "case" do + text = """ + case x do + a -> + some_fun() + b -> + more() + funs() + end + """ + + ranges = get_ranges(text, 4, 5) + + # full range + assert Enum.at(ranges, 0) == range(0, 0, 7, 0) + # full b case + assert Enum.at(ranges, 5) == range(3, 2, 5, 10) + # b block + assert Enum.at(ranges, 7) == range(4, 4, 5, 10) + # more() + assert Enum.at(ranges, 8) == range(4, 4, 4, 10) + end + + test "utf16" do + text = """ + "foooob🏳️‍🌈rbaz" + """ + + ranges = get_ranges(text, 0, 1) + + # full range + assert Enum.at(ranges, 0) == range(0, 0, 1, 0) + # utf16 range + assert range(0, 0, 0, end_character) = Enum.at(ranges, 1) + + assert end_character == SourceFile.lines(text) |> Enum.at(0) |> SourceFile.line_length_utf16() + end +end