From e43661b8977aceba1c41dcbd7ecb3d46afd26e6e Mon Sep 17 00:00:00 2001 From: doorgan Date: Thu, 10 Jul 2025 01:23:23 -0300 Subject: [PATCH] wip --- apps/expert/lib/expert.ex | 3 + .../expert/provider/handlers/folding_range.ex | 147 +++++ .../handlers/folding_range/comment_block.ex | 56 ++ .../handlers/folding_range/helpers.ex | 13 + .../handlers/folding_range/indentation.ex | 74 +++ .../handlers/folding_range/special_token.ex | 105 +++ .../provider/handlers/folding_range/token.ex | 43 ++ .../handlers/folding_range/token_pair.ex | 90 +++ apps/expert/lib/expert/state.ex | 3 +- .../provider/handlers/folding_range_test.exs | 619 ++++++++++++++++++ 10 files changed, 1152 insertions(+), 1 deletion(-) create mode 100644 apps/expert/lib/expert/provider/handlers/folding_range.ex create mode 100644 apps/expert/lib/expert/provider/handlers/folding_range/comment_block.ex create mode 100644 apps/expert/lib/expert/provider/handlers/folding_range/helpers.ex create mode 100644 apps/expert/lib/expert/provider/handlers/folding_range/indentation.ex create mode 100644 apps/expert/lib/expert/provider/handlers/folding_range/special_token.ex create mode 100644 apps/expert/lib/expert/provider/handlers/folding_range/token.ex create mode 100644 apps/expert/lib/expert/provider/handlers/folding_range/token_pair.ex create mode 100644 apps/expert/test/expert/provider/handlers/folding_range_test.exs diff --git a/apps/expert/lib/expert.ex b/apps/expert/lib/expert.ex index 8da8c1d6..6220d469 100644 --- a/apps/expert/lib/expert.ex +++ b/apps/expert/lib/expert.ex @@ -234,6 +234,9 @@ defmodule Expert do %GenLSP.Requests.WorkspaceSymbol{} -> {:ok, Handlers.WorkspaceSymbol} + %GenLSP.Requests.TextDocumentFoldingRange{} -> + {:ok, Handlers.FoldingRange} + %request_module{} -> {:error, {:unhandled, request_module}} end diff --git a/apps/expert/lib/expert/provider/handlers/folding_range.ex b/apps/expert/lib/expert/provider/handlers/folding_range.ex new file mode 100644 index 00000000..be0c6463 --- /dev/null +++ b/apps/expert/lib/expert/provider/handlers/folding_range.ex @@ -0,0 +1,147 @@ +defmodule Expert.Provider.Handlers.FoldingRange do + @moduledoc """ + ## Methodology + + ### High level + + We make multiple passes (currently 4) through the source text and create + folding ranges from each pass. + Then we merge the ranges from each pass to provide the final ranges. + Each pass gets a priority to help break ties (the priority is an integer, + higher integers win). + + ### Indentation pass (priority: 1) + + We use the indentation level -- determined by the column of the first + non-whitespace character on each line -- to provide baseline ranges. + All ranges from this pass are `kind?: :region` ranges. + + ### Comment block pass (priority: 2) + + We let "comment blocks", consecutive lines starting with `#`, from regions. + All ranges from this pass are `kind?: :comment` ranges. + + ### Token-pairs pass (priority: 3) + + We use pairs of tokens, e.g. `do` and `end`, to provide another pass of + ranges. + All ranges from this pass are `kind?: :region` ranges. + + ### Special tokens pass (priority: 3) + + We find strings (regular/charlist strings/heredocs) and sigils in a pass as + they're delimited by a few special tokens. + Ranges from this pass are either + - `kind?: :comment` if the token is paired with `@doc` or `@moduledoc`, or + - `kind?: :region` otherwise. + + ## Notes + + Each pass may return ranges in any order. + But all ranges are valid, i.e. end_line > start_line. + """ + alias Expert.Provider.Handlers.FoldingRange + alias Expert.Provider.Handlers.FoldingRange.Token + alias Forge.Document + alias GenLSP.Requests + alias GenLSP.Structures + + import Forge.Document.Line + + def handle(%Requests.TextDocumentFoldingRange{params: %Structures.FoldingRangeParams{} = params}, _config) do + document = Document.Container.context_document(params, nil) + + input = document_to_input(document) + + passes_with_priority = [ + {1, FoldingRange.Indentation}, + {2, FoldingRange.CommentBlock}, + {3, FoldingRange.TokenPair}, + {3, FoldingRange.SpecialToken} + ] + + ranges = + passes_with_priority + |> Enum.map(fn {priority, pass} -> + ranges = ranges_from_pass(pass, input) + {priority, ranges} + end) + |> merge_ranges_with_priorities() + + {:ok, ranges} + end + + def document_to_input(document) do + %{ + tokens: tokens(document), + lines: lines(document) + } + end + + defp tokens(document) do + text = Document.to_string(document) + Token.format_string(text) + end + + defp lines(document) do + for idx <- 1..(Forge.Document.Lines.size(document.lines) - 1) do + {:ok, line} = Forge.Document.Lines.fetch_line(document.lines, idx) + {line, indentation(line)} + end + end + + defp indentation(line) do + text = line(line, :text) + ascii? = line(line, :ascii?) + full_length = line_length(text, ascii?) + trimmed = String.trim_leading(text) + trimmed_length = line_length(trimmed, ascii?) + + if {full_length, trimmed_length} == {0, 0} do + nil + else + full_length - trimmed_length + end + end + + defp line_length(text, true) do + text + |> byte_size() + |> div(2) + end + + defp line_length(text, false) do + text + |> characters_to_binary!(:utf8, :utf16) + |> byte_size() + |> div(2) + end + + defp characters_to_binary!(binary, from, to) do + case :unicode.characters_to_binary(binary, from, to) do + result when is_binary(result) -> result + end + end + + defp ranges_from_pass(pass, input) do + with {:ok, ranges} <- pass.provide_ranges(input) do + ranges + else + _ -> [] + end + end + + defp merge_ranges_with_priorities(range_lists_with_priorities) do + range_lists_with_priorities + |> Enum.flat_map(fn {priority, ranges} -> Enum.zip(Stream.cycle([priority]), ranges) end) + |> Enum.group_by(fn {_priority, range} -> range.start_line end) + |> Enum.map(fn {_start, ranges_with_priority} -> + {_priority, range} = + ranges_with_priority + |> Enum.max_by(fn {priority, range} -> {priority, range.end_line} end) + + range + end) + |> Enum.sort_by(& &1.start_line) + end +end diff --git a/apps/expert/lib/expert/provider/handlers/folding_range/comment_block.ex b/apps/expert/lib/expert/provider/handlers/folding_range/comment_block.ex new file mode 100644 index 00000000..e58fbb03 --- /dev/null +++ b/apps/expert/lib/expert/provider/handlers/folding_range/comment_block.ex @@ -0,0 +1,56 @@ +defmodule Expert.Provider.Handlers.FoldingRange.CommentBlock do + @moduledoc """ + Code folding based on comment blocks + + Note that this implementation can create comment ranges inside heredocs. + It's a little sloppy, but it shouldn't be very impactful. + We'd have to merge the token and line representations of the source text to + mitigate this issue, so we've left it as is for now. + """ + alias Expert.Provider.Handlers.FoldingRange.Helpers + + import Forge.Document.Line + + def provide_ranges(%{lines: lines}) do + ranges = + lines + |> Enum.map(&extract_cell/1) + |> group_comments() + |> Enum.filter(fn group -> length(group) > 1 end) + |> Enum.map(&convert_comment_group_to_range/1) + + {:ok, ranges} + end + + def extract_cell({line(line_number: line), indentation}), do: {line, indentation} + + def group_comments(lines) do + lines + |> Enum.reduce([[]], fn + {_, cell, "#"}, [[{_, "#"} | _] = head | tail] -> + [[{cell, "#"} | head] | tail] + + {_, cell, "#"}, [[] | tail] -> + [[{cell, "#"}] | tail] + + _, [[{_, "#"} | _] | _] = acc -> + [[] | acc] + + _, acc -> + acc + end) + end + + defp convert_comment_group_to_range(group) do + {{{end_line, _}, _}, {{start_line, _}, _}} = + Helpers.first_and_last_of_list(group) + + %GenLSP.Structures.FoldingRange{ + start_line: start_line, + # We're not doing end_line - 1 on purpose. + # It seems weird to show the first _and_ last line of a comment block. + end_line: end_line, + kind: GenLSP.Enumerations.FoldingRangeKind.comment() + } + end +end diff --git a/apps/expert/lib/expert/provider/handlers/folding_range/helpers.ex b/apps/expert/lib/expert/provider/handlers/folding_range/helpers.ex new file mode 100644 index 00000000..502df6cf --- /dev/null +++ b/apps/expert/lib/expert/provider/handlers/folding_range/helpers.ex @@ -0,0 +1,13 @@ +defmodule Expert.Provider.Handlers.FoldingRange.Helpers do + @moduledoc false + + def first_and_last_of_list([]), do: :empty_list + + def first_and_last_of_list([head]), do: {head, head} + + def first_and_last_of_list([head, last]), do: {head, last} + + def first_and_last_of_list([head | tail]) do + {head, List.last(tail)} + end +end diff --git a/apps/expert/lib/expert/provider/handlers/folding_range/indentation.ex b/apps/expert/lib/expert/provider/handlers/folding_range/indentation.ex new file mode 100644 index 00000000..37b76f06 --- /dev/null +++ b/apps/expert/lib/expert/provider/handlers/folding_range/indentation.ex @@ -0,0 +1,74 @@ +defmodule Expert.Provider.Handlers.FoldingRange.Indentation do + @moduledoc """ + Code folding based on indentation level + + Note that we trim trailing empty rows from regions. + """ + + import Forge.Document.Line + + def provide_ranges(%{lines: lines}) do + ranges = lines + |> Enum.map(&extract_cell/1) + |> pair_cells() + |> pairs_to_ranges() + + {:ok, ranges} + end + + def extract_cell({line(line_number: line), indentation}), do: {line, indentation} + + @doc """ + Pairs cells into {start, end} tuples of regions + Public function for testing + """ + def pair_cells(cells) do + do_pair_cells(cells, [], [], []) + end + + # Base case + defp do_pair_cells([], _, _, pairs) do + pairs + |> Enum.map(fn + {cell1, cell2, []} -> {cell1, cell2} + {cell1, _, empties} -> {cell1, List.last(empties)} + end) + |> Enum.reject(fn {{r1, _}, {r2, _}} -> r1 + 1 >= r2 end) + end + + # Empty row + defp do_pair_cells([{_, nil} = head | tail], stack, empties, pairs) do + do_pair_cells(tail, stack, [head | empties], pairs) + end + + # Empty stack + defp do_pair_cells([head | tail], [], empties, pairs) do + do_pair_cells(tail, [head], empties, pairs) + end + + # Non-empty stack: head is to the right of the top of the stack + defp do_pair_cells([{_, x} = head | tail], [{_, y} | _] = stack, _, pairs) when x > y do + do_pair_cells(tail, [head | stack], [], pairs) + end + + # Non-empty stack: head is equal to or to the left of the top of the stack + defp do_pair_cells([{_, x} = head | tail], stack, empties, pairs) do + # If the head is <= to the top of the stack, then we need to pair it with + # everything on the stack to the right of it. + # The head can also start a new region, so it's pushed onto the stack. + {leftovers, new_tail_stack} = stack |> Enum.split_while(fn {_, y} -> x <= y end) + new_pairs = leftovers |> Enum.map(&{&1, head, empties}) + do_pair_cells(tail, [head | new_tail_stack], [], new_pairs ++ pairs) + end + + defp pairs_to_ranges(pairs) do + pairs + |> Enum.map(fn {{r1, _}, {r2, _}} -> + %GenLSP.Structures.FoldingRange{ + start_line: r1, + end_line: r2 - 1, + kind: GenLSP.Enumerations.FoldingRangeKind.region() + } + end) + end +end diff --git a/apps/expert/lib/expert/provider/handlers/folding_range/special_token.ex b/apps/expert/lib/expert/provider/handlers/folding_range/special_token.ex new file mode 100644 index 00000000..dcf4f10e --- /dev/null +++ b/apps/expert/lib/expert/provider/handlers/folding_range/special_token.ex @@ -0,0 +1,105 @@ +defmodule Expert.Provider.Handlers.FoldingRange.SpecialToken do + @moduledoc """ + Code folding based on "special" tokens. + + Several tokens, like `"..."`s, define ranges all on their own. + This module converts these tokens to ranges. + These ranges can be either `kind: "comment"` or `kind: "region"`. + """ + + alias Expert.Provider.Handlers.FoldingRange.Helpers + + @kinds [ + :bin_heredoc, + :bin_string, + :list_heredoc, + :list_string, + :sigil + ] + + @docs [:moduledoc, :typedoc, :doc] + + def provide_ranges(%{tokens: tokens}) do + ranges = + tokens + |> group_tokens() + |> convert_groups_to_ranges() + + {:ok, ranges} + end + + def group_tokens(tokens) do + do_group_tokens(tokens, []) + end + + defp do_group_tokens([], acc), do: 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( + [{:at_op, _, _} = at_op, {:identifier, _, doc_identifier} = token | rest], + acc + ) + when doc_identifier in @docs do + 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]) + when k in @kinds do + acc = [[token | head] | tail] + do_group_tokens(rest, acc) + end + + # Start a new folding range + defp do_group_tokens([{k, _, _} = token | rest], acc) when k in @kinds do + acc = [[token] | acc] + do_group_tokens(rest, acc) + end + + # Finish the open folding range + defp do_group_tokens([{:eol, _, _} = token | rest], [[{k, _, _} | _] = head | tail]) + when k in @kinds do + acc = [[token | head] | tail] + do_group_tokens(rest, acc) + end + + defp do_group_tokens([_unmatched_token | rest], acc) do + do_group_tokens(rest, acc) + end + + defp convert_groups_to_ranges(groups) do + groups + |> Enum.map(fn group -> + # Each group comes out of group_tokens/1 reversed + {last, first} = Helpers.first_and_last_of_list(group) + classify_group(first, last) + end) + |> Enum.map(fn {start_line, end_line, kind} -> + %GenLSP.Structures.FoldingRange{ + start_line: start_line, + end_line: end_line - 1, + kind: kind + } + end) + |> Enum.filter(fn range -> range.end_line > range.start_line end) + end + + defp classify_group({kind, {start_line, _, _}, _}, {_, {end_line, _, _}, _}) do + kind = + if kind == :at_op, + do: GenLSP.Enumerations.FoldingRangeKind.comment(), + else: GenLSP.Enumerations.FoldingRangeKind.region() + + {start_line, end_line, kind} + end +end diff --git a/apps/expert/lib/expert/provider/handlers/folding_range/token.ex b/apps/expert/lib/expert/provider/handlers/folding_range/token.ex new file mode 100644 index 00000000..0aa731d6 --- /dev/null +++ b/apps/expert/lib/expert/provider/handlers/folding_range/token.ex @@ -0,0 +1,43 @@ +defmodule Expert.Provider.Handlers.FoldingRange.Token do + @moduledoc """ + This module normalizes the tokens provided by `ElixirSense.Core.Normalized.Tokenizer` + """ + + alias ElixirSense.Core.Normalized.Tokenizer + + @type t :: {atom(), {non_neg_integer(), non_neg_integer(), any()}, any()} + + @doc """ + Make pattern-matching easier by forcing all token tuples to be 3-tuples. + Also convert start_info to 0-indexing as ranges are 0-indexed. + """ + @spec format_string(String.t()) :: [t()] + def format_string(text) do + reversed_tokens = text |> Tokenizer.tokenize() + + reversed_tokens + # This reverses the tokens, but they come out of Tokenizer.tokenize/1 + # already reversed. + |> Enum.reduce([], fn tuple, acc -> + tuple = + case tuple do + {a, {b1, b2, b3}} -> + {a, {b1 - 1, b2 - 1, b3}, nil} + + {a, {b1, b2, b3}, c} -> + {a, {b1 - 1, b2 - 1, b3}, c} + + {:sigil, {b1, b2, b3}, _, _, _, _, delimiter} -> + {:sigil, {b1 - 1, b2 - 1, b3}, delimiter} + + {:bin_heredoc, {b1, b2, b3}, _, _} -> + {:bin_heredoc, {b1 - 1, b2 - 1, b3}, nil} + + {:list_heredoc, {b1, b2, b3}, _, _} -> + {:list_heredoc, {b1 - 1, b2 - 1, b3}, nil} + end + + [tuple | acc] + end) + end +end diff --git a/apps/expert/lib/expert/provider/handlers/folding_range/token_pair.ex b/apps/expert/lib/expert/provider/handlers/folding_range/token_pair.ex new file mode 100644 index 00000000..6c471357 --- /dev/null +++ b/apps/expert/lib/expert/provider/handlers/folding_range/token_pair.ex @@ -0,0 +1,90 @@ +defmodule Expert.Provider.Handlers.FoldingRange.TokenPair do + @moduledoc """ + Code folding based on pairs of tokens + + Certain pairs of tokens, like `do` and `end`, natrually define ranges. + These ranges all have `kind: "region"`. + + Note that we exclude the line that the 2nd of the pair, e.g. `end`, is on. + This is so that when collapsed, both tokens are visible. + """ + + @token_pairs %{ + "(": [:")"], + "[": [:"]"], + "{": [:"}"], + "<<": [:">>"], + # do blocks + do: [:block_identifier, :end], + block_identifier: [:block_identifier, :end], + # other special forms that are not covered by :block_identifier + with: [:do], + for: [:do], + case: [:do], + fn: [:end] + } + + def provide_ranges(%{tokens: tokens}) do + ranges = + tokens + |> pair_tokens() + |> convert_token_pairs_to_ranges() + + {:ok, ranges} + end + + def pair_tokens(tokens) do + do_pair_tokens(tokens, [], []) + end + + # Note + # Tokenizer.tokenize/1 doesn't differentiate between successful and failed + # attempts to tokenize the string. + # This could mean the returned tokens are unbalanced. + # Therefore, the stack may not be empty when the base clause is hit. + # We're choosing to return the successfully paired tokens rather than to + # return an error if not all tokens could be paired. + defp do_pair_tokens([], _stack, pairs), do: pairs + + defp do_pair_tokens([{head_kind, _, _} = head | tail_tokens], [], pairs) do + new_stack = if @token_pairs |> Map.has_key?(head_kind), do: [head], else: [] + do_pair_tokens(tail_tokens, new_stack, pairs) + end + + defp do_pair_tokens( + [{head_kind, _, _} = head | tail_tokens], + [{top_kind, _, _} = top | tail_stack] = stack, + pairs + ) do + head_matches_any? = @token_pairs |> Map.has_key?(head_kind) + # Map.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 + {false, false} -> {stack, pairs} + {false, true} -> {tail_stack, [{top, head} | pairs]} + {true, false} -> {[head | stack], pairs} + {true, true} -> {[head | tail_stack], [{top, head} | pairs]} + end + + do_pair_tokens(tail_tokens, new_stack, new_pairs) + end + + defp convert_token_pairs_to_ranges(token_pairs) do + token_pairs + |> Enum.map(fn {{_, {start_line, _, _}, _}, {_, {end_line, _, _}, _}} -> + # -1 for end_line because the range should stop 1 short + # e.g. both "do" and "end" should be visible when collapsed + {start_line, end_line - 1} + end) + |> Enum.filter(fn {start_line, end_line} -> end_line > start_line end) + |> Enum.map(fn {start_line, end_line} -> + %GenLSP.Structures.FoldingRange{ + start_line: start_line, + end_line: end_line, + kind: GenLSP.Enumerations.FoldingRangeKind.region() + } + end) + end +end diff --git a/apps/expert/lib/expert/state.ex b/apps/expert/lib/expert/state.ex index 927345b3..369f7195 100644 --- a/apps/expert/lib/expert/state.ex +++ b/apps/expert/lib/expert/state.ex @@ -243,7 +243,8 @@ defmodule Expert.State do hover_provider: true, references_provider: true, text_document_sync: sync_options, - workspace_symbol_provider: true + workspace_symbol_provider: true, + folding_range_provider: true, } %GenLSP.Structures.InitializeResult{ diff --git a/apps/expert/test/expert/provider/handlers/folding_range_test.exs b/apps/expert/test/expert/provider/handlers/folding_range_test.exs new file mode 100644 index 00000000..ecf04e71 --- /dev/null +++ b/apps/expert/test/expert/provider/handlers/folding_range_test.exs @@ -0,0 +1,619 @@ +defmodule Expert.Provider.Handlers.FoldingRangeTest do + use ExUnit.Case + use Forge.Test.DocumentSupport + + alias Expert.Provider.Handlers.FoldingRange + + def to_document(text) do + Forge.Document.new("file://elixir.ex", text, 1) + end + + describe "indentation" do + setup [:fold_via_indentation] + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + :world # 2 + end # 3 + end # 4 + """ + test "basic test", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 3}, {1, 2}], text) + end + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + # world # 2 + if true do # 3 + :world # 4 + end # 5 + end # 6 + end # 7 + """ + test "consecutive matching levels", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 6}, {1, 5}, {3, 4}], text) + end + + @tag text: """ + defmodule A do # 0 + def f(%{"key" => value} = map) do # 1 + case NaiveDateTime.from_iso8601(value) do # 2 + {:ok, ndt} -> # 3 + dt = # 4 + ndt # 5 + |> DateTime.from_naive!("Etc/UTC") # 6 + |> Map.put(:microsecond, {0, 6}) # 7 + + %{map | "key" => dt} # 9 + + e -> # 11 + Logger.warning(\"\"\" + Could not use data map from #\{inspect(value)\} # 13 + #\{inspect(e)\} # 14 + \"\"\") + + :could_not_parse_value # 17 + end # 18 + end # 19 + end # 20 + """ + test "complicated function", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + expected = [{0, 19}, {1, 18}, {2, 17}, {3, 9}, {4, 7}, {11, 17}] + assert compare_condensed_ranges(ranges, expected, text) + end + + @tag text: """ + defmodule A do # 0 + def get_info(args) do # 1 + org = # 2 + args # 3 + |> Ecto.assoc(:organization) # 4 + |> Repo.one!() # 5 + + user = # 7 + org # 8 + |> Organization.user!() # 9 + + {:ok, %{org: org, user: user}} # 11 + end # 12 + end # 13 + """ + test "different complicated function", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 12}, {1, 11}, {2, 5}, {7, 9}], text) + end + + defp fold_via_indentation(%{text: text} = context) do + ranges_result = + text + |> to_document() + |> FoldingRange.document_to_input() + |> FoldingRange.Indentation.provide_ranges() + + {:ok, Map.put(context, :ranges_result, ranges_result)} + end + end + + describe "token pairs" do + setup [:fold_via_token_pairs] + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + :world # 2 + end # 3 + end # 4 + """ + test "basic test", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 3}, {1, 2}], text) + end + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + :world # 2 + end # 3 + end # 4 + """ + test "unusual indentation", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 3}, {1, 2}], text) + end + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + if true do # 2 + :hello # 3 + else # 4 + :error # 5 + end # 6 + end # 7 + end # 8 + """ + test "if-do-else-end", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 7}, {1, 6}, {2, 3}, {4, 5}], text) + end + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + try do # 2 + :hello # 3 + rescue # 4 + ArgumentError -> # 5 + IO.puts("rescue") # 6 + catch # 7 + value -> # 8 + IO.puts("catch") # 9 + else # 10 + value -> # 11 + IO.puts("else") # 12 + after # 13 + IO.puts("after") # 14 + end # 15 + end # 16 + end # 17 + """ + test "try block", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + expected = [{0, 16}, {1, 15}, {2, 3}, {4, 6}, {7, 9}, {10, 12}, {13, 14}] + assert compare_condensed_ranges(ranges, expected, text) + end + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + a = 20 # 2 + + case a do # 4 + 20 -> # 5 + :ok # 6 + + _ -> # 8 + :error # 9 + end # 10 + end # 11 + end # 12 + """ + test "1 defmodule, 1 def, 1 case", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 11}, {1, 10}, {4, 9}], text) + end + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + <<0>> # 2 + << # 3 + 1, 2, 3, # 4 + 4, 5, 6 # 5 + >> # 6 + end # 7 + end # 8 + """ + test "binaries", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 7}, {1, 6}, {3, 5}], text) + end + + @tag text: """ + defmodule A do # 0 + @moduledoc "This is module A" # 1 + end # 2 + + defmodule B do # 4 + @moduledoc "This is module B" # 5 + end # 6 + """ + test "2 defmodules in the top-level of file", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 1}, {4, 5}], text) + end + + @tag text: """ + defmodule A do # 0 + def compare_and_hello(list) do # 1 + assert list == [ # 2 + %{"a" => 1, "b" => 2}, # 3 + %{"a" => 3, "b" => 4}, # 4 + ] # 5 + + :world # 7 + end # 8 + end # 9 + """ + test "1 defmodule, 1 def, 1 list", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 8}, {1, 7}, {2, 4}], text) + end + + @tag text: """ + defmodule A do # 0 + def f(%{"key" => value} = map) do # 1 + case NaiveDateTime.from_iso8601(value) do # 2 + {:ok, ndt} -> # 3 + dt = # 4 + ndt # 5 + |> DateTime.from_naive!("Etc/UTC") # 6 + |> Map.put(:microsecond, {0, 6}) # 7 + + %{map | "key" => dt} # 9 + + e -> # 11 + Logger.warning(\"\"\" + Could not use data map from #\{inspect(value)\} # 13 + #\{inspect(e)\} # 14 + \"\"\") + + :could_not_parse_value # 17 + end # 18 + end # 19 + end # 20 + """ + test "complicated function", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{0, 19}, {1, 18}, {2, 17}, {12, 14}], text) + end + + defp fold_via_token_pairs(%{text: text} = context) do + ranges_result = + text + |> to_document() + |> FoldingRange.document_to_input() + |> FoldingRange.TokenPair.provide_ranges() + + {:ok, Map.put(context, :ranges_result, ranges_result)} + end + end + + describe "special tokens" do + setup [:fold_via_special_tokens] + + @tag text: """ + defmodule A do # 0 + @moduledoc \"\"\" + @moduledoc heredoc # 2 + \"\"\" + + @doc \"\"\" + @doc heredoc # 6 + \"\"\" + def hello() do # 8 + \"\"\" + regular heredoc # 10 + \"\"\" + end # 12 + end # 13 + """ + test "@moduledoc, @doc, and stand-alone heredocs", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + expected = [{1, 2, "comment"}, {5, 6, "comment"}, {9, 10, "region"}] + assert compare_condensed_ranges(ranges, expected, text) + end + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + " + regular string # 3 + " + ' + charlist string # 6 + ' + \"\"\" + regular heredoc # 9 + \"\"\" + ''' + charlist heredoc # 12 + ''' + end # 14 + end # 15 + """ + test "charlist heredocs", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{2, 3}, {5, 6}, {8, 9}, {11, 12}], text) + end + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + ~r/ + hello # 3 + / + ~r| + hello # 6 + | + ~r" + hello # 9 + " + ~r' + hello # 12 + ' + ~r( + hello # 15 + ) + ~r[ + hello # 18 + ] + ~r{ + hello # 21 + } + ~r< + hello # 24 + > + end # 26 + end # 27 + """ + test "sigil delimiters", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + expected = [{2, 3}, {5, 6}, {8, 9}, {11, 12}, {14, 15}, {17, 18}, {20, 21}, {23, 24}] + assert compare_condensed_ranges(ranges, expected, text) + end + + @tag text: """ + defmodule A do # 0 + @moduledoc ~S\"\"\" + sigil @moduledoc # 2 + \"\"\" + + @doc ~S\"\"\" + sigil @doc # 6 + \"\"\" + def hello() do # 8 + :world # 9 + end # 10 + end # 11 + """ + test "@doc with ~S sigil", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{1, 2, "comment"}, {5, 6, "comment"}], text) + end + + defp fold_via_special_tokens(%{text: text} = context) do + ranges_result = + text + |> to_document() + |> FoldingRange.document_to_input() + |> FoldingRange.SpecialToken.provide_ranges() + + {:ok, Map.put(context, :ranges_result, ranges_result)} + end + end + + describe "comment blocks" do + setup [:fold_via_comment_blocks] + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + # single comment # 2 + do_hello() # 3 + end # 4 + end # 5 + """ + test "no single line comment blocks", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [], text) + end + + @tag text: """ + defmodule A do # 0 + def hello() do # 1 + do_hello() # 2 + end # 3 + + # comment block 0 # 5 + # comment block 1 # 6 + # comment block 2 # 7 + defp do_hello(), do: :world # 8 + end # 9 + """ + test "@moduledoc, @doc, and stand-alone heredocs", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + assert compare_condensed_ranges(ranges, [{5, 7}], text) + end + + defp fold_via_comment_blocks(%{text: text} = context) do + ranges_result = + text + |> to_document() + |> FoldingRange.document_to_input() + |> FoldingRange.CommentBlock.provide_ranges() + + {:ok, Map.put(context, :ranges_result, ranges_result)} + end + end + + describe "end to end" do + setup [:fold_text] + + @tag text: """ + defmodule A do # 0 + @moduledoc ~S\"\"\" + I'm a @moduledoc heredoc. # 2 + \"\"\" + + def f(%{"key" => value} = map) do # 5 + # comment block 0 # 6 + # comment block 1 # 7 + case NaiveDateTime.from_iso8601(value) do # 8 + {:ok, ndt} -> # 9 + dt = # 10 + ndt # 11 + |> DateTime.from_naive!("Etc/UTC") # 12 + |> Map.put(:microsecond, {0, 6}) # 13 + + %{map | "key" => dt} # 15 + + e -> # 17 + Logger.warning(\"\"\" + Could not use data map from #\{inspect(value)\} # 19 + #\{inspect(e)\} # 20 + \"\"\") + + :could_not_parse_value # 23 + end # 24 + end # 25 + end # 26 + """ + test "complicated function", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + + expected = [ + {0, 25}, + {1, 2}, + {5, 24}, + {6, 7}, + {8, 23}, + {9, 15}, + {10, 13}, + {17, 23}, + {18, 20} + ] + + assert compare_condensed_ranges(ranges, expected, text) + end + + @tag text: """ + defmodule A do + @doc false + def init(_) do + IO.puts("Hello World!") + {:ok, []} + end + end + """ + test "@doc false does not create a folding range", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + expected = [{0, 5, "region"}, {2, 4, "region"}] + assert compare_condensed_ranges(ranges, expected, text) + end + + @tag text: """ + defmodule A do + @typedoc false + @type t :: %{} + + def init(_) do + IO.puts("Hello World!") + {:ok, []} + end + end + """ + test "@typedoc example", %{ranges_result: ranges_result, text: text} do + assert {:ok, ranges} = ranges_result + expected = [{0, 7, "region"}, {4, 6, "region"}] + assert compare_condensed_ranges(ranges, expected, text) + end + + @tag text: """ + defmodule A do + @moduledoc false + + def init(_) do + IO.puts("Hello World!") + {:ok, []} + end + end + """ + test "@moduledoc false does not create a folding range", %{ + ranges_result: ranges_result, + text: text + } do + assert {:ok, ranges} = ranges_result + expected = [{0, 6, "region"}, {3, 5, "region"}] + assert compare_condensed_ranges(ranges, expected, text) + end + + defp fold_text(%{text: text} = context) do + start_supervised(Forge.Document.Store) + + uri = "file:///test.ex" + + :ok = Forge.Document.Store.open(uri, text, 1) + + request = %GenLSP.Requests.TextDocumentFoldingRange{ + id: 1, + params: %GenLSP.Structures.FoldingRangeParams{ + text_document: %GenLSP.Structures.TextDocumentIdentifier{ + uri: uri + } + } + } + + ranges_result = FoldingRange.handle(request, %Expert.Configuration{}) + {:ok, Map.put(context, :ranges_result, ranges_result)} + end + end + + defp compare_condensed_ranges(result, expected_condensed, text) do + result_condensed = + result + |> Enum.map(fn + %GenLSP.Structures.FoldingRange{start_line: start_line, end_line: end_line, kind: kind} -> + {start_line, end_line, kind} + + %GenLSP.Structures.FoldingRange{start_line: start_line, end_line: end_line} -> + {start_line, end_line, :any} + end) + |> Enum.sort() + + expected_condensed = + expected_condensed + |> Enum.map(fn + {start_line, end_line, kind} -> + {start_line, end_line, kind} + + {start_line, end_line} -> + {start_line, end_line, :any} + end) + |> Enum.sort() + + {result_condensed, expected_condensed} = + Enum.zip(result_condensed, expected_condensed) + |> Enum.map(fn + {{rs, re, rk}, {es, ee, ek}} when rk == :any or ek == :any -> + {{rs, re, :any}, {es, ee, :any}} + + otherwise -> + otherwise + end) + |> Enum.unzip() + + if result_condensed != expected_condensed do + visualize_folding(text, result_condensed) + end + + assert result_condensed == expected_condensed + end + + def visualize_folding(nil, _), do: :ok + + def visualize_folding(text, result_condensed) do + lines = + String.split(text, "\n") + |> Enum.with_index() + |> Enum.map(fn {line, index} -> + String.pad_leading(to_string(index), 2, " ") <> ": " <> line + end) + + result_condensed + |> Enum.map(fn {line_start, line_end, descriptor} -> + out = + Enum.slice(lines, line_start, line_end - line_start + 2) + |> Enum.join("\n") + + IO.puts("Folding lines #{line_start}, #{line_end} (#{descriptor}):") + IO.puts(out) + IO.puts("\n") + end) + end +end