~sbaildon/lexical

f7f7693dc29b4a11e589556954fd54b114b7f1ad — Steve Cohen 2 months ago f6ca36f
Document Symbols support (#652)

* Document Symbols support

Added document symbols, which supports the following symbols:

  * Modules
  * Functions, both private and public
  * Typespecs
  * Module Attributes
  * ExUnit describe / setup / tests

Fixes #382

* Added support for block ranges and detail ranges

For document symbols, we need to provide support for block ranges for
things like modules, functions and tests, so that the editor can
understand if it's inside the given symbol.
The LSP also would like to have selection ranges, which are more
specific, and would, say highlight the function definition.

* Upgraded sourceror

Sourceror had a bug calculating end lines, which was causing responses
not to be emitted, but only when unicode was present.

It was emitting the ending several characters beyond where the `end`
keyword was, and this would fail during conversion as being out of bounds.
A apps/protocol/lib/generated/lexical/protocol/types/document/symbol.ex => apps/protocol/lib/generated/lexical/protocol/types/document/symbol.ex +15 -0
@@ 0,0 1,15 @@
# This file's contents are auto-generated. Do not edit.
defmodule Lexical.Protocol.Types.Document.Symbol do
  alias Lexical.Proto
  alias Lexical.Protocol.Types
  use Proto

  deftype children: optional(list_of(Types.Document.Symbol)),
          deprecated: optional(boolean()),
          detail: optional(string()),
          kind: Types.Symbol.Kind,
          name: string(),
          range: Types.Range,
          selection_range: Types.Range,
          tags: optional(list_of(Types.Symbol.Tag))
end

A apps/protocol/lib/generated/lexical/protocol/types/document/symbol/params.ex => apps/protocol/lib/generated/lexical/protocol/types/document/symbol/params.ex +10 -0
@@ 0,0 1,10 @@
# This file's contents are auto-generated. Do not edit.
defmodule Lexical.Protocol.Types.Document.Symbol.Params do
  alias Lexical.Proto
  alias Lexical.Protocol.Types
  use Proto

  deftype partial_result_token: optional(Types.Progress.Token),
          text_document: Types.TextDocument.Identifier,
          work_done_token: optional(Types.Progress.Token)
end

M apps/protocol/lib/lexical/protocol/requests.ex => apps/protocol/lib/lexical/protocol/requests.ex +6 -0
@@ 76,6 76,12 @@ defmodule Lexical.Protocol.Requests do
    defrequest "workspace/executeCommand", Types.ExecuteCommand.Params
  end

  defmodule DocumentSymbols do
    use Proto

    defrequest "textDocument/documentSymbol", Types.Document.Symbol.Params
  end

  # Server -> Client requests

  defmodule RegisterCapability do

M apps/protocol/lib/lexical/protocol/responses.ex => apps/protocol/lib/lexical/protocol/responses.ex +6 -0
@@ 50,6 50,12 @@ defmodule Lexical.Protocol.Responses do
    defresponse optional(list_of(one_of([list_of(Types.Completion.Item), Types.Completion.List])))
  end

  defmodule DocumentSymbols do
    use Proto

    defresponse optional(list_of(Types.Document.Symbol))
  end

  defmodule Shutdown do
    use Proto
    # yeah, this is odd... it has no params

M apps/remote_control/lib/lexical/remote_control/api.ex => apps/remote_control/lib/lexical/remote_control/api.ex +4 -0
@@ 130,4 130,8 @@ defmodule Lexical.RemoteControl.Api do
  def struct_definitions(%Project{} = project) do
    RemoteControl.call(project, CodeIntelligence.Structs, :for_project, [])
  end

  def document_symbols(%Project{} = project, %Document{} = document) do
    RemoteControl.call(project, CodeIntelligence.Symbols, :for_document, [document])
  end
end

A apps/remote_control/lib/lexical/remote_control/code_intelligence/symbols.ex => apps/remote_control/lib/lexical/remote_control/code_intelligence/symbols.ex +70 -0
@@ 0,0 1,70 @@
defmodule Lexical.RemoteControl.CodeIntelligence.Symbols do
  alias Lexical.Document
  alias Lexical.RemoteControl.CodeIntelligence.Symbols
  alias Lexical.RemoteControl.Search.Indexer
  alias Lexical.RemoteControl.Search.Indexer.Entry
  alias Lexical.RemoteControl.Search.Indexer.Extractors

  @block_types [
    :ex_unit_describe,
    :ex_unit_setup,
    :ex_unit_setup_all,
    :ex_unit_test,
    :module,
    :private_function,
    :public_function
  ]

  @symbol_extractors [
    Extractors.FunctionDefinition,
    Extractors.Module,
    Extractors.ModuleAttribute,
    Extractors.StructDefinition,
    Extractors.ExUnit
  ]

  def for_document(%Document{} = document) do
    {:ok, entries} = Indexer.Source.index_document(document, @symbol_extractors)

    definitions = Enum.filter(entries, &(&1.subtype == :definition))
    to_symbols(document, definitions)
  end

  defp to_symbols(%Document{} = document, entries) do
    entries_by_block_id = Enum.group_by(entries, & &1.block_id)
    rebuild_structure(entries_by_block_id, document, :root)
  end

  defp rebuild_structure(entries_by_block_id, %Document{} = document, block_id) do
    block_entries = Map.get(entries_by_block_id, block_id, [])

    Enum.flat_map(block_entries, fn
      %Entry{type: type, subtype: :definition} = entry when type in @block_types ->
        result =
          if Map.has_key?(entries_by_block_id, entry.id) do
            children =
              entries_by_block_id
              |> rebuild_structure(document, entry.id)
              |> Enum.sort_by(fn %Symbols.Document{} = symbol ->
                start = symbol.range.start
                {start.line, start.character}
              end)

            Symbols.Document.from(document, entry, children)
          else
            Symbols.Document.from(document, entry)
          end

        case result do
          {:ok, symbol} -> [symbol]
          _ -> []
        end

      %Entry{} = entry ->
        case Symbols.Document.from(document, entry) do
          {:ok, symbol} -> [symbol]
          _ -> []
        end
    end)
  end
end

A apps/remote_control/lib/lexical/remote_control/code_intelligence/symbols/document.ex => apps/remote_control/lib/lexical/remote_control/code_intelligence/symbols/document.ex +89 -0
@@ 0,0 1,89 @@
defmodule Lexical.RemoteControl.CodeIntelligence.Symbols.Document do
  alias Lexical.Document
  alias Lexical.Formats
  alias Lexical.RemoteControl.Search.Indexer.Entry

  defstruct [:name, :type, :range, :detail_range, :detail, children: []]

  def from(%Document{} = document, %Entry{} = entry, children \\ []) do
    case name_and_type(entry.type, entry, document) do
      {name, type} ->
        range = entry.block_range || entry.range

        {:ok,
         %__MODULE__{
           name: name,
           type: type,
           range: range,
           detail_range: entry.range,
           children: children
         }}

      _ ->
        :error
    end
  end

  @def_regex ~r/def\w*\s+/
  @do_regex ~r/\s*do\s*$/

  defp name_and_type(function, %Entry{} = entry, %Document{} = document)
       when function in [:public_function, :private_function] do
    fragment = Document.fragment(document, entry.range.start, entry.range.end)

    name =
      fragment
      |> String.replace(@def_regex, "")
      |> String.replace(@do_regex, "")

    {name, function}
  end

  @ignored_attributes ~w[spec doc moduledoc derive impl tag]
  @type_name_regex ~r/@type\s+[^\s]+/

  defp name_and_type(:module_attribute, %Entry{} = entry, document) do
    case String.split(entry.subject, "@") do
      [_, name] when name in @ignored_attributes ->
        nil

      [_, "type"] ->
        type_text = Document.fragment(document, entry.range.start, entry.range.end)

        name =
          case Regex.scan(@type_name_regex, type_text) do
            [[match]] -> match
            _ -> "@type ??"
          end

        {name, :type}

      [_, name] ->
        {"@#{name}", :module_attribute}
    end
  end

  defp name_and_type(ex_unit, %Entry{} = entry, document)
       when ex_unit in [:ex_unit_describe, :ex_unit_setup, :ex_unit_test] do
    name =
      document
      |> Document.fragment(entry.range.start, entry.range.end)
      |> String.trim()
      |> String.replace(@do_regex, "")

    {name, ex_unit}
  end

  defp name_and_type(:struct, %Entry{} = entry, _document) do
    module_name = Formats.module(entry.subject)
    {"%#{module_name}{}", :struct}
  end

  defp name_and_type(type, %Entry{subject: name}, _document) when is_atom(name) do
    {Formats.module(name), type}
  end

  defp name_and_type(type, %Entry{} = entry, _document) do
    {to_string(entry.subject), type}
  end
end

M apps/remote_control/lib/lexical/remote_control/search/indexer/entry.ex => apps/remote_control/lib/lexical/remote_control/search/indexer/entry.ex +23 -10
@@ 10,6 10,7 @@ defmodule Lexical.RemoteControl.Search.Indexer.Entry do
    :application,
    :id,
    :block_id,
    :block_range,
    :path,
    :range,
    :subject,


@@ 21,6 22,7 @@ defmodule Lexical.RemoteControl.Search.Indexer.Entry do
          application: module(),
          subject: subject(),
          block_id: block_id(),
          block_range: Lexical.Document.Range.t() | nil,
          path: Path.t(),
          range: Lexical.Document.Range.t(),
          subtype: entry_subtype(),


@@ 55,16 57,27 @@ defmodule Lexical.RemoteControl.Search.Indexer.Entry do
    new(path, Identifier.next_global!(), block.id, subject, type, :definition, range, application)
  end

  def block_definition(path, %Block{} = block, subject, type, range, application) do
    definition(
      path,
      block.id,
      block.parent_id,
      subject,
      type,
      range,
      application
    )
  def block_definition(
        path,
        %Block{} = block,
        subject,
        type,
        block_range,
        detail_range,
        application
      ) do
    definition =
      definition(
        path,
        block.id,
        block.parent_id,
        subject,
        type,
        detail_range,
        application
      )

    %__MODULE__{definition | block_range: block_range}
  end

  defp definition(path, id, block_id, subject, type, range, application) do

A apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/ex_unit.ex => apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/ex_unit.ex +127 -0
@@ 0,0 1,127 @@
defmodule Lexical.RemoteControl.Search.Indexer.Extractors.ExUnit do
  alias Lexical.Ast
  alias Lexical.Ast.Analysis
  alias Lexical.Document.Position
  alias Lexical.Document.Range
  alias Lexical.Formats
  alias Lexical.RemoteControl.Analyzer
  alias Lexical.RemoteControl.Search.Indexer.Entry
  alias Lexical.RemoteControl.Search.Indexer.Metadata
  alias Lexical.RemoteControl.Search.Indexer.Source.Reducer

  require Logger

  # setup block i.e. setup do... or setup arg do...
  def extract({setup_fn, _, args} = setup, %Reducer{} = reducer)
      when setup_fn in [:setup, :setup_all] and length(args) > 0 do
    {:ok, module} = Analyzer.current_module(reducer.analysis, Reducer.position(reducer))
    arity = arity_for(args)
    subject = Formats.mfa(module, setup_fn, arity)
    setup_type = :"ex_unit_#{setup_fn}"

    entry =
      case Metadata.location(setup) do
        {:block, _, _, _} ->
          block_entry(reducer, setup, setup_type, subject)

        {:expression, _} ->
          expression_entry(reducer, setup, setup_type, subject)
      end

    {:ok, entry}
  end

  # Test block test "test name" do ... or test "test name", arg do
  def extract({:test, _, [{_, _, [test_name]} | _] = args} = test, %Reducer{} = reducer) do
    {:ok, module} = Analyzer.current_module(reducer.analysis, Reducer.position(reducer))
    arity = arity_for(args)
    module_name = Formats.module(module)
    subject = "#{module_name}.[\"#{test_name}\"]/#{arity}"

    entry =
      case Metadata.location(test) do
        {:block, _, _, _} ->
          # a test with a body
          block_entry(reducer, test, :ex_unit_test, subject)

        {:expression, _} ->
          # a pending test
          expression_entry(reducer, test, :ex_unit_test, subject)
      end

    {:ok, entry}
  end

  # describe blocks
  def extract({:describe, _, [{_, _, [describe_name]} | _] = args} = test, %Reducer{} = reducer) do
    {:ok, module} = Analyzer.current_module(reducer.analysis, Reducer.position(reducer))
    arity = arity_for(args)
    module_name = Formats.module(module)
    subject = "#{module_name}[\"#{describe_name}\"]/#{arity}"

    entry = block_entry(reducer, test, :ex_unit_describe, subject)

    {:ok, entry}
  end

  def extract(_ign, _) do
    :ignored
  end

  defp expression_entry(%Reducer{} = reducer, ast, type, subject) do
    path = reducer.analysis.document.path
    block = Reducer.current_block(reducer)

    {:ok, module} = Analyzer.current_module(reducer.analysis, Reducer.position(reducer))
    app = Application.get_application(module)
    detail_range = detail_range(reducer.analysis, ast)

    Entry.definition(path, block, subject, type, detail_range, app)
  end

  defp block_entry(%Reducer{} = reducer, ast, type, subject) do
    path = reducer.analysis.document.path
    block = Reducer.current_block(reducer)

    {:ok, module} = Analyzer.current_module(reducer.analysis, Reducer.position(reducer))
    app = Application.get_application(module)
    detail_range = detail_range(reducer.analysis, ast)
    block_range = block_range(reducer.analysis, ast)
    Entry.block_definition(path, block, subject, type, block_range, detail_range, app)
  end

  defp block_range(%Analysis{} = analysis, ast) do
    case Ast.Range.fetch(ast, analysis.document) do
      {:ok, range} -> range
      _ -> nil
    end
  end

  defp detail_range(%Analysis{} = analysis, ast) do
    case Metadata.location(ast) do
      {:block, {start_line, start_column}, {do_line, do_column}, _} ->
        Range.new(
          Position.new(analysis.document, start_line, start_column),
          Position.new(analysis.document, do_line, do_column + 2)
        )

      {:expression, {start_line, start_column}} ->
        %{end: [line: end_line, column: end_column]} = Sourceror.get_range(ast)

        Range.new(
          Position.new(analysis.document, start_line, start_column),
          Position.new(analysis.document, end_line, end_column)
        )
    end
  end

  defp arity_for([{:__block__, _meta, labels}]) do
    length(labels)
  end

  defp arity_for(args) when is_list(args) do
    length(args)
  end

  defp arity_for(_), do: 0
end

M apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/function_definition.ex => apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/function_definition.ex +22 -4
@@ 1,5 1,6 @@
defmodule Lexical.RemoteControl.Search.Indexer.Extractors.FunctionDefinition do
  alias Lexical.Ast.Analysis
  alias Lexical.Ast.Range
  alias Lexical.Document.Position
  alias Lexical.Document.Range
  alias Lexical.RemoteControl


@@ 13,9 14,9 @@ defmodule Lexical.RemoteControl.Search.Indexer.Extractors.FunctionDefinition do

  def extract({definition, metadata, [{fn_name, _, args}, body]}, %Reducer{} = reducer)
      when is_atom(fn_name) and definition in @function_definitions do
    range = get_definition_range(reducer.analysis, metadata, body)
    detail_range = detail_range(reducer.analysis, metadata, body)

    {:ok, module} = RemoteControl.Analyzer.current_module(reducer.analysis, range.start)
    {:ok, module} = RemoteControl.Analyzer.current_module(reducer.analysis, detail_range.start)

    arity =
      case args do


@@ 36,8 37,18 @@ defmodule Lexical.RemoteControl.Search.Indexer.Extractors.FunctionDefinition do
    %Block{} = block = Reducer.current_block(reducer)
    path = reducer.analysis.document.path

    block_range = block_range(reducer.analysis, ast)

    entry =
      Entry.block_definition(path, block, mfa, type, range, Application.get_application(module))
      Entry.block_definition(
        path,
        block,
        mfa,
        type,
        block_range,
        detail_range,
        Application.get_application(module)
      )

    {:ok, entry, [args, body]}
  end


@@ 46,7 57,7 @@ defmodule Lexical.RemoteControl.Search.Indexer.Extractors.FunctionDefinition do
    :ignored
  end

  defp get_definition_range(%Analysis{} = analysis, def_metadata, block) do
  defp detail_range(%Analysis{} = analysis, def_metadata, block) do
    {line, column} = Metadata.position(def_metadata)

    {do_line, do_column} =


@@ 66,4 77,11 @@ defmodule Lexical.RemoteControl.Search.Indexer.Extractors.FunctionDefinition do
    do_pos = Position.new(analysis.document, do_line, do_column)
    Range.new(start_pos, do_pos)
  end

  defp block_range(%Analysis{} = analysis, def_ast) do
    case Lexical.Ast.Range.fetch(def_ast, analysis.document) do
      {:ok, range} -> range
      _ -> nil
    end
  end
end

M apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/module.ex => apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/module.ex +9 -1
@@ 19,7 19,7 @@ defmodule Lexical.RemoteControl.Search.Indexer.Extractors.Module do
  # extract a module definition
  def extract(
        {:defmodule, defmodule_meta,
         [{:__aliases__, module_name_meta, module_name}, module_block]},
         [{:__aliases__, module_name_meta, module_name}, module_block]} = defmodule_ast,
        %Reducer{} = reducer
      ) do
    %Block{} = block = Reducer.current_block(reducer)


