diff --git a/apps/language_server/lib/language_server/ast_utils.ex b/apps/language_server/lib/language_server/ast_utils.ex new file mode 100644 index 000000000..9702705d1 --- /dev/null +++ b/apps/language_server/lib/language_server/ast_utils.ex @@ -0,0 +1,409 @@ +defmodule ElixirLS.LanguageServer.AstUtils do + import ElixirLS.LanguageServer.Protocol + alias ElixirLS.LanguageServer.SourceFile + + @binary_operators ~w[| . ** * / + - ++ -- +++ --- .. <> in |> <<< >>> <<~ ~>> <~ ~> <~> < > <= >= == != === !== =~ && &&& and || ||| or = => :: when <- -> \\]a + @unary_operators ~w[@ + - ! ^ not &]a + + def node_range(node, options \\ []) + def node_range(atom, _options) when is_atom(atom), do: nil + + def node_range([{{:__block__, _, [_]} = first, _} | _] = list, _options) do + case List.last(list) do + {_, last} -> + case {node_range(first), node_range(last)} do + {range(start_line, start_character, _, _), range(_, _, end_line, end_character)} -> + range(start_line, start_character, end_line, end_character) + + _ -> + nil + end + + _ -> + nil + end + end + + def node_range(list, _options) when is_list(list), do: nil + + def node_range({:__block__, meta, args} = _ast, _options) do + line = Keyword.get(meta, :line) + column = Keyword.get(meta, :column) + + if line == nil or column == nil do + if match?([_ | _], args) do + first = List.first(args) + last = List.last(args) + + case {node_range(first), node_range(last)} do + {range(start_line, start_character, _, _), range(_, _, end_line, end_character)} -> + range(start_line, start_character, end_line, end_character) + + _ -> + nil + end + end + else + line = line - 1 + column = column - 1 + + {end_line, end_column} = + cond do + token = meta[:token] -> + {line, column + String.length(token)} + + end_location = meta[:closing] -> + # 2 element tuple + {end_location[:line] - 1, end_location[:column] - 1 + 1} + + match?([_], args) -> + [literal] = args + delimiter = meta[:delimiter] + + if delimiter in ["\"\"\"", "'''"] do + literal = + if is_list(literal) do + to_string(literal) + else + literal + end + + lines = SourceFile.lines(literal) + # meta[:indentation] is nil on 1.12 + indentation = Keyword.get(meta, :indentation, 0) + + {line + length(lines), indentation + get_delimiter_length(delimiter)} + else + get_literal_end(literal, {line, column}, delimiter) + end + + true -> + {line, column} + end + + range(line, column, end_line, end_column) + end + end + + # interpolated charlist AST is too complicated to handle via the generic algorithm + def node_range({{:., _, [List, :to_charlist]}, meta, _args} = ast, options) do + line = Keyword.get(meta, :line) - 1 + column = Keyword.get(meta, :column) - 1 + {end_line, end_column} = get_eoe_by_formatting(ast, {line, column}, options) + # on elixir 1.15+ formatter changes charlist '' to ~c"" sigil so we need to correct columns + # if charlist is single line + correction = + if end_line == line and Version.match?(System.version(), ">= 1.15.0-dev") do + 2 + else + 0 + end + + range(line, column, end_line, end_column - correction) + end + + # interpolated atom AST is too complicated to handle via the generic algorithm + def node_range({{:., _, [:erlang, :binary_to_atom]}, meta, _args} = ast, options) do + line = Keyword.get(meta, :line) - 1 + column = Keyword.get(meta, :column) - 1 + {end_line, end_column} = get_eoe_by_formatting(ast, {line, column}, options) + range(line, column, end_line, end_column) + end + + def node_range({form, meta, args} = ast, options) do + line = Keyword.get(meta, :line) + column = Keyword.get(meta, :column) + + if line == nil or column == nil do + nil + else + line = line - 1 + column = column - 1 + + start_position = + cond do + form == :%{} -> + column = + if Version.match?(System.version(), "< 1.16.2") do + # workaround elixir bug + # https://github.com/elixir-lang/elixir/commit/fd4e6b530c0e010712b06909c89820b08e49c238 + column - 1 + else + column + end + + {line, column} + + form == :-> and match?([[_ | _], _], args) -> + [[left | _], _right] = args + + case node_range(left) do + range(line, column, _, _) -> + {line, column} + + nil -> + nil + end + + form == :& and match?([int] when is_integer(int), args) -> + {line, column} + + form in @binary_operators and match?([_, _], args) -> + [left, _right] = args + + case node_range(left) do + range(line, column, _, _) -> + {line, column} + + nil -> + nil + end + + match?({:., _, [Kernel, :to_string]}, form) -> + {line, column} + + match?({:., _, [Access, :get]}, form) and match?([_ | _], args) -> + [arg | _] = args + + case node_range(arg) do + range(line, column, _, _) -> + {line, column} + + nil -> + nil + end + + match?({:., _, [_ | _]}, form) -> + {:., _, [module_or_var | _]} = form + + case node_range(module_or_var) do + range(line, column, _, _) -> + {line, column} + + nil -> + nil + end + + true -> + {line, column} + end + + end_position = + cond do + end_location = meta[:end] -> + {end_location[:line] - 1, end_location[:column] - 1 + 3} + + end_location = meta[:end_of_expression] -> + {end_location[:line] - 1, end_location[:column] - 1} + + end_location = meta[:closing] -> + closing_length = + case form do + :<<>> -> 2 + :fn -> 3 + _ -> 1 + end + + {end_location[:line] - 1, end_location[:column] - 1 + closing_length} + + form == :__aliases__ -> + last = meta[:last] + + if last do + last_length = + case List.last(args) do + atom when is_atom(atom) -> atom |> to_string() |> String.length() + _ -> 0 + end + + {last[:line] - 1, last[:column] - 1 + last_length} + else + # last is nil on 1.12 + get_eoe_by_formatting(ast, {line, column}, options) + end + + form == :% and match?([_, _], args) -> + [_alias, map] = args + + case node_range(map) do + range(_, _, end_line, end_column) -> + {end_line, end_column} + + nil -> + nil + end + + form == :<<>> or (is_atom(form) and String.starts_with?(to_string(form), "sigil_")) -> + # interpolated string AST is too complicated + # try to format it instead + get_eoe_by_formatting(ast, {line, column}, options) + + form == :& and match?([int] when is_integer(int), args) -> + [int] = args + {line, column + String.length(to_string(int))} + + form in @binary_operators and match?([_, _], args) -> + [_left, right] = args + + case node_range(right) do + range(_, _, end_line, end_column) -> + {end_line, end_column} + + nil -> + # e.g. inside form of a call - not enough meta {:., _, [alias, atom]} + nil + end + + form in @unary_operators and match?([_], args) -> + [right] = args + + case node_range(right) do + range(_, _, end_line, end_column) -> + {end_line, end_column} + + nil -> + nil + end + + match?({:., _, [_, _]}, form) -> + case args do + [] -> + {:., _, [_, fun]} = form + {line, column + String.length(to_string(fun))} + + _ -> + case node_range(List.last(args)) do + range(_, _, end_line, end_column) -> + {end_line, end_column} + + nil -> + nil + end + end + + is_atom(form) -> + variable_length = form |> to_string() |> String.length() + + case args do + nil -> + {line, column + variable_length} + + [] -> + {line, column + variable_length} + + _ -> + # local call no parens + last_arg = List.last(args) + + case node_range(last_arg) do + range(_, _, end_line, end_column) -> + {end_line, end_column} + + nil -> + nil + end + end + + true -> + raise "unhandled block" + end + + case {start_position, end_position} do + {{start_line, start_column}, {end_line, end_column}} -> + range(start_line, start_column, end_line, end_column) + + _ -> + nil + end + end + end + + def node_range(_, _options), do: nil + + def get_literal_end(true, {line, column}, _), do: {line, column + 4} + def get_literal_end(false, {line, column}, _), do: {line, column + 5} + def get_literal_end(nil, {line, column}, _), do: {line, column + 3} + + def get_literal_end(atom, {line, column}, delimiter) when is_atom(atom) do + delimiter_length = get_delimiter_length(delimiter) + lines = atom |> to_string() |> SourceFile.lines() + + case lines do + [only_line] -> + # add : + {line, column + String.length(only_line) + 1 + 2 * delimiter_length} + + _ -> + last_line_length = lines |> List.last() |> String.length() + {line + length(lines) - 1, last_line_length + 1 * delimiter_length} + end + end + + def get_literal_end(list, {line, column}, delimiter) when is_list(list) do + delimiter_length = get_delimiter_length(delimiter) + lines = list |> to_string() |> SourceFile.lines() + + case lines do + [only_line] -> + # add 2 x ' + {line, column + String.length(only_line) + 2 * delimiter_length} + + _ -> + # add 1 x ' + last_line_length = lines |> List.last() |> String.length() + {line + length(lines) - 1, last_line_length + 1 * delimiter_length} + end + end + + def get_literal_end(binary, {line, column}, delimiter) when is_binary(binary) do + delimiter_length = get_delimiter_length(delimiter) + lines = binary |> SourceFile.lines() + + case lines do + [only_line] -> + # add 2 x " + {line, column + String.length(only_line) + 2 * delimiter_length} + + _ -> + # add 1 x " + last_line_length = lines |> List.last() |> String.length() + {line + length(lines) - 1, last_line_length + 1 * delimiter_length} + end + end + + def get_delimiter_length(nil), do: 0 + def get_delimiter_length("\""), do: 1 + def get_delimiter_length("'"), do: 1 + def get_delimiter_length("\"\"\""), do: 3 + def get_delimiter_length("'''"), do: 3 + + defp get_eoe_by_formatting(ast, {line, column}, options) do + formatter_opts = Keyword.get(options, :formatter_opts, []) + locals_without_parens = Keyword.get(formatter_opts, :locals_without_parens, []) + line_length = Keyword.get(formatter_opts, :line_length, 98) + + code = + if Version.match?(System.version(), ">= 1.13.0-dev") do + ast + |> Code.quoted_to_algebra( + escape: false, + locals_without_parens: locals_without_parens + ) + |> Inspect.Algebra.format(line_length) + |> IO.iodata_to_binary() + else + Macro.to_string(ast) + end + + lines = code |> SourceFile.lines() + + case lines do + [_] -> + {line, column + String.length(code)} + + _ -> + last_line = List.last(lines) + {line + length(lines) - 1, String.length(last_line)} + end + end +end 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..7d89da5ae 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,33 @@ 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 + defp do_group_tokens( + [{:at_op, _, _}, {:identifier, _, doc_identifier}, {false, _, _} | rest], + 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], + 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 +124,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..574b9d0ac --- /dev/null +++ b/apps/language_server/lib/language_server/providers/selection_ranges.ex @@ -0,0 +1,464 @@ +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 + + There is no one good way to get selection ranges that is bot robust and accurate. This module uses a combination of + different approaches. Each produces different ranges (possibly contradictory) that are finally merged and combined + + Algorithms providers currently used: + 1. Token pairs (), [], do-end etc. with stop tokens , ; eol eof | + 2. Special token groups (regular/charlist strings/heredocs) and sigils + 3. Comment blocks + 4. Code.Fragment.surround_context + 5. AST + + First 3 algorithms reuse passes from folding ranges provider with some modifications + """ + + alias ElixirLS.LanguageServer.SourceFile + alias ElixirLS.LanguageServer.Providers.FoldingRange + import ElixirLS.LanguageServer.Protocol + import ElixirLS.LanguageServer.RangeUtils + alias ElixirLS.LanguageServer.AstUtils + alias ElixirSense.Core.Normalized.Code, as: NormalizedCode + + 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 + + @stop_tokens [:",", :";", :eol, :eof, :pipe_op] + + def selection_ranges(text, positions, options \\ []) do + lines = SourceFile.lines(text) + full_file_range = full_range(lines) + + tokens = FoldingRange.Token.format_string(text) + + token_pairs = FoldingRange.TokenPair.pair_tokens(tokens) + + stop_tokens = get_stop_tokens_in_token_pairs(tokens, token_pairs) + + 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, + unescape: false, + literal_encoder: fn literal, meta -> + {:ok, {:__block__, meta, [literal]}} + 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 = cell_pair_ranges(lines, cell_pairs, line, character) + + token_pair_ranges = + token_pair_ranges(lines, token_pairs, stop_tokens, line, character) + |> deduplicate + + special_token_group_ranges = + special_token_group_ranges(special_token_groups, line, character) + + comment_block_ranges = comment_block_ranges(lines, comment_groups, line, character) + + ast_node_ranges = ast_node_ranges(parse_result, line, character, options) + + surround_context_ranges = surround_context_ranges(text, line, character) + + merged_ranges = + [full_file_range | token_pair_ranges] + |> merge_ranges_lists([full_file_range | cell_pair_ranges]) + |> merge_ranges_lists([full_file_range | special_token_group_ranges]) + |> merge_ranges_lists([full_file_range | comment_block_ranges]) + |> merge_ranges_lists([full_file_range | surround_context_ranges]) + |> merge_ranges_lists([full_file_range | ast_node_ranges]) + + if not increasingly_narrowing?(merged_ranges) do + raise "merged_ranges are not increasingly narrowing" + end + + to_nested_lsp_message(merged_ranges, lines) + end + end + + defp to_nested_lsp_message(ranges, lines) do + ranges + |> 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) + end + + def token_pair_ranges(lines, token_pairs, stop_tokens, line, character) do + 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([], fn {{start_token, {start_line, start_character, _}, _}, + {end_token, {end_line, end_character, _}, _}} = pair, + acc -> + stop_tokens_in_pair = Map.get(stop_tokens, pair, []) + 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() + inner_range = range(start_line + 1, 0, end_line - 1, line_length) + + find_stop_token_range(stop_tokens_in_pair, pair, inner_range, line, character) ++ + [inner_range, 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 + inner_range = + range( + start_line, + start_character + start_token_length, + end_line, + end_character + ) + + find_stop_token_range(stop_tokens_in_pair, pair, inner_range, line, character) ++ + [ + inner_range, + outer_range | acc + ] + end + end + end) + |> Enum.reverse() + end + + defp find_stop_token_range([], _, _, _, _), do: [] + + defp find_stop_token_range(tokens, {begin_token, end_token}, inner_range, line, character) do + {_, found} = + Enum.reduce_while(tokens ++ [{end_token, nil, nil}], {{begin_token, nil, nil}, []}, fn + {token, before_stop, _} = token_tuple, {{previous_token, _, after_previous}, _} -> + {_, {start_line, start_character, _}, _} = previous_token + {_, {end_line, end_character, _}, _} = token + + range_between_stop_tokens = range(start_line, start_character, end_line, end_character) + + if in?(range_between_stop_tokens, {line, character}) do + # dbg({previous_token, after_previous, before_stop, token}) + {end_line, end_character} = + case before_stop do + {kind, _, _} when kind in [:bin_string, :list_string] -> + {end_line, end_character} + + {kind, {before_start_line, before_start_character, list}, _} when is_list(list) -> + length_modifier = + if kind == :atom do + 1 + else + 0 + end + + {before_start_line, before_start_character + length(list) + length_modifier} + + {_, {before_start_line, before_start_character, _}, list} when is_list(list) -> + {before_start_line, before_start_character + length(list)} + + {:atom_quoted, {before_start_line, before_start_character, _}, atom} -> + {before_start_line, before_start_character + String.length(to_string(atom)) + 3} + + _ -> + {end_line, end_character} + end + + {start_line, start_character} = + case after_previous do + {_, {after_end_line, after_end_character, _}, _} -> + {after_end_line, after_end_character} + + nil -> + {start_line, start_character} + end + + trimmed_range = + intersection( + range(start_line, start_character, end_line, end_character), + inner_range + ) + + if in?(trimmed_range, {line, character}) do + {:halt, {token_tuple, [trimmed_range]}} + else + {:halt, {token_tuple, []}} + end + else + {:cont, {token_tuple, []}} + end + end) + + found + end + + def cell_pair_ranges(lines, cell_pairs, line, character) do + for {{start_line, start_character}, {end_line, _end_line_start_character}} <- + cell_pairs, + (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() + |> sort_ranges_widest_to_narrowest() + end + + def special_token_group_ranges(special_token_groups, line, character) do + 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 + end + + def comment_block_ranges(lines, comment_groups, line, character) do + 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() + end + + @empty_node {:__block__, [], []} + + def ast_node_ranges({:ok, ast}, line, character, options) do + node_range_options = [ + formatter_opts: Keyword.get(options, :formatter_opts, []) + ] + + {_new_ast, {acc, [@empty_node]}} = + Macro.traverse( + ast, + {[], [@empty_node]}, + fn + ast, {acc, [parent_ast_from_stack | _] = parent_ast} -> + matching_range = + case AstUtils.node_range(ast, node_range_options) do + range(start_line, start_character, end_line, end_character) -> + start_character = + if match?({:%{}, _, _}, ast) and match?({:%, _, _}, parent_ast_from_stack) and + Version.match?(System.version(), "< 1.16.2") do + # workaround elixir bug + # https://github.com/elixir-lang/elixir/commit/fd4e6b530c0e010712b06909c89820b08e49c238 + # undo column offset for structs inner map node + start_character + 1 + else + start_character + end + + range = range(start_line, start_character, end_line, end_character) + + 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, range, parent_ast_from_stack}) + # {ast, {[range | acc], [ast | parent_ast]}} + range + else + # dbg({ast, range, {line, character}, "outside"}) + # {ast, {acc, [ast | parent_ast]}} + nil + end + + nil -> + # dbg({ast, "nil"}) + # {ast, {acc, [ast | parent_ast]}} + nil + end + + ranges_acc = + if matching_range != nil do + [matching_range | acc] + else + acc + end + + parent_acc = + if match?({_, _, _}, ast) do + [ast | parent_ast] + else + parent_ast + end + + {ast, {ranges_acc, parent_acc}} + end, + fn + {_, _meta, _} = ast, {acc, [_ | tail]} -> + {ast, {acc, tail}} + + other, {acc, stack} -> + {other, {acc, stack}} + end + ) + + acc + |> sort_ranges_widest_to_narrowest() + end + + def ast_node_ranges(_, _, _, _), do: [] + + def surround_context_ranges(text, line, character) do + case NormalizedCode.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 + end + + def get_stop_tokens_in_token_pairs(tokens, token_pairs) do + tokens_next = tl(tokens) ++ [nil] + tokens_prev = [nil | Enum.slice(tokens, 0..-2//1)] + tokens_prev_next = Enum.zip([tokens_prev, tokens, tokens_next]) + + for {prev_token, {token, {line, character, _}, _} = token_tuple, next_token} <- + tokens_prev_next, + token in @stop_tokens do + pair = + token_pairs + |> Enum.filter(fn {{_, {start_line, start_character, _}, _}, + {_, {end_line, end_character, _}, _}} -> + in?(range(start_line, start_character, end_line, end_character), {line, character}) + end) + |> Enum.min_by( + fn {{_, {start_line, start_character, _}, _}, {_, {end_line, end_character, _}, _}} -> + {end_line - start_line, end_character - start_character} + end, + &<=/2, + fn -> nil end + ) + + {pair, {token_tuple, prev_token, next_token}} + end + |> Enum.group_by(&elem(&1, 0)) + |> Enum.map(fn {pair, tuples} -> + {pair, Enum.map(tuples, &elem(&1, 1))} + end) + |> Map.new() + end +end diff --git a/apps/language_server/lib/language_server/range_utils.ex b/apps/language_server/lib/language_server/range_utils.ex new file mode 100644 index 000000000..dae9b0c45 --- /dev/null +++ b/apps/language_server/lib/language_server/range_utils.ex @@ -0,0 +1,183 @@ +defmodule ElixirLS.LanguageServer.RangeUtils do + @moduledoc """ + Utilities for working with ranges. + """ + + import ElixirLS.LanguageServer.Protocol + + # this function differs from the one in SourceFile - it returns utf8 ranges + def full_range(lines = [_ | _]) do + utf8_size = + lines + |> List.last() + |> String.length() + + range(0, 0, Enum.count(lines) - 1, utf8_size) + end + + def in?(range(start_line, start_character, end_line, end_character), {line, character}) do + (start_line < line or (start_line == line and start_character <= character)) and + (end_line > line or (end_line == line and end_character >= character)) + end + + def valid?(range(start_line, start_character, end_line, end_character)) + when is_integer(start_line) and is_integer(end_line) and is_integer(start_character) and + is_integer(end_character) do + (start_line >= 0 and end_line >= 0 and start_character >= 0 and end_character >= 0 and + start_line < end_line) or (start_line == end_line and start_character <= end_character) + end + + def valid?(_), do: false + + def increasingly_narrowing?([left]), do: valid?(left) + + def increasingly_narrowing?([left, right | rest]) do + valid?(left) and valid?(right) and left_in_right?(right, left) and + increasingly_narrowing?([right | rest]) + end + + def 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 + + def sort_ranges_widest_to_narrowest(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 + + def union( + range(start_line_1, start_character_1, end_line_1, end_character_1) = left, + range(start_line_2, start_character_2, end_line_2, end_character_2) = right + ) do + _intersection = intersection(left, right) + + {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 + + def 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 + + result = range(start_line, start_character, end_line, end_character) + + if not valid?(result) do + raise ArgumentError, message: "no intersection" + end + + result + end + + def merge_ranges_lists(ranges_1, ranges_2) do + if hd(ranges_1) != hd(ranges_2) do + raise ArgumentError, message: "range list do not start with the same range" + end + + if not increasingly_narrowing?(ranges_1) do + raise ArgumentError, message: "ranges_1 is not increasingly narrowing" + end + + if not increasingly_narrowing?(ranges_2) do + raise ArgumentError, message: "ranges_2 is not increasingly narrowing" + end + + do_merge_ranges(ranges_1, ranges_2, []) + |> Enum.reverse() + end + + defp do_merge_ranges([], [], acc) do + acc + end + + defp do_merge_ranges([range_1 | rest_1], [], acc) do + # range_1 is guaranteed to be increasingly narrowing + do_merge_ranges(rest_1, [], [range_1 | acc]) + end + + defp do_merge_ranges([], [range_2 | rest_2], acc) do + # we might have added a narrower range by favoring range_1 in the previous iteration + range_2 = trim_range_to_acc(range_2, acc) + + do_merge_ranges([], rest_2, [range_2 | acc]) + end + + defp do_merge_ranges([range | rest_1], [range | rest_2], acc) do + do_merge_ranges(rest_1, rest_2, [range | acc]) + end + + defp do_merge_ranges([range_1 | rest_1], [range_2 | rest_2], acc) do + # we might have added a narrower range by favoring range_1 in the previous iteration + range_2 = trim_range_to_acc(range_2, acc) + + cond do + left_in_right?(range_2, range_1) -> + # 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 + 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) + do_merge_ranges(rest_1, rest_2, [range_1, union_range | acc]) + end + end + + defp trim_range_to_acc(range, []), do: range + + defp trim_range_to_acc(range, [acc_range | _]) do + intersection(range, acc_range) + end + + def deduplicate(ranges) do + do_deduplicate(ranges, []) + |> Enum.reverse() + end + + defp do_deduplicate([], acc), do: acc + + defp do_deduplicate([range | rest], [range | _] = acc) do + do_deduplicate(rest, acc) + end + + defp do_deduplicate([range | rest], acc) do + do_deduplicate(rest, [range | acc]) + end +end diff --git a/apps/language_server/lib/language_server/server.ex b/apps/language_server/lib/language_server/server.ex index be1042238..00af4ec88 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 @@ -1217,6 +1218,32 @@ 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 + formatter_opts = + case SourceFile.formatter_for(uri, state.project_dir, state.mix_project?) do + {:ok, {_, opts, _formatter_exs_dir}} -> opts + {:error, _} -> [] + end + + ranges = + SelectionRanges.selection_ranges(source_file.text, positions, + formatter_opts: formatter_opts + ) + + {: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 +1292,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..59dac7fd6 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 @@ -59,7 +83,7 @@ defmodule ElixirLS.LanguageServer.SourceFile do end def full_range(source_file) do - lines = lines(source_file) + [_ | _] = lines = lines(source_file) utf16_size = lines diff --git a/apps/language_server/test/ast_utils_test.exs b/apps/language_server/test/ast_utils_test.exs new file mode 100644 index 000000000..0b8eb086c --- /dev/null +++ b/apps/language_server/test/ast_utils_test.exs @@ -0,0 +1,497 @@ +defmodule ElixirLS.LanguageServer.AstUtilsTest do + use ExUnit.Case + + import ElixirLS.LanguageServer.Protocol + import ElixirLS.LanguageServer.AstUtils + + defp get_range(code) do + # IO.puts(code) + + {:ok, ast} = + Code.string_to_quoted(code, + columns: true, + token_metadata: true, + unescape: false, + literal_encoder: &{:ok, {:__block__, &2, [&1]}} + ) + + # dbg(ast) + node_range(ast) + end + + describe "literals" do + test "true" do + assert get_range("true") == range(0, 0, 0, 4) + end + + test "false" do + assert get_range("false") == range(0, 0, 0, 5) + end + + test "nil" do + assert get_range("nil") == range(0, 0, 0, 3) + end + + test "integer" do + assert get_range("1234") == range(0, 0, 0, 4) + end + + test "float" do + assert get_range("123.4") == range(0, 0, 0, 5) + end + + test "atom" do + assert get_range(":abc") == range(0, 0, 0, 4) + end + + if Version.match?(System.version(), ">= 1.13.0-dev") do + test "quoted atom string" do + assert get_range(":\"abc\"") == range(0, 0, 0, 6) + end + end + + if Version.match?(System.version(), ">= 1.13.0-dev") do + test "quoted atom charlist" do + assert get_range(":'abc'") == range(0, 0, 0, 6) + end + end + + if Version.match?(System.version(), ">= 1.13.0-dev") do + test "quoted atom string interpolated" do + assert get_range(":\"ab\#{inspect(self())}c\"") == range(0, 0, 0, 24) + end + end + + if Version.match?(System.version(), ">= 1.13.0-dev") do + test "quoted atom charlist interpolated" do + assert get_range(":'ab\#{inspect(self())}c'") == range(0, 0, 0, 24) + end + end + + test "string" do + assert get_range("\"abc\"") == range(0, 0, 0, 5) + end + + test "charlist" do + assert get_range("'abc'") == range(0, 0, 0, 5) + end + + test "string with newlines" do + assert get_range("\"ab\nc\"") == range(0, 0, 1, 2) + end + + test "charlist with newlines" do + assert get_range("'ab\nc'") == range(0, 0, 1, 2) + end + + test "string heredoc" do + assert get_range("\"\"\"\nabc\n\"\"\"") == range(0, 0, 2, 3) + end + + if Version.match?(System.version(), ">= 1.13.0-dev") do + test "string heredoc with indentation" do + assert get_range("\"\"\"\n abc\n \"\"\"") == range(0, 0, 2, 5) + end + end + + test "charlist heredoc" do + assert get_range("'''\nabc\n'''") == range(0, 0, 2, 3) + end + + if Version.match?(System.version(), ">= 1.13.0-dev") do + test "charlist heredoc with indentation" do + assert get_range("'''\n abc\n '''") == range(0, 0, 2, 5) + end + end + + test "string interpolated" do + assert get_range("\"abc \#{inspect(a)} sd\"") == range(0, 0, 0, 22) + end + + if Version.match?(System.version(), ">= 1.13.0-dev") do + test "charlist interpolated" do + assert get_range("'abc \#{inspect(a)} sd'") == range(0, 0, 0, 22) + end + end + + if Version.match?(System.version(), ">= 1.13.0-dev") do + test "string heredoc interpolated" do + assert get_range("\"\"\"\nab\#{inspect(a)}c\n\"\"\"") == range(0, 0, 2, 3) + end + end + + if Version.match?(System.version(), ">= 1.13.0-dev") do + test "charlist heredoc interpolated" do + assert get_range("'''\nab\#{inspect(a)}c\n'''") == range(0, 0, 2, 3) + end + end + + test "sigil" do + assert get_range("~w(asd fgh)") == range(0, 0, 0, 11) + end + + test "sigil with modifier" do + assert get_range("~w(asd fgh)a") == range(0, 0, 0, 12) + end + + test "sigil with interpolation" do + text = "~s(asd \#{inspect(self())} fgh)" + assert get_range(text) == range(0, 0, 0, 30) + end + + test "sigil with heredoc string" do + text = """ + ~S\"\"\" + some text + \"\"\" + """ + + assert get_range(text) == range(0, 0, 2, 3) + end + + test "sigil with heredoc charlist" do + text = """ + ~S''' + some text + ''' + """ + + assert get_range(text) == range(0, 0, 2, 3) + end + + test "empty tuple" do + assert get_range("{}") == range(0, 0, 0, 2) + end + + test "1 element tuple" do + assert get_range("{:ok}") == range(0, 0, 0, 5) + end + + test "2 element tuple" do + assert get_range("{:ok, 123}") == range(0, 0, 0, 10) + end + + test "3 element tuple" do + assert get_range("{:ok, 123, nil}") == range(0, 0, 0, 15) + end + + test "empty list" do + assert get_range("[]") == range(0, 0, 0, 2) + end + + test "1 element list" do + assert get_range("[123]") == range(0, 0, 0, 5) + end + + test "2 element list" do + assert get_range("[123, 456]") == range(0, 0, 0, 10) + end + + test "1 element list with cons operator" do + assert get_range("[123 | abc]") == range(0, 0, 0, 11) + end + + test "2 element list with cons operator" do + assert get_range("[123, 456 | abc]") == range(0, 0, 0, 16) + end + + test "keyword" do + assert get_range("[abc: 2]") == range(0, 0, 0, 8) + end + + test "empty map" do + assert get_range("%{}") == range(0, 0, 0, 3) + end + + test "map with string key" do + assert get_range("%{\"abc\" => 1}") == range(0, 0, 0, 13) + end + + test "map with atom key" do + assert get_range("%{abc: 1}") == range(0, 0, 0, 9) + end + + test "map update syntax" do + assert get_range("%{var | abc: 1}") == range(0, 0, 0, 15) + end + + test "alias" do + assert get_range("Some") == range(0, 0, 0, 4) + end + + test "alias nested" do + assert get_range("Some.Foo") == range(0, 0, 0, 8) + end + + test "empty struct" do + assert get_range("%Some{}") == range(0, 0, 0, 7) + end + + test "struct with atom key" do + assert get_range("%Some{abc: 1}") == range(0, 0, 0, 13) + end + + test "struct update syntax" do + assert get_range("%Some{var | abc: 1}") == range(0, 0, 0, 19) + end + + test "empty bitstring" do + assert get_range("<<>>") == range(0, 0, 0, 4) + end + + test "bitstring with content" do + assert get_range("<< 0 >>") == range(0, 0, 0, 7) + end + + test "variable" do + assert get_range("var") == range(0, 0, 0, 3) + end + + test "module attribute" do + assert get_range("@attr") == range(0, 0, 0, 5) + end + + test "module attribute definition" do + assert get_range("@attr 123") == range(0, 0, 0, 9) + end + + test "binary operator" do + assert get_range("var + foo") == range(0, 0, 0, 9) + end + + test "nested binary operators" do + assert get_range("var * 3 + foo / x") == range(0, 0, 0, 17) + end + + # Parser is simplifying the expression and not including the parens + # test "nested binary operators with parens" do + # assert get_range("var * 3 * (foo + x)") == range(0, 0, 0, 17) + # end + + test "nested binary and unary operators" do + assert get_range("var * 3 + foo / -x") == range(0, 0, 0, 18) + end + + test "if" do + text = """ + if true do + 1 + end + """ + + assert get_range(text) == range(0, 0, 2, 3) + end + + test "if else" do + text = """ + if true do + 1 + else + 2 + end + """ + + assert get_range(text) == range(0, 0, 4, 3) + end + + test "if short notation" do + text = """ + if true, do: 1 + """ + + assert get_range(text) == range(0, 0, 0, 14) + end + + test "case" do + text = """ + case x do + ^abc -> + :ok + true -> + :error + end + """ + + assert get_range(text) == range(0, 0, 5, 3) + end + + test "cond" do + text = """ + cond do + abc == 1 -> + :ok + true -> + :error + end + """ + + assert get_range(text) == range(0, 0, 5, 3) + end + + test "local call" do + assert get_range("local(123)") == range(0, 0, 0, 10) + end + + test "variable call" do + assert get_range("local.(123)") == range(0, 0, 0, 11) + end + + if Version.match?(System.version(), ">= 1.13.0-dev") do + test "nested call" do + assert get_range("local.prop.foo") == range(0, 0, 0, 14) + end + end + + test "access" do + assert get_range("local[\"some\"]") == range(0, 0, 0, 13) + end + + test "nested access" do + assert get_range("local[\"some\"][1]") == range(0, 0, 0, 16) + end + + test "remote call" do + assert get_range("Some.fun(123)") == range(0, 0, 0, 13) + end + + test "remote call on atom" do + assert get_range(":some.fun(123)") == range(0, 0, 0, 14) + end + + test "remote call quoted string" do + assert get_range("Some.\"0fun\"(123)") == range(0, 0, 0, 16) + end + + test "remote call quoted charlist" do + assert get_range("Some.'0fun'(123)") == range(0, 0, 0, 16) + end + + test "remote call pipe" do + text = """ + 123 + |> Some.fun1() + """ + + assert get_range(text) == range(0, 0, 1, 14) + end + + if Version.match?(System.version(), ">= 1.13.0-dev") do + test "remote call pipe no parens" do + text = """ + 123 + |> Some.fun1 + """ + + assert get_range(text) == range(0, 0, 1, 12) + end + end + + test "local call pipe" do + text = """ + 123 + |> local() + """ + + assert get_range(text) == range(0, 0, 1, 10) + end + + test "local call pipe no parens" do + text = """ + 123 + |> local + """ + + assert get_range(text) == range(0, 0, 1, 8) + end + + test "local call no parens" do + assert get_range("local 123") == range(0, 0, 0, 9) + end + + test "remote call no parens" do + assert get_range("Some.fun 123") == range(0, 0, 0, 12) + end + + test "local capture" do + assert get_range("&local/1") == range(0, 0, 0, 8) + end + + test "remote capture" do + assert get_range("&Some.fun/1") == range(0, 0, 0, 11) + end + + test "remote capture quoted" do + assert get_range("&Some.\"fun\"/1") == range(0, 0, 0, 13) + end + + test "anonymous capture" do + assert get_range("& &1 + 1") == range(0, 0, 0, 8) + end + + test "complicated local call" do + text = """ + fun(%My{} = my, keyword: 123, other: [:a, ""]) + """ + + assert get_range(text) == range(0, 0, 0, 46) + end + + test "block" do + text = """ + a = foo() + b = bar() + :ok + """ + + assert get_range(text) == range(0, 0, 2, 3) + end + + test "anonymous function no args" do + test = """ + fn -> 1 end + """ + + assert get_range(test) == range(0, 0, 0, 11) + end + + test "anonymous function multiple args" do + test = """ + fn a, b -> 1 end + """ + + assert get_range(test) == range(0, 0, 0, 16) + end + + test "anonymous function multiple clauses" do + test = """ + fn + 1 -> 1 + _ -> 2 + end + """ + + assert get_range(test) == range(0, 0, 3, 3) + end + + test "with" do + text = """ + with {:ok, x} <- foo() do + x + end + """ + + assert get_range(text) == range(0, 0, 2, 3) + end + + if Version.match?(System.version(), ">= 1.13.0-dev") do + test "def short notation" do + test = ~S""" + defp name(%Config{} = config), + do: :"#{__MODULE__}_#{config.node_id}_#{config.channel_unique_id}" + """ + + assert get_range(test) == range(0, 0, 1, 68) + end + end + end +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..f2d73df26 --- /dev/null +++ b/apps/language_server/test/providers/selection_ranges_test.exs @@ -0,0 +1,1065 @@ +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 + + defmacrop assert_range(ranges, expected) do + quote do + assert Enum.any?(unquote(ranges), &(&1 == unquote(expected))) + end + 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_range(ranges, range(0, 0, 1, 0)) + # [] outside + assert_range(ranges, range(0, 0, 0, 11)) + # [] inside + assert_range(ranges, range(0, 1, 0, 10)) + # {} outside + assert_range(ranges, range(0, 1, 0, 7)) + # {} inside + assert_range(ranges, range(0, 2, 0, 6)) + end + + test "brackets cursor inside left" do + text = """ + {1, 2} + """ + + ranges = get_ranges(text, 0, 1) + + # full range + assert_range(ranges, range(0, 0, 1, 0)) + # {} outside + assert_range(ranges, range(0, 0, 0, 6)) + # {} inside + assert_range(ranges, range(0, 1, 0, 5)) + end + + test "brackets cursor inside right" do + text = """ + {1, 2} + """ + + ranges = get_ranges(text, 0, 5) + + # full range + assert_range(ranges, range(0, 0, 1, 0)) + # {} outside + assert_range(ranges, range(0, 0, 0, 6)) + # {} inside + assert_range(ranges, range(0, 1, 0, 5)) + end + + test "brackets cursor outside left" do + text = """ + {1, 2} + """ + + ranges = get_ranges(text, 0, 0) + + # full range + assert_range(ranges, range(0, 0, 1, 0)) + # {} outside + assert_range(ranges, range(0, 0, 0, 6)) + end + + test "brackets cursor outside right" do + text = """ + {1, 2} + """ + + ranges = get_ranges(text, 0, 0) + + # full range + assert_range(ranges, range(0, 0, 1, 0)) + # {} outside + assert_range(ranges, range(0, 0, 0, 6)) + end + end + + test "alias" do + text = """ + Some.Module.Foo + """ + + ranges = get_ranges(text, 0, 1) + + # full range + assert_range(ranges, range(0, 0, 1, 0)) + # full alias + assert_range(ranges, 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_range(ranges, range(0, 0, 1, 0)) + # full remote call + assert_range(ranges, range(0, 0, 0, 26)) + # full remote call + assert_range(ranges, 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_range(ranges, range(0, 0, 1, 0)) + # full line + assert_range(ranges, range(0, 0, 0, 16)) + # from # + assert_range(ranges, 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_range(ranges, range(0, 0, 3, 0)) + # full lines + assert_range(ranges, range(0, 0, 2, 13)) + # from # + assert_range(ranges, range(0, 2, 2, 13)) + # from # first line + assert_range(ranges, 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_range(ranges, range(0, 0, 3, 0)) + # full lines + assert_range(ranges, range(0, 0, 2, 13)) + # from # + assert_range(ranges, range(0, 2, 2, 13)) + # full # middle line + assert_range(ranges, range(1, 0, 1, 18)) + # from # middle line + assert_range(ranges, 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_range(ranges, range(0, 0, 3, 0)) + # full lines + assert_range(ranges, range(0, 0, 2, 13)) + # from # + assert_range(ranges, range(0, 2, 2, 13)) + # full # last line + assert_range(ranges, range(2, 0, 2, 13)) + # from # last line + assert_range(ranges, 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_range(ranges, range(0, 0, 4, 0)) + # outside do-end + assert_range(ranges, range(0, 0, 3, 3)) + # inside do-end + assert_range(ranges, range(1, 0, 2, 4)) + end + + if Version.match?(System.version(), ">= 1.14.0-dev") do + test "left from do" do + text = """ + do + 1 + 24 + end + """ + + ranges = get_ranges(text, 0, 0) + # full range + assert_range(ranges, range(0, 0, 4, 0)) + # outside do-end + assert_range(ranges, range(0, 0, 3, 3)) + # do + assert_range(ranges, range(0, 0, 0, 2)) + end + end + + test "right from do" do + text = """ + do + 1 + 24 + end + """ + + ranges = get_ranges(text, 0, 2) + # full range + assert_range(ranges, range(0, 0, 4, 0)) + # outside do-end + assert_range(ranges, range(0, 0, 3, 3)) + end + + if Version.match?(System.version(), ">= 1.14.0-dev") do + test "left from end" do + text = """ + do + 1 + 24 + end + """ + + ranges = get_ranges(text, 3, 0) + # full range + assert_range(ranges, range(0, 0, 4, 0)) + # outside do-end + assert_range(ranges, range(0, 0, 3, 3)) + # end + assert_range(ranges, range(3, 0, 3, 3)) + end + end + + test "right from end" do + text = """ + do + 1 + 24 + end + """ + + ranges = get_ranges(text, 3, 3) + # full range + assert_range(ranges, range(0, 0, 4, 0)) + # outside do-end + assert_range(ranges, 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_range(ranges, range(0, 0, 5, 0)) + # defmodule + assert_range(ranges, range(0, 0, 4, 3)) + # def + assert_range(ranges, 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_range(ranges, range(0, 0, 3, 0)) + # full @doc + assert_range(ranges, range(0, 0, 2, 3)) + end + + test "heredoc" do + text = """ + @doc \""" + This is a doc + \""" + """ + + ranges = get_ranges(text, 1, 0) + # full range + assert_range(ranges, range(0, 0, 3, 0)) + # full @doc + assert_range(ranges, 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_range(ranges, range(0, 0, 3, 0)) + # full @doc + assert_range(ranges, 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_range(ranges, range(0, 0, 3, 0)) + # full literal + assert_range(ranges, range(0, 2, 2, 3)) + end + + test "number" do + text = """ + 1234 + 43 + """ + + ranges = get_ranges(text, 0, 0) + # full range + assert_range(ranges, range(0, 0, 1, 0)) + # full expression + assert_range(ranges, range(0, 0, 0, 9)) + # full literal + assert_range(ranges, range(0, 0, 0, 4)) + end + + test "atom" do + text = """ + :asdfghj + """ + + ranges = get_ranges(text, 0, 1) + # full range + assert_range(ranges, range(0, 0, 1, 0)) + # full literal + assert_range(ranges, 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_range(ranges, range(0, 0, 1, 0)) + # full literal + assert_range(ranges, range(0, 0, 0, 28)) + # full interpolation + assert_range(ranges, range(0, 5, 0, 23)) + # inside #{} + assert_range(ranges, range(0, 7, 0, 22)) + # inside () + assert_range(ranges, range(0, 15, 0, 21)) + # literal + # NOTE AST only matching - no tokens inside interpolation + assert_range(ranges, range(0, 16, 0, 17)) + end + end + + test "utf16" do + text = """ + "foooob🏳️‍🌈rbaz" + """ + + ranges = get_ranges(text, 0, 1) + + # full range + assert_range(ranges, 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 + + describe "struct" do + test "inside {}" do + text = """ + %My.Struct{ + some: 123, + other: "abc" + } + """ + + ranges = get_ranges(text, 1, 2) + + # full range + assert_range(ranges, range(0, 0, 4, 0)) + # full struct + assert_range(ranges, range(0, 0, 3, 1)) + # full {} outside + assert_range(ranges, range(0, 10, 3, 1)) + # full {} inside + assert_range(ranges, range(0, 11, 3, 0)) + # full lines: + assert_range(ranges, range(1, 0, 2, 14)) + # full lines trimmed + assert_range(ranges, range(1, 2, 2, 14)) + # some: 123 + assert_range(ranges, range(1, 2, 1, 11)) + # some + assert_range(ranges, range(1, 2, 1, 6)) + end + + test "on alias" do + text = """ + %My.Struct{ + some: 123, + other: "abc" + } + """ + + ranges = get_ranges(text, 0, 2) + + # full range + assert_range(ranges, range(0, 0, 4, 0)) + # full struct + assert_range(ranges, range(0, 0, 3, 1)) + # %My.Struct + assert_range(ranges, range(0, 0, 0, 10)) + # My.Struct + assert_range(ranges, range(0, 1, 0, 10)) + end + end + + describe "comma separated" do + test "before first ," do + text = """ + fun(%My{} = my, keyword: 123, other: [:a, ""]) + """ + + ranges = get_ranges(text, 0, 6) + + # full range + assert_range(ranges, range(0, 0, 1, 0)) + # full call + assert_range(ranges, range(0, 0, 0, 46)) + # full () outside + assert_range(ranges, range(0, 3, 0, 46)) + # full () inside + assert_range(ranges, range(0, 4, 0, 45)) + # %My{} = my + assert_range(ranges, range(0, 4, 0, 14)) + end + + test "between ," do + text = """ + fun(%My{} = my, keyword: 123, other: [:a, ""]) + """ + + ranges = get_ranges(text, 0, 18) + + # full range + assert_range(ranges, range(0, 0, 1, 0)) + # full call + assert_range(ranges, range(0, 0, 0, 46)) + # full () outside + assert_range(ranges, range(0, 3, 0, 46)) + # full () inside + assert_range(ranges, range(0, 4, 0, 45)) + # keyword: 123 + assert_range(ranges, range(0, 16, 0, 28)) + end + + test "after last ," do + text = """ + fun(%My{} = my, keyword: 123, other: [:a, ""]) + """ + + ranges = get_ranges(text, 0, 31) + + # full range + assert_range(ranges, range(0, 0, 1, 0)) + # full call + assert_range(ranges, range(0, 0, 0, 46)) + # full () outside + assert_range(ranges, range(0, 3, 0, 46)) + # full () inside + assert_range(ranges, range(0, 4, 0, 45)) + # other: [:a, ""] + assert_range(ranges, range(0, 30, 0, 45)) + end + end + + describe "case" do + test "case" do + text = """ + case x do + a -> + some_fun() + b -> + more() + funs() + end + """ + + ranges = get_ranges(text, 4, 5) + + # full range + assert_range(ranges, range(0, 0, 7, 0)) + # full b case + assert_range(ranges, range(3, 2, 5, 10)) + # b block + assert_range(ranges, range(4, 4, 5, 10)) + # more() + assert_range(ranges, range(4, 4, 4, 10)) + end + + test "inside case arg" do + text = """ + case foo do + {:ok, _} -> :ok + _ -> + Logger.error("Foo") + :error + end + """ + + ranges = get_ranges(text, 0, 6) + + # full range + assert_range(ranges, range(0, 0, 6, 0)) + # full case + assert_range(ranges, range(0, 0, 5, 3)) + # foo + assert_range(ranges, range(0, 5, 0, 8)) + end + + test "left side of -> single line" do + text = """ + case foo do + {:ok, _} -> :ok + _ -> + Logger.error("Foo") + :error + end + """ + + ranges = get_ranges(text, 1, 3) + + # full range + assert_range(ranges, range(0, 0, 6, 0)) + # full case + assert_range(ranges, range(0, 0, 5, 3)) + # do block + assert_range(ranges, range(0, 9, 5, 3)) + # do block inside + assert_range(ranges, range(1, 0, 4, 10)) + # do block inside trimmed + assert_range(ranges, range(1, 2, 4, 10)) + # full expression + assert_range(ranges, range(1, 2, 1, 17)) + # {:ok, _} + assert_range(ranges, range(1, 2, 1, 10)) + end + + test "right side of -> single line" do + text = """ + case foo do + {:ok, _} -> :ok + _ -> + Logger.error("Foo") + :error + end + """ + + ranges = get_ranges(text, 1, 16) + + # full range + assert_range(ranges, range(0, 0, 6, 0)) + # full case + assert_range(ranges, range(0, 0, 5, 3)) + # do block + assert_range(ranges, range(0, 9, 5, 3)) + # do block inside + assert_range(ranges, range(1, 0, 4, 10)) + # do block inside trimmed + assert_range(ranges, range(1, 2, 4, 10)) + # full expression + assert_range(ranges, range(1, 2, 1, 17)) + # :ok expression + assert_range(ranges, range(1, 14, 1, 17)) + end + + test "left side of -> multi line" do + text = """ + case foo do + {:ok, _} -> :ok + %{ + asdf: 1 + } -> + Logger.error("Foo") + :error + _ -> :foo + end + """ + + ranges = get_ranges(text, 3, 5) + + # full range + assert_range(ranges, range(0, 0, 9, 0)) + # full case + assert_range(ranges, range(0, 0, 8, 3)) + # do block + assert_range(ranges, range(0, 9, 8, 3)) + # case -> expression + assert_range(ranges, range(2, 2, 6, 10)) + # pattern with -> + assert_range(ranges, range(2, 2, 4, 6)) + # pattern + assert_range(ranges, range(2, 2, 4, 3)) + end + + test "right side of -> multi line" do + text = """ + case foo do + {:ok, _} -> :ok + %{ + asdf: 1 + } -> + Logger.error("Foo") + :error + _ -> :foo + end + """ + + ranges = get_ranges(text, 5, 5) + + # full range + assert_range(ranges, range(0, 0, 9, 0)) + # full case + assert_range(ranges, range(0, 0, 8, 3)) + # do block + assert_range(ranges, range(0, 9, 8, 3)) + # do block inside + assert_range(ranges, range(1, 0, 7, 11)) + # do block inside trimmed + assert_range(ranges, range(1, 2, 7, 11)) + # case -> expression + assert_range(ranges, range(2, 2, 6, 10)) + # full block + assert_range(ranges, range(5, 4, 6, 10)) + end + + test "right side of -> last expression in do block" do + text = """ + case foo do + {:ok, _} -> :ok + %{ + asdf: 1 + } -> + Logger.error("Foo") + :error + _ -> :foo + end + """ + + ranges = get_ranges(text, 7, 8) + + # full range + assert_range(ranges, range(0, 0, 9, 0)) + # full case + assert_range(ranges, range(0, 0, 8, 3)) + # do block + assert_range(ranges, range(0, 9, 8, 3)) + # do block inside trimmed + assert_range(ranges, range(1, 2, 7, 11)) + # case -> expression + assert_range(ranges, range(7, 2, 7, 11)) + # :foo + assert_range(ranges, range(7, 7, 7, 11)) + end + end + + describe "for" do + test "inside do block" do + text = """ + for x <- [1, 2, 3], y <- [4, 5, 6] do + x + y + end + """ + + ranges = get_ranges(text, 1, 2) + + # full range + assert_range(ranges, range(0, 0, 3, 0)) + # full for + assert_range(ranges, range(0, 0, 2, 3)) + # do block + assert_range(ranges, range(0, 35, 2, 3)) + # x + y expression + assert_range(ranges, range(1, 2, 1, 7)) + end + + test "inside do expression single line" do + text = """ + for x <- [1, 2, 3], y <- [4, 5, 6], into: %{}, do: x + y + """ + + ranges = get_ranges(text, 0, 51) + + # full range + assert_range(ranges, range(0, 0, 1, 0)) + # full for + assert_range(ranges, range(0, 0, 0, 56)) + # x + y expression + assert_range(ranges, range(0, 51, 0, 56)) + end + + test "inside do expression" do + text = """ + for x <- [1, 2, 3], y <- [4, 5, 6], + into: %{}, + do: x + y + """ + + ranges = get_ranges(text, 2, 6) + + # full range + assert_range(ranges, range(0, 0, 3, 0)) + # full for expression + assert_range(ranges, range(0, 0, 2, 11)) + # x + y expression + assert_range(ranges, range(2, 6, 2, 11)) + end + + test "inside <- expression" do + text = """ + for x <- [1, 2, 3], y <- [4, 5, 6] do + x + y + end + """ + + ranges = get_ranges(text, 0, 10) + + # full range + assert_range(ranges, range(0, 0, 3, 0)) + # full for + assert_range(ranges, range(0, 0, 2, 3)) + # x <- [1, 2, 3] + assert_range(ranges, range(0, 4, 0, 18)) + # [1, 2, 3] + assert_range(ranges, range(0, 9, 0, 18)) + end + end + + describe "with" do + test "inside do block" do + text = """ + with x <- [1, 2, 3], y <- [4, 5, 6] do + x ++ y + end + """ + + ranges = get_ranges(text, 1, 2) + + # full range + assert_range(ranges, range(0, 0, 3, 0)) + # full for + assert_range(ranges, range(0, 0, 2, 3)) + # do block + assert_range(ranges, range(0, 36, 2, 3)) + # x ++ y expression + assert_range(ranges, range(1, 2, 1, 8)) + end + + test "inside do expression single line" do + text = """ + with x <- [1, 2, 3], y <- [4, 5, 6], do: x ++ y + """ + + ranges = get_ranges(text, 0, 51) + + # full range + assert_range(ranges, range(0, 0, 1, 0)) + # full for + assert_range(ranges, range(0, 0, 0, 47)) + # x ++ y expression + assert_range(ranges, range(0, 41, 0, 47)) + end + + test "inside do expression" do + text = """ + with x <- [1, 2, 3], + y <- [4, 5, 6], + do: x ++ y + """ + + ranges = get_ranges(text, 2, 6) + + # full range + assert_range(ranges, range(0, 0, 3, 0)) + # full for expression + assert_range(ranges, range(0, 0, 2, 12)) + # x ++ y expression + assert_range(ranges, range(2, 6, 2, 12)) + end + + test "inside <- expression" do + text = """ + with x <- [1, 2, 3], y <- [4, 5, 6] do + x ++ y + end + """ + + ranges = get_ranges(text, 0, 10) + + # full range + assert_range(ranges, range(0, 0, 3, 0)) + # full for + assert_range(ranges, range(0, 0, 2, 3)) + # x <- [1, 2, 3] + assert_range(ranges, range(0, 5, 0, 19)) + # [1, 2, 3] + assert_range(ranges, range(0, 10, 0, 19)) + end + end + + describe "if" do + test "inside condition" do + text = """ + if a + b > 1 do + :ok + else + :error + end + """ + + ranges = get_ranges(text, 0, 3) + + # full range + assert_range(ranges, range(0, 0, 5, 0)) + # full if + assert_range(ranges, range(0, 0, 4, 3)) + # condition + assert_range(ranges, range(0, 3, 0, 12)) + end + + test "inside do block" do + text = """ + if a + b > 1 do + :ok + else + :error + end + """ + + ranges = get_ranges(text, 1, 2) + + # full range + assert_range(ranges, range(0, 0, 5, 0)) + # full if + assert_range(ranges, range(0, 0, 4, 3)) + # do-else + assert_range(ranges, range(0, 15, 2, 0)) + # :ok + assert_range(ranges, range(1, 2, 1, 5)) + end + + test "inside else block" do + text = """ + if a + b > 1 do + :ok + else + :error + end + """ + + ranges = get_ranges(text, 3, 2) + + # full range + assert_range(ranges, range(0, 0, 5, 0)) + # full if + assert_range(ranges, range(0, 0, 4, 3)) + # else-end + assert_range(ranges, range(2, 0, 4, 3)) + # :error + assert_range(ranges, range(3, 2, 3, 8)) + end + end + + test "operators" do + text = """ + var1 + var2 * var3 > var4 - var5 + """ + + ranges = get_ranges(text, 0, 8) + + # full range + assert_range(ranges, range(0, 0, 1, 0)) + # full expression + assert_range(ranges, range(0, 0, 0, 32)) + # full left side of operator > + assert_range(ranges, range(0, 0, 0, 18)) + # var2 * var3 + assert_range(ranges, range(0, 7, 0, 18)) + # var2 + assert_range(ranges, range(0, 7, 0, 11)) + end + + describe "keyword args" do + test "single line" do + text = """ + my(1, a: 2, b: 3) + """ + + ranges = get_ranges(text, 0, 6) + + # full range + assert_range(ranges, range(0, 0, 1, 0)) + # full call + assert_range(ranges, range(0, 0, 0, 17)) + # full keyword + assert_range(ranges, range(0, 6, 0, 16)) + end + + test "multi line" do + text = """ + my(1, a: 2, + b: 3, + c: 4 + ) + """ + + ranges = get_ranges(text, 1, 2) + + # full range + assert_range(ranges, range(0, 0, 4, 0)) + # full call + assert_range(ranges, range(0, 0, 3, 1)) + # full keyword + assert_range(ranges, range(0, 6, 2, 6)) + end + end + + describe "map update" do + test "left side of |" do + text = """ + %{asd | a: 1, b: x} + """ + + ranges = get_ranges(text, 0, 3) + + # full range + assert_range(ranges, range(0, 0, 1, 0)) + # full map + assert_range(ranges, range(0, 0, 0, 19)) + # asd + assert_range(ranges, range(0, 2, 0, 5)) + end + + test "right side of |" do + text = """ + %{asd | a: 1, b: x} + """ + + ranges = get_ranges(text, 0, 9) + + # full range + assert_range(ranges, range(0, 0, 1, 0)) + # full map + assert_range(ranges, range(0, 0, 0, 19)) + # full keyword + assert_range(ranges, range(0, 8, 0, 18)) + # a: 1 + assert_range(ranges, range(0, 8, 0, 12)) + end + + if Version.match?(System.version(), ">= 1.14.0-dev") do + test "left side of | near" do + text = """ + %{state | 1 => 1, counter: counter + to_dispatch, demand: demand - to_dispatch} + """ + + ranges = get_ranges(text, 0, 8) + + # full range + assert_range(ranges, range(0, 0, 1, 0)) + # full map + assert_range(ranges, range(0, 0, 0, 79)) + # | + assert_range(ranges, range(0, 8, 0, 9)) + end + end + + test "right side of | near" do + text = """ + %{state | 1 => 1, counter: counter + to_dispatch, demand: demand - to_dispatch} + """ + + ranges = get_ranges(text, 0, 9) + + # full range + assert_range(ranges, range(0, 0, 1, 0)) + # full map + assert_range(ranges, range(0, 0, 0, 79)) + # | expression + assert_range(ranges, range(0, 2, 0, 78)) + end + end +end diff --git a/apps/language_server/test/range_utils_test.exs b/apps/language_server/test/range_utils_test.exs new file mode 100644 index 000000000..23f1160fc --- /dev/null +++ b/apps/language_server/test/range_utils_test.exs @@ -0,0 +1,593 @@ +defmodule ElixirLS.LanguageServer.RangeUtilsTest do + use ExUnit.Case + + import ElixirLS.LanguageServer.Protocol + import ElixirLS.LanguageServer.RangeUtils + + describe "valid?/1" do + test "returns true if range is valid" do + assert valid?(range(0, 0, 0, 0)) + assert valid?(range(1, 1, 1, 2)) + assert valid?(range(1, 1, 2, 0)) + assert valid?(range(1, 1, 2, 6)) + end + + test "returns false if range is invalid" do + refute valid?(range(1, 1, 1, 0)) + refute valid?(range(1, 1, 0, 1)) + refute valid?(range(-1, 1, 5, 5)) + refute valid?(range(1, -1, 5, 5)) + refute valid?(range(1, 1, -5, 5)) + refute valid?(range(1, 1, 5, -5)) + refute valid?(range(1, 1, 5, nil)) + refute valid?(range(1, 1, nil, 5)) + refute valid?(range(1, nil, 5, 5)) + refute valid?(range(nil, 1, 5, 5)) + end + end + + describe "in?" do + test "empty range" do + assert in?(range(1, 2, 1, 2), {1, 2}) + end + + test "in range" do + assert in?(range(1, 2, 3, 4), {1, 2}) + assert in?(range(1, 2, 3, 4), {1, 3}) + assert in?(range(1, 2, 3, 4), {2, 2}) + assert in?(range(1, 2, 5, 6), {5, 5}) + assert in?(range(1, 2, 5, 6), {5, 6}) + end + + test "out of range" do + refute in?(range(1, 2, 3, 4), {0, 3}) + refute in?(range(1, 2, 3, 4), {1, 1}) + refute in?(range(1, 2, 5, 6), {5, 7}) + refute in?(range(1, 2, 5, 6), {6, 3}) + end + end + + describe "left_in_right?" do + test "returns true if range 1 is inside range 2" do + range1 = range(2, 1, 3, 20) + range2 = range(1, 2, 4, 15) + assert left_in_right?(range1, range2) + end + + test "returns true if range 1 is inside range 2 columns equal" do + range1 = range(2, 1, 3, 20) + range2 = range(2, 1, 3, 20) + assert left_in_right?(range1, range2) + end + + test "returns true if range 1 is inside range 2 same line" do + range1 = range(1, 5, 1, 10) + range2 = range(1, 2, 1, 15) + assert left_in_right?(range1, range2) + end + + test "returns false if ranges overlap but range 1 is wider" do + range2 = range(2, 1, 3, 20) + + range1 = range(2, 0, 3, 20) + refute left_in_right?(range1, range2) + + range1 = range(2, 1, 3, 21) + refute left_in_right?(range1, range2) + + range1 = range(1, 1, 3, 21) + refute left_in_right?(range1, range2) + + range1 = range(2, 1, 4, 21) + refute left_in_right?(range1, range2) + end + + test "returns false if range 1 starts after range 2" do + range1 = range(3, 5, 4, 10) + range2 = range(1, 2, 2, 15) + refute left_in_right?(range1, range2) + end + + test "returns false if range 1 starts after range 2 same line" do + range1 = range(1, 16, 1, 18) + range2 = range(1, 2, 1, 15) + refute left_in_right?(range1, range2) + end + + test "returns false if range 1 ends before range 2" do + range1 = range(1, 5, 2, 10) + range2 = range(3, 7, 4, 15) + refute left_in_right?(range1, range2) + end + + test "returns false if range 1 ends before range 2 same line" do + range1 = range(1, 5, 1, 10) + range2 = range(1, 11, 1, 15) + refute left_in_right?(range1, range2) + end + end + + describe "sort_ranges_widest_to_narrowest/1" do + test "sorts ranges" do + ranges = [ + range(1, 5, 1, 10), + range(1, 5, 1, 5), + range(0, 0, 3, 10), + range(1, 1, 2, 15), + range(1, 4, 1, 20), + range(1, 3, 2, 10) + ] + + expected = [ + range(0, 0, 3, 10), + range(1, 1, 2, 15), + range(1, 3, 2, 10), + range(1, 4, 1, 20), + range(1, 5, 1, 10), + range(1, 5, 1, 5) + ] + + assert sort_ranges_widest_to_narrowest(ranges) == expected + end + end + + describe "increasingly_narrowing?/1" do + test "returns true if only one range" do + ranges = [ + range(0, 0, 3, 10) + ] + + assert increasingly_narrowing?(ranges) + end + + test "returns true if ranges are increasingly narrowing" do + ranges = [ + range(0, 0, 3, 10), + range(1, 1, 2, 15), + range(1, 3, 2, 10), + range(1, 4, 1, 20), + range(1, 5, 1, 10), + range(1, 5, 1, 5) + ] + + assert increasingly_narrowing?(ranges) + end + + test "returns false if order is broken" do + ranges = [ + range(0, 0, 3, 10), + range(1, 1, 3, 11) + ] + + refute increasingly_narrowing?(ranges) + end + end + + describe "union/2" do + test "right in left" do + left = range(1, 1, 4, 10) + right = range(2, 5, 3, 5) + + expected = left + + assert union(left, right) == expected + assert union(right, left) == expected + end + + test "right in left same line" do + left = range(1, 1, 1, 10) + right = range(1, 5, 1, 5) + + expected = left + + assert union(left, right) == expected + assert union(right, left) == expected + end + + test "right equal left" do + left = range(1, 1, 2, 10) + right = left + + expected = left + + assert union(left, right) == expected + end + + test "overlap" do + left = range(1, 1, 3, 10) + right = range(2, 5, 4, 15) + + expected = range(1, 1, 4, 15) + + assert union(left, right) == expected + assert union(right, left) == expected + end + + test "overlap same line" do + left = range(1, 1, 1, 10) + right = range(1, 5, 1, 15) + + expected = range(1, 1, 1, 15) + + assert union(left, right) == expected + assert union(right, left) == expected + end + + test "overlap same line one column" do + left = range(1, 1, 1, 10) + right = range(1, 10, 1, 15) + + expected = range(1, 1, 1, 15) + + assert union(left, right) == expected + assert union(right, left) == expected + end + + test "raises if ranges do not intersect" do + left = range(1, 1, 2, 5) + right = range(3, 1, 4, 1) + + assert_raise ArgumentError, "no intersection", fn -> + union(left, right) + end + + assert_raise ArgumentError, "no intersection", fn -> + union(right, left) + end + end + + test "raises if ranges do not intersect same line" do + left = range(1, 1, 1, 5) + right = range(1, 8, 1, 10) + + assert_raise ArgumentError, "no intersection", fn -> + union(left, right) + end + + assert_raise ArgumentError, "no intersection", fn -> + union(right, left) + end + end + end + + describe "intersection/2" do + test "right in left" do + left = range(1, 1, 4, 10) + right = range(2, 5, 3, 5) + + expected = right + + assert intersection(left, right) == expected + assert intersection(right, left) == expected + end + + test "right in left same line" do + left = range(1, 1, 1, 10) + right = range(1, 5, 1, 5) + + expected = right + + assert intersection(left, right) == expected + assert intersection(right, left) == expected + end + + test "right equal left" do + left = range(1, 1, 2, 10) + right = left + + expected = left + + assert intersection(left, right) == expected + end + + test "overlap" do + left = range(1, 1, 3, 10) + right = range(2, 5, 4, 15) + + expected = range(2, 5, 3, 10) + + assert intersection(left, right) == expected + assert intersection(right, left) == expected + end + + test "overlap same line" do + left = range(1, 1, 1, 10) + right = range(1, 5, 1, 15) + + expected = range(1, 5, 1, 10) + + assert intersection(left, right) == expected + assert intersection(right, left) == expected + end + + test "overlap same line one column" do + left = range(1, 1, 1, 10) + right = range(1, 10, 1, 15) + + expected = range(1, 10, 1, 10) + + assert intersection(left, right) == expected + assert intersection(right, left) == expected + end + + test "raises if ranges do not intersect" do + left = range(1, 1, 2, 5) + right = range(3, 1, 4, 1) + + assert_raise ArgumentError, "no intersection", fn -> + intersection(left, right) + end + + assert_raise ArgumentError, "no intersection", fn -> + intersection(right, left) + end + end + + test "raises if ranges do not intersect same line" do + left = range(1, 1, 1, 5) + right = range(1, 8, 1, 10) + + assert_raise ArgumentError, "no intersection", fn -> + intersection(left, right) + end + + assert_raise ArgumentError, "no intersection", fn -> + intersection(right, left) + end + end + end + + describe "merge_ranges_lists/2" do + test "equal length, 2 in 1" do + range_1 = [ + range(1, 1, 5, 5), + range(2, 2, 4, 4) + ] + + range_2 = [ + range(1, 1, 5, 5), + range(3, 1, 3, 5) + ] + + expected = [ + range(1, 1, 5, 5), + range(2, 2, 4, 4), + range(3, 1, 3, 5) + ] + + assert merge_ranges_lists(range_1, range_2) == expected + end + + test "equal length, 1 in 2" do + range_1 = [ + range(1, 1, 5, 5), + range(3, 1, 3, 5) + ] + + range_2 = [ + range(1, 1, 5, 5), + range(2, 2, 4, 4) + ] + + expected = [ + range(1, 1, 5, 5), + range(2, 2, 4, 4), + range(3, 1, 3, 5) + ] + + assert merge_ranges_lists(range_1, range_2) == expected + end + + test "equal length, ranges intersect" do + range_1 = [ + range(1, 1, 5, 5), + range(2, 2, 4, 4) + ] + + range_2 = [ + range(1, 1, 5, 5), + range(2, 5, 4, 8) + ] + + expected = [ + range(1, 1, 5, 5), + # union + range(2, 2, 4, 8), + # preferred from range_1 + range(2, 2, 4, 4) + ] + + assert merge_ranges_lists(range_1, range_2) == expected + end + + test "equal length, ranges intersect, last range_2 wider than range_1" do + range_1 = [ + range(1, 1, 5, 5), + range(2, 2, 4, 4), + range(3, 6, 3, 8) + ] + + range_2 = [ + range(1, 1, 5, 5), + range(2, 5, 4, 8), + range(2, 6, 4, 8) + ] + + expected = [ + range(1, 1, 5, 5), + # union + range(2, 2, 4, 8), + # preferred from range_1 + range(2, 2, 4, 4), + # intersection of range_2 and range_1 + range(2, 6, 4, 4), + # last range_1 range + range(3, 6, 3, 8) + ] + + assert merge_ranges_lists(range_1, range_2) == expected + end + + test "ranges intersect, last range_2 wider than range_1" do + range_1 = [ + range(1, 1, 5, 5), + range(2, 2, 4, 4) + ] + + range_2 = [ + range(1, 1, 5, 5), + range(2, 5, 4, 8), + range(2, 6, 4, 8) + ] + + expected = [ + range(1, 1, 5, 5), + # union + range(2, 2, 4, 8), + # preferred from range_1 + range(2, 2, 4, 4), + # intersection of range_2 and range_1 + range(2, 6, 4, 4) + ] + + assert merge_ranges_lists(range_1, range_2) == expected + end + + test "raises if range list do not start with the same range" do + range_1 = [ + range(2, 2, 4, 4), + range(1, 1, 5, 5) + ] + + range_2 = [ + range(1, 1, 3, 3), + range(2, 2, 2, 2) + ] + + assert_raise ArgumentError, fn -> + merge_ranges_lists(range_1, range_2) + end + end + + test "raises if range_1 is not increasingly narrowing" do + range_1 = [ + range(0, 0, 10, 10), + range(2, 2, 4, 4), + range(1, 1, 5, 5) + ] + + range_2 = [ + range(0, 0, 10, 10), + range(1, 1, 3, 3), + range(2, 2, 2, 2) + ] + + assert_raise ArgumentError, fn -> + merge_ranges_lists(range_1, range_2) + end + end + + test "raises if range_2 is not increasingly narrowing" do + range_1 = [ + range(0, 0, 10, 10), + range(1, 1, 5, 5), + range(2, 2, 4, 4) + ] + + range_2 = [ + range(0, 0, 10, 10), + range(2, 2, 2, 2), + range(1, 1, 3, 3) + ] + + assert_raise ArgumentError, fn -> + merge_ranges_lists(range_1, range_2) + end + end + + test "handles equal ranges" do + range_1 = [range(1, 1, 5, 5)] + range_2 = [range(1, 1, 5, 5)] + + assert merge_ranges_lists(range_1, range_2) == [range(1, 1, 5, 5)] + end + + test "handles one empty range" do + range_1 = [ + range(1, 1, 5, 5), + range(2, 2, 4, 4) + ] + + range_2 = [range(1, 1, 5, 5)] + + expected = [ + range(1, 1, 5, 5), + range(2, 2, 4, 4) + ] + + assert merge_ranges_lists(range_1, range_2) == expected + end + end + + describe "deduplicate/1" do + test "removes duplicates in the middle" do + range_1 = [ + range(0, 0, 10, 10), + range(1, 1, 5, 5), + range(1, 1, 5, 5), + range(1, 1, 5, 5), + range(2, 2, 4, 4) + ] + + expected = [ + range(0, 0, 10, 10), + range(1, 1, 5, 5), + range(2, 2, 4, 4) + ] + + assert deduplicate(range_1) == expected + end + + test "removes duplicates at start" do + range_1 = [ + range(0, 0, 10, 10), + range(0, 0, 10, 10), + range(1, 1, 5, 5), + range(2, 2, 4, 4) + ] + + expected = [ + range(0, 0, 10, 10), + range(1, 1, 5, 5), + range(2, 2, 4, 4) + ] + + assert deduplicate(range_1) == expected + end + + test "removes duplicates at end" do + range_1 = [ + range(0, 0, 10, 10), + range(1, 1, 5, 5), + range(2, 2, 4, 4), + range(2, 2, 4, 4) + ] + + expected = [ + range(0, 0, 10, 10), + range(1, 1, 5, 5), + range(2, 2, 4, 4) + ] + + assert deduplicate(range_1) == expected + end + + test "handles empty list" do + assert deduplicate([]) == [] + end + + test "handles one element list" do + assert deduplicate([range(0, 0, 10, 10)]) == [range(0, 0, 10, 10)] + end + end +end