From f7f7693dc29b4a11e589556954fd54b114b7f1ad Mon Sep 17 00:00:00 2001 From: Steve Cohen Date: Wed, 27 Mar 2024 15:21:58 -0700 Subject: [PATCH] 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. --- .../lexical/protocol/types/document/symbol.ex | 15 + .../protocol/types/document/symbol/params.ex | 10 + .../protocol/lib/lexical/protocol/requests.ex | 6 + .../lib/lexical/protocol/responses.ex | 6 + .../lib/lexical/remote_control/api.ex | 4 + .../code_intelligence/symbols.ex | 70 ++++ .../code_intelligence/symbols/document.ex | 89 +++++ .../remote_control/search/indexer/entry.ex | 33 +- .../search/indexer/extractors/ex_unit.ex | 127 +++++++ .../indexer/extractors/function_definition.ex | 26 +- .../search/indexer/extractors/module.ex | 10 +- .../search/indexer/extractors/variable.ex | 11 + .../remote_control/search/indexer/source.ex | 5 + .../code_intelligence/symbols_test.exs | 271 +++++++++++++++ .../indexer/extractors/ex_unit_test.exs | 323 ++++++++++++++++++ .../extractors/function_definition_test.exs | 22 +- .../lib/lexical/server/provider/handlers.ex | 4 + .../provider/handlers/document_symbols.ex | 58 ++++ apps/server/lib/lexical/server/state.ex | 1 + mix.lock | 2 +- 20 files changed, 1072 insertions(+), 21 deletions(-) create mode 100644 apps/protocol/lib/generated/lexical/protocol/types/document/symbol.ex create mode 100644 apps/protocol/lib/generated/lexical/protocol/types/document/symbol/params.ex create mode 100644 apps/remote_control/lib/lexical/remote_control/code_intelligence/symbols.ex create mode 100644 apps/remote_control/lib/lexical/remote_control/code_intelligence/symbols/document.ex create mode 100644 apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/ex_unit.ex create mode 100644 apps/remote_control/test/lexical/remote_control/code_intelligence/symbols_test.exs create mode 100644 apps/remote_control/test/lexical/remote_control/search/indexer/extractors/ex_unit_test.exs create mode 100644 apps/server/lib/lexical/server/provider/handlers/document_symbols.ex diff --git a/apps/protocol/lib/generated/lexical/protocol/types/document/symbol.ex b/apps/protocol/lib/generated/lexical/protocol/types/document/symbol.ex new file mode 100644 index 000000000..ac39f53d7 --- /dev/null +++ b/apps/protocol/lib/generated/lexical/protocol/types/document/symbol.ex @@ -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 diff --git a/apps/protocol/lib/generated/lexical/protocol/types/document/symbol/params.ex b/apps/protocol/lib/generated/lexical/protocol/types/document/symbol/params.ex new file mode 100644 index 000000000..35f5d65bd --- /dev/null +++ b/apps/protocol/lib/generated/lexical/protocol/types/document/symbol/params.ex @@ -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 diff --git a/apps/protocol/lib/lexical/protocol/requests.ex b/apps/protocol/lib/lexical/protocol/requests.ex index e6a829915..4cb9b9313 100644 --- a/apps/protocol/lib/lexical/protocol/requests.ex +++ b/apps/protocol/lib/lexical/protocol/requests.ex @@ -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 diff --git a/apps/protocol/lib/lexical/protocol/responses.ex b/apps/protocol/lib/lexical/protocol/responses.ex index a7ef22ae2..d69bcc225 100644 --- a/apps/protocol/lib/lexical/protocol/responses.ex +++ b/apps/protocol/lib/lexical/protocol/responses.ex @@ -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 diff --git a/apps/remote_control/lib/lexical/remote_control/api.ex b/apps/remote_control/lib/lexical/remote_control/api.ex index e36446ac7..1f88988fd 100644 --- a/apps/remote_control/lib/lexical/remote_control/api.ex +++ b/apps/remote_control/lib/lexical/remote_control/api.ex @@ -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 diff --git a/apps/remote_control/lib/lexical/remote_control/code_intelligence/symbols.ex b/apps/remote_control/lib/lexical/remote_control/code_intelligence/symbols.ex new file mode 100644 index 000000000..d33a78ad1 --- /dev/null +++ b/apps/remote_control/lib/lexical/remote_control/code_intelligence/symbols.ex @@ -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 diff --git a/apps/remote_control/lib/lexical/remote_control/code_intelligence/symbols/document.ex b/apps/remote_control/lib/lexical/remote_control/code_intelligence/symbols/document.ex new file mode 100644 index 000000000..4f8e92b8d --- /dev/null +++ b/apps/remote_control/lib/lexical/remote_control/code_intelligence/symbols/document.ex @@ -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 diff --git a/apps/remote_control/lib/lexical/remote_control/search/indexer/entry.ex b/apps/remote_control/lib/lexical/remote_control/search/indexer/entry.ex index 0635746d9..4e57891a2 100644 --- a/apps/remote_control/lib/lexical/remote_control/search/indexer/entry.ex +++ b/apps/remote_control/lib/lexical/remote_control/search/indexer/entry.ex @@ -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 diff --git a/apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/ex_unit.ex b/apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/ex_unit.ex new file mode 100644 index 000000000..bfd1ed78e --- /dev/null +++ b/apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/ex_unit.ex @@ -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 diff --git a/apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/function_definition.ex b/apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/function_definition.ex index 5e04ac40e..f14ae423b 100644 --- a/apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/function_definition.ex +++ b/apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/function_definition.ex @@ -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 diff --git a/apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/module.ex b/apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/module.ex index b915b5ce2..534331dbb 100644 --- a/apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/module.ex +++ b/apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/module.ex @@ -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 diff --git a/apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/variable.ex b/apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/variable.ex index 010a16375..bd433aeed 100644 --- a/apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/variable.ex +++ b/apps/remote_control/lib/lexical/remote_control/search/indexer/extractors/variable.ex @@ -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} = diff --git a/apps/remote_control/lib/lexical/remote_control/search/indexer/source.ex b/apps/remote_control/lib/lexical/remote_control/search/indexer/source.ex index a339c42aa..cd21c0094 100644 --- a/apps/remote_control/lib/lexical/remote_control/search/indexer/source.ex +++ b/apps/remote_control/lib/lexical/remote_control/search/indexer/source.ex @@ -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 diff --git a/apps/remote_control/test/lexical/remote_control/code_intelligence/symbols_test.exs b/apps/remote_control/test/lexical/remote_control/code_intelligence/symbols_test.exs new file mode 100644 index 000000000..d0523c53e --- /dev/null +++ b/apps/remote_control/test/lexical/remote_control/code_intelligence/symbols_test.exs @@ -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 diff --git a/apps/remote_control/test/lexical/remote_control/search/indexer/extractors/ex_unit_test.exs b/apps/remote_control/test/lexical/remote_control/search/indexer/extractors/ex_unit_test.exs new file mode 100644 index 000000000..6ccabdf79 --- /dev/null +++ b/apps/remote_control/test/lexical/remote_control/search/indexer/extractors/ex_unit_test.exs @@ -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 diff --git a/apps/remote_control/test/lexical/remote_control/search/indexer/extractors/function_definition_test.exs b/apps/remote_control/test/lexical/remote_control/search/indexer/extractors/function_definition_test.exs index 140430351..ce199b4fc 100644 --- a/apps/remote_control/test/lexical/remote_control/search/indexer/extractors/function_definition_test.exs +++ b/apps/remote_control/test/lexical/remote_control/search/indexer/extractors/function_definition_test.exs @@ -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 diff --git a/apps/server/lib/lexical/server/provider/handlers.ex b/apps/server/lib/lexical/server/provider/handlers.ex index 7c524e0ad..35396dadd 100644 --- a/apps/server/lib/lexical/server/provider/handlers.ex +++ b/apps/server/lib/lexical/server/provider/handlers.ex @@ -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 diff --git a/apps/server/lib/lexical/server/provider/handlers/document_symbols.ex b/apps/server/lib/lexical/server/provider/handlers/document_symbols.ex new file mode 100644 index 000000000..cba79df21 --- /dev/null +++ b/apps/server/lib/lexical/server/provider/handlers/document_symbols.ex @@ -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 diff --git a/apps/server/lib/lexical/server/state.ex b/apps/server/lib/lexical/server/state.ex index 54785f26e..95a20317c 100644 --- a/apps/server/lib/lexical/server/state.ex +++ b/apps/server/lib/lexical/server/state.ex @@ -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, diff --git a/mix.lock b/mix.lock index f5bbf9188..dc935b167 100644 --- a/mix.lock +++ b/mix.lock @@ -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"},