@@ 35,6 35,7 @@ defmodule Lexical.RemoteControl.Search.Indexer.Extractors.Module do
            block,
            Subject.module(aliased_module),
            :module,
            block_range(reducer.analysis.document, defmodule_ast),
            range,
            Application.get_application(aliased_module)
          )


@@ 196,4 197,11 @@ defmodule Lexical.RemoteControl.Search.Indexer.Extractors.Module do
      Position.new(document, line, column + module_length)
    )
  end

  defp block_range(document, ast) do
    case Ast.Range.fetch(ast, document) do
      {:ok, range} -> range
      _ -> nil
    end
  end
end

M apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/variable.ex => apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/variable.ex +11 -0
@@ 69,6 69,10 @@ defmodule Lexical.RemoteControl.Search.Indexer.Extractors.Variable do
    :ignored
  end

  def extract({:@, _, _}, %Reducer{}) do
    {:ok, nil, nil}
  end

  # Generic variable reference
  def extract({var_name, _, _} = ast, %Reducer{} = reducer) when is_atom(var_name) do
    case extract_reference(ast, reducer, get_current_app(reducer)) do


@@ 103,6 107,9 @@ defmodule Lexical.RemoteControl.Search.Indexer.Extractors.Variable do
          {entries, ast} when is_list(entries) ->
            {ast, entries ++ acc}

          {_, ast} ->
            {ast, acc}

          _ ->
            {ast, acc}
        end


@@ 125,6 132,10 @@ defmodule Lexical.RemoteControl.Search.Indexer.Extractors.Variable do
    {reference, nil}
  end

  defp extract_definition({:@, _, _}, %Reducer{}, _current_app) do
    {nil, []}
  end

  # when clauses actually contain parameters and references
  defp extract_definition({:when, _, when_args}, %Reducer{} = reducer, _current_app) do
    {definitions, references} =

M apps/remote_control/lib/lexical/remote_control/search/indexer/source.ex => apps/remote_control/lib/lexical/remote_control/search/indexer/source.ex +5 -0
@@ 8,6 8,11 @@ defmodule Lexical.RemoteControl.Search.Indexer.Source do
  def index(path, source, extractors \\ nil) do
    path
    |> Document.new(source, 1)
    |> index_document(extractors)
  end

  def index_document(%Document{} = document, extractors \\ nil) do
    document
    |> Ast.analyze()
    |> Indexer.Quoted.index(extractors)
  end

A apps/remote_control/test/lexical/remote_control/code_intelligence/symbols_test.exs => apps/remote_control/test/lexical/remote_control/code_intelligence/symbols_test.exs +271 -0
@@ 0,0 1,271 @@
defmodule Lexical.RemoteControl.CodeIntelligence.SymbolsTest do
  alias Lexical.Document
  alias Lexical.RemoteControl.CodeIntelligence.Symbols
  use ExUnit.Case

  import Lexical.Test.CodeSigil
  import Lexical.Test.RangeSupport

  def document_symbols(code) do
    doc = Document.new("file:///file.ex", code, 1)
    symbols = Symbols.for_document(doc)
    {symbols, doc}
  end

  test "a top level module is found" do
    {[%Symbols.Document{} = module], doc} =
      ~q[
      defmodule MyModule do
      end
      ]
      |> document_symbols()

    assert decorate(doc, module.detail_range) =~ "defmodule «MyModule» do"
    assert module.name == "MyModule"
    assert module.type == :module
  end

  test "multiple top-level modules are found" do
    {[first, second], doc} =
      ~q[
      defmodule First do
      end

      defmodule Second do
      end
      ]
      |> document_symbols()

    assert decorate(doc, first.detail_range) =~ "defmodule «First» do"
    assert first.name == "First"
    assert first.type == :module

    assert decorate(doc, second.detail_range) =~ "defmodule «Second» do"
    assert second.name == "Second"
    assert second.type == :module
  end

  test "nested modules are found" do
    {[outer], doc} =
      ~q[
      defmodule Outer do
        defmodule Inner do
          defmodule Innerinner do
          end
        end
      end
      ]
      |> document_symbols()

    assert decorate(doc, outer.detail_range) =~ "defmodule «Outer» do"
    assert outer.name == "Outer"
    assert outer.type == :module

    assert [inner] = outer.children
    assert decorate(doc, inner.detail_range) =~ "defmodule «Inner» do"
    assert inner.name == "Outer.Inner"
    assert inner.type == :module

    assert [inner_inner] = inner.children
    assert decorate(doc, inner_inner.detail_range) =~ "defmodule «Innerinner» do"
    assert inner_inner.name == "Outer.Inner.Innerinner"
    assert inner_inner.type == :module
  end

  test "module attribute definitions are found" do
    {[module], doc} =
      ~q[
      defmodule Module do
        @first 3
        @second 4
      end
      ]
      |> document_symbols()

    assert [first, second] = module.children
    assert decorate(doc, first.detail_range) =~ "  «@first 3»"
    assert first.name == "@first"

    assert decorate(doc, second.detail_range) =~ "  «@second 4»"
    assert second.name == "@second"
  end

  test "module attribute references are skipped" do
    {[module], _doc} =
      ~q[
        defmodule Parent do
         @attr 3
         def my_fun() do
          @attr
         end
        end

      ]
      |> document_symbols()

    [_attr_def, function_def] = module.children
    [] = function_def.children
  end

  test "public function definitions are found" do
    {[module], doc} =
      ~q[
      defmodule Module do
        def my_fn do
        end
      end
      ]
      |> document_symbols()

    assert [function] = module.children
    assert decorate(doc, function.detail_range) =~ " «def my_fn do»"
  end

  test "private function definitions are found" do
    {[module], doc} =
      ~q[
      defmodule Module do
        defp my_fn do
        end
      end
      ]
      |> document_symbols()

    assert [function] = module.children
    assert decorate(doc, function.detail_range) =~ " «defp my_fn do»"
    assert function.name == "my_fn"
  end

  test "struct definitions are found" do
    {[module], doc} =
      ~q{
      defmodule Module do
        defstruct [:name, :value]
      end
      }
      |> document_symbols()

    assert [struct] = module.children
    assert decorate(doc, struct.detail_range) =~ "  «defstruct [:name, :value]»"
    assert struct.name == "%Module{}"
    assert struct.type == :struct
  end

  test "struct references are skippedd" do
    assert {[], _doc} =
             ~q[%OtherModule{}]
             |> document_symbols()
  end

  test "variable definitions are skipped" do
    {[module], _doc} =
      ~q[
      defmodule Module do
        defp my_fn do
          my_var = 3
        end
      end
      ]
      |> document_symbols()

    assert [function] = module.children
    assert [] = function.children
  end

  test "variable references are skipped" do
    {[module], _doc} =
      ~q[
      defmodule Module do
        defp my_fn do
          my_var = 3
          my_var
        end
      end
      ]
      |> document_symbols()

    assert [function] = module.children
    assert [] = function.children
  end

  test "guards shown in the name" do
    {[module], doc} =
      ~q[
      defmodule Module do
        def my_fun(x) when x > 0 do
        end
      end
      ]
      |> document_symbols()

    [fun] = module.children
    assert decorate(doc, fun.detail_range) =~ "  «def my_fun(x) when x > 0 do»"
    assert fun.type == :public_function
    assert fun.name == "my_fun(x) when x > 0"
    assert [] == fun.children
  end

  test "types show only their name" do
    {[type], doc} =
      ~q[
       @type something :: :ok
      ]
      |> document_symbols()

    assert decorate(doc, type.detail_range) =~ "«@type something :: :ok»"
    assert type.name == "@type something"
    assert type.type == :type
  end

  test "specs are ignored" do
    {[], _doc} =
      ~q[
      @spec my_fun(integer()) :: :ok
      ]
      |> document_symbols()
  end

  test "docs are ignored" do
    assert {[], _doc} =
             ~q[
                @doc """
                 Hello
                """
             ]
             |> document_symbols()
  end

  test "moduledocs are ignored" do
    assert {[], _doc} =
             ~q[
                @moduledoc """
                 Hello
                """
             ]
             |> document_symbols()
  end

  test "derives are ignored" do
    assert {[], _doc} =
             ~q[
               @derive {Something, other}
             ]
             |> document_symbols()
  end

  test "impl declarations are ignored" do
    assert {[], _doc} =
             ~q[
              @impl GenServer
             ]
             |> document_symbols()
  end

  test "tags ignored" do
    assert {[], _doc} =
             ~q[
              @tag :skip
             ]
             |> document_symbols()
  end
end

A apps/remote_control/test/lexical/remote_control/search/indexer/extractors/ex_unit_test.exs => apps/remote_control/test/lexical/remote_control/search/indexer/extractors/ex_unit_test.exs +323 -0
@@ 0,0 1,323 @@
defmodule Lexical.RemoteControl.Search.Indexer.Extractors.ExUnitTest do
  alias Lexical.RemoteControl.Search.Indexer.Extractors

  use Lexical.Test.ExtractorCase
  import Lexical.Test.RangeSupport

  @test_types [
    :ex_unit_setup,
    :ex_unit_setup_all,
    :ex_unit_test,
    :ex_unit_describe
  ]

  def index_definitions(source) do
    do_index(source, fn entry -> entry.type in @test_types and entry.subtype == :definition end, [
      Extractors.ExUnit
    ])
  end

  def index_with_structure(source) do
    do_index(source, fn entry -> entry.type != :metadata end, [
      Extractors.ExUnit,
      Extractors.Module
    ])
  end

  describe "finds setup" do
    test "in blocks without an argument" do
      {:ok, [setup], doc} =
        ~q[
        defmodule SomeTest do
          setup do
            :ok
          end
        end
        ]
        |> index_definitions()

      assert setup.type == :ex_unit_setup
      assert setup.subject == "SomeTest.setup/1"
      assert decorate(doc, setup.range) =~ "  «setup do»"
      assert decorate(doc, setup.block_range) =~ "  «setup do\n    :ok\n  end»"
    end

    test "in blocks with an argument" do
      {:ok, [setup], doc} =
        ~q[
        defmodule SomeTest do
          setup arg do
            :ok
          end
        end
        ]
        |> index_definitions()

      assert setup.type == :ex_unit_setup
      assert setup.subject == "SomeTest.setup/2"
      assert decorate(doc, setup.range) =~ "  «setup arg do»"
      assert decorate(doc, setup.block_range) =~ "  «setup arg do\n    :ok\n  end»"
    end

    test "as an atom" do
      {:ok, [setup], doc} =
        ~q[
        defmodule SomeTest do
          setup :other_function
        end
        ]
        |> index_definitions()

      assert setup.type == :ex_unit_setup
      assert setup.subject == "SomeTest.setup/1"
      refute setup.block_range
      assert decorate(doc, setup.range) =~ "  «setup :other_function»"
    end

    test "as a list of atoms" do
      {:ok, [setup], doc} =
        ~q{
        defmodule SomeTest do
          setup [:other_function, :second_function]
        end
        }
        |> index_definitions()

      assert setup.type == :ex_unit_setup
      assert setup.subject == "SomeTest.setup/1"
      refute setup.block_range
      assert decorate(doc, setup.range) =~ "  «setup [:other_function, :second_function]»"
    end

    test "as a MF tuple" do
      {:ok, [setup], doc} =
        ~q[
        defmodule SomeTest do
          setup {OtherModule, :setup}
        end
        ]
        |> index_definitions()

      assert setup.type == :ex_unit_setup
      assert setup.subject == "SomeTest.setup/1"
      refute setup.block_range
      assert decorate(doc, setup.range) =~ "  «setup {OtherModule, :setup}»"
    end

    test "unless setup is a variable" do
      {:ok, [test], _doc} =
        ~q[
        defmodule SomeTest do
          test "something" do
            setup = 3
            setup
          end
        end
        ]
        |> index_definitions()

      assert test.type == :ex_unit_test
    end
  end

  describe "finds setup_all" do
    test "as a block without an argument" do
      {:ok, [setup], doc} =
        ~q[
        defmodule SomeTest do
          setup_all do
            :ok
          end
        end
        ]
        |> index_definitions()

      assert setup.type == :ex_unit_setup_all
      assert setup.subject == "SomeTest.setup_all/1"
      assert decorate(doc, setup.range) =~ "  «setup_all do»"
      assert decorate(doc, setup.block_range) =~ "  «setup_all do\n    :ok\n  end"
    end

    test "as a block with an argument" do
      {:ok, [setup], doc} =
        ~q[
        defmodule SomeTest do
          setup_all arg do
            :ok
          end
        end
        ]
        |> index_definitions()

      assert setup.type == :ex_unit_setup_all
      assert setup.subject == "SomeTest.setup_all/2"
      assert decorate(doc, setup.range) =~ "  «setup_all arg do»"
      assert decorate(doc, setup.block_range) =~ "  «setup_all arg do\n    :ok\n  end"
    end

    test "as an atom" do
      {:ok, [setup], doc} =
        ~q[
        defmodule SomeTest do
          setup_all :other_function
        end
        ]
        |> index_definitions()

      assert setup.type == :ex_unit_setup_all
      assert setup.subject == "SomeTest.setup_all/1"
      refute setup.block_range

      assert decorate(doc, setup.range) =~ "  «setup_all :other_function»"
    end

    test "as a list of atoms" do
      {:ok, [setup], doc} =
        ~q{
        defmodule SomeTest do
          setup_all [:other_function, :second_function]
        end
        }
        |> index_definitions()

      assert setup.type == :ex_unit_setup_all
      assert setup.subject == "SomeTest.setup_all/1"
      refute setup.block_range

      assert decorate(doc, setup.range) =~ "  «setup_all [:other_function, :second_function]»"
    end

    test "as a MF tuple" do
      {:ok, [setup], doc} =
        ~q[
        defmodule SomeTest do
          setup_all {OtherModule, :setup}
        end
        ]
        |> index_definitions()

      assert setup.type == :ex_unit_setup_all
      assert setup.subject == "SomeTest.setup_all/1"
      refute setup.block_range

      assert decorate(doc, setup.range) =~ "  «setup_all {OtherModule, :setup}»"
    end
  end

  describe "finds describe blocks" do
    test "with an empty block" do
      {:ok, [describe], doc} =
        ~q[
        defmodule SomeTest do
          describe "something" do
          end
        end
        ]
        |> index_definitions()

      assert describe.type == :ex_unit_describe
      assert describe.subtype == :definition
      assert decorate(doc, describe.range) =~ "  «describe \"something\" do»"
      assert decorate(doc, describe.block_range) =~ "  «describe \"something\" do\n  end»"
    end

    test "with tests" do
      {:ok, [describe, _test], doc} =
        ~q[
        defmodule SomeTest do
          describe "something" do
            test "something"
          end
        end
        ]
        |> index_definitions()

      assert describe.type == :ex_unit_describe
      assert describe.subtype == :definition

      assert decorate(doc, describe.range) =~ "  «describe \"something\" do»"

      assert decorate(doc, describe.block_range) =~
               "  «describe \"something\" do\n    test \"something\"\n  end»"
    end
  end

  describe "finds tests" do
    test "when pending" do
      {:ok, [test], doc} =
        ~q[
      defmodule SomeTest do
        test "my test"
      end
      ]
        |> index_definitions()

      assert test.type == :ex_unit_test
      assert test.subject == "SomeTest.[\"my test\"]/1"
      refute test.block_range

      assert decorate(doc, test.range) =~ ~s[  «test "my test"»]
    end

    test "when they only have a block" do
      {:ok, [test], doc} =
        ~q[
      defmodule SomeTest do
        test "my test" do
        end
      end
      ]
        |> index_definitions()

      assert test.type == :ex_unit_test
      assert test.subject == "SomeTest.[\"my test\"]/2"

      assert decorate(doc, test.range) =~ ~s[  «test "my test" do»]
      assert decorate(doc, test.block_range) =~ ~s[  «test "my test" do\n  end»]
    end

    test "when they have a block and a context" do
      {:ok, [test], doc} =
        ~q[
      defmodule SomeTest do
        test "my test", context do
        end
      end
      ]
        |> index_definitions()

      assert test.type == :ex_unit_test
      assert test.subject =~ "SomeTest.[\"my test\"]/3"

      expected_detail = "  «test \"my test\", context do»"
      assert decorate(doc, test.range) =~ expected_detail

      expected_block = "  «test \"my test\", context do\n  end»"
      assert decorate(doc, test.block_range) =~ expected_block
    end
  end

  describe "block structure" do
    test "describe contains tests" do
      {:ok, [module, describe, test], _} =
        ~q[
        defmodule SomeTexst do
          describe "outer" do
            test "my test", context do
            end
          end
        end
        ]
        |> index_with_structure()

      assert module.type == :module
      assert module.block_id == :root

      assert describe.type == :ex_unit_describe
      assert describe.block_id == module.id

      assert test.type == :ex_unit_test
      assert test.block_id == describe.id
    end
  end
end

M apps/remote_control/test/lexical/remote_control/search/indexer/extractors/function_definition_test.exs => apps/remote_control/test/lexical/remote_control/search/indexer/extractors/function_definition_test.exs +17 -5
@@ 43,6 43,7 @@ defmodule Lexical.RemoteControl.Search.Indexer.Extractors.FunctionDefinitionTest
      assert zero_arity.subtype == :definition
      assert zero_arity.subject == "Parent.zero_arity/0"
      assert "def zero_arity, do" == extract(code, zero_arity.range)
      assert "def zero_arity, do: true" == extract(code, zero_arity.block_range)
    end

    test "finds zero arity public functions (with parens)" do


@@ 59,6 60,7 @@ defmodule Lexical.RemoteControl.Search.Indexer.Extractors.FunctionDefinitionTest
      assert zero_arity.subtype == :definition
      assert zero_arity.subject == "Parent.zero_arity/0"
      assert "def zero_arity() do" == extract(code, zero_arity.range)
      assert "def zero_arity() do\nend" == extract(code, zero_arity.block_range)
    end

    test "finds one arity public function" do


@@ 76,6 78,7 @@ defmodule Lexical.RemoteControl.Search.Indexer.Extractors.FunctionDefinitionTest
      assert one_arity.subtype == :definition
      assert one_arity.subject == "Parent.one_arity/1"
      assert "def one_arity(a) do" == extract(code, one_arity.range)
      assert "def one_arity(a) do\na + 1\nend" == extract(code, one_arity.block_range)
    end

    test "finds multi arity public function" do


@@ 93,6 96,9 @@ defmodule Lexical.RemoteControl.Search.Indexer.Extractors.FunctionDefinitionTest
      assert multi_arity.subtype == :definition
      assert multi_arity.subject == "Parent.multi_arity/4"
      assert "def multi_arity(a, b, c, d) do" == extract(code, multi_arity.range)

      assert "def multi_arity(a, b, c, d) do\n{a, b, c, d}\nend" ==
               extract(code, multi_arity.block_range)
    end

    test "finds multi-line function definitions" do


@@ 165,6 171,7 @@ defmodule Lexical.RemoteControl.Search.Indexer.Extractors.FunctionDefinitionTest
      assert zero_arity.subtype == :definition
      assert zero_arity.subject == "Parent.zero_arity/0"
      assert "defp zero_arity, do" == extract(code, zero_arity.range)
      assert "defp zero_arity, do: true" == extract(code, zero_arity.block_range)
    end

    test "finds zero arity one-line private functions (with parens)" do


@@ 180,6 187,7 @@ defmodule Lexical.RemoteControl.Search.Indexer.Extractors.FunctionDefinitionTest
      assert zero_arity.subtype == :definition
      assert zero_arity.subject == "Parent.zero_arity/0"
      assert "defp zero_arity(), do" == extract(code, zero_arity.range)
      assert "defp zero_arity(), do: true" == extract(code, zero_arity.block_range)
    end

    test "finds zero arity private functions (no parens)" do


@@ 196,6 204,7 @@ defmodule Lexical.RemoteControl.Search.Indexer.Extractors.FunctionDefinitionTest
      assert zero_arity.subtype == :definition
      assert zero_arity.subject == "Parent.zero_arity/0"
      assert "defp zero_arity do" == extract(code, zero_arity.range)
      assert "defp zero_arity do\nend" == extract(code, zero_arity.block_range)
    end

    test "finds zero arity private functions (with parens)" do


@@ 212,6 221,7 @@ defmodule Lexical.RemoteControl.Search.Indexer.Extractors.FunctionDefinitionTest
      assert zero_arity.subtype == :definition
      assert zero_arity.subject == "Parent.zero_arity/0"
      assert "defp zero_arity() do" == extract(code, zero_arity.range)
      assert "defp zero_arity() do\nend" == extract(code, zero_arity.block_range)
    end

    test "finds one arity one-line private functions" do


@@ 227,6 237,7 @@ defmodule Lexical.RemoteControl.Search.Indexer.Extractors.FunctionDefinitionTest
      assert one_arity.subtype == :definition
      assert one_arity.subject == "Parent.one_arity/1"
      assert "defp one_arity(a), do" == extract(code, one_arity.range)
      assert "defp one_arity(a), do: a + 1" == extract(code, one_arity.block_range)
    end

    test "finds one arity private functions" do


@@ 253,12 264,13 @@ defmodule Lexical.RemoteControl.Search.Indexer.Extractors.FunctionDefinitionTest
        ]
        |> in_a_module()

      {:ok, [one_arity], _} = index(code)
      {:ok, [multi_arity], _} = index(code)

      assert one_arity.type == :private_function
      assert one_arity.subtype == :definition
      assert one_arity.subject == "Parent.multi_arity/3"
      assert "defp multi_arity(a, b, c), do" == extract(code, one_arity.range)
      assert multi_arity.type == :private_function
      assert multi_arity.subtype == :definition
      assert multi_arity.subject == "Parent.multi_arity/3"
      assert "defp multi_arity(a, b, c), do" == extract(code, multi_arity.range)
      assert "defp multi_arity(a, b, c), do: {a, b, c}" = extract(code, multi_arity.block_range)
    end

    test "finds multi arity private functions" do

M apps/server/lib/lexical/server/provider/handlers.ex => apps/server/lib/lexical/server/provider/handlers.ex +4 -0
@@ 1,3 1,4 @@
# credo:disable-for-this-file Credo.Check.Refactor.CyclomaticComplexity
defmodule Lexical.Server.Provider.Handlers do
  alias Lexical.Protocol.Requests
  alias Lexical.Server.Provider.Handlers


@@ 28,6 29,9 @@ defmodule Lexical.Server.Provider.Handlers do
      %Requests.ExecuteCommand{} ->
        {:ok, Handlers.Commands}

      %Requests.DocumentSymbols{} ->
        {:ok, Handlers.DocumentSymbols}

      %request_module{} ->
        {:error, {:unhandled, request_module}}
    end

A apps/server/lib/lexical/server/provider/handlers/document_symbols.ex => apps/server/lib/lexical/server/provider/handlers/document_symbols.ex +58 -0
@@ 0,0 1,58 @@
defmodule Lexical.Server.Provider.Handlers.DocumentSymbols do
  alias Lexical.Document
  alias Lexical.Protocol.Requests.DocumentSymbols
  alias Lexical.Protocol.Responses
  alias Lexical.Protocol.Types.Document.Symbol
  alias Lexical.Protocol.Types.Symbol.Kind, as: SymbolKind
  alias Lexical.RemoteControl.Api
  alias Lexical.RemoteControl.CodeIntelligence.Symbols
  alias Lexical.Server.Provider.Env

  require SymbolKind

  def handle(%DocumentSymbols{} = request, %Env{} = env) do
    symbols =
      env.project
      |> Api.document_symbols(request.document)
      |> Enum.map(&to_response(&1, request.document))

    response = Responses.DocumentSymbols.new(request.id, symbols)

    {:reply, response}
  end

  def to_response(%Symbols.Document{} = root, %Document{} = document) do
    children =
      case root.children do
        list when is_list(list) ->
          Enum.map(list, &to_response(&1, document))

        _ ->
          nil
      end

    Symbol.new(
      children: children,
      detail: root.detail,
      kind: to_kind(root.type),
      name: root.name,
      range: root.range,
      selection_range: root.detail_range
    )
  end

  defp to_kind(:struct), do: :struct
  defp to_kind(:module), do: :module
  defp to_kind(:variable), do: :variable
  defp to_kind(:public_function), do: :function
  defp to_kind(:private_function), do: :function
  defp to_kind(:module_attribute), do: :constant
  defp to_kind(:ex_unit_test), do: :method
  defp to_kind(:ex_unit_describe), do: :method
  defp to_kind(:ex_unit_setup), do: :method
  defp to_kind(:ex_unit_setup_all), do: :method
  defp to_kind(:type), do: :type_parameter
  defp to_kind(:spec), do: :interface
  defp to_kind(:file), do: :file
  defp to_kind(_), do: :string
end

M apps/server/lib/lexical/server/state.ex => apps/server/lib/lexical/server/state.ex +1 -0
@@ 285,6 285,7 @@ defmodule Lexical.Server.State do
        completion_provider: completion_options,
        definition_provider: true,
        document_formatting_provider: true,
        document_symbol_provider: true,
        execute_command_provider: command_options,
        hover_provider: true,
        references_provider: true,

M mix.lock => mix.lock +1 -1
@@ 27,7 27,7 @@
  "plug": {:hex, :plug, "1.14.2", "cff7d4ec45b4ae176a227acd94a7ab536d9b37b942c8e8fa6dfc0fff98ff4d80", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "842fc50187e13cf4ac3b253d47d9474ed6c296a8732752835ce4a86acdf68d13"},
  "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"},
  "snowflake": {:hex, :snowflake, "1.0.4", "8433b4e04fbed19272c55e1b7de0f7a1ee1230b3ae31a813b616fd6ef279e87a", [:mix], [], "hexpm", "badb07ebb089a5cff737738297513db3962760b10fe2b158ae3bebf0b4d5be13"},
  "sourceror": {:hex, :sourceror, "1.0.1", "ec2c41726d181adce888ac94b3f33b359a811b46e019c084509e02c70042e424", [:mix], [], "hexpm", "28225464ffd68bda1843c974f3ff7ccef35e29be09a65dfe8e3df3f7e3600c57"},
  "sourceror": {:hex, :sourceror, "1.0.2", "c5e86fdc14881f797749d1fe5df017ca66727a8146e7ee3e736605a3df78f3e6", [:mix], [], "hexpm", "832335e87d0913658f129d58b2a7dc0490ddd4487b02de6d85bca0169ec2bd79"},
  "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"},
  "stream_data": {:hex, :stream_data, "0.6.0", "e87a9a79d7ec23d10ff83eb025141ef4915eeb09d4491f79e52f2562b73e5f47", [:mix], [], "hexpm", "b92b5031b650ca480ced047578f1d57ea6dd563f5b57464ad274718c9c29501c"},
  "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},