diff --git a/README.md b/README.md index efe85b8a3..66bbb863c 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ This fork started when [Jake Becker's repository](https://github.com/JakeBecker/ - Code formatter - Find references to functions and modules (Thanks to @mattbaker) - Quick symbol lookup in file (Thanks to @mattbaker) +- Quick symbol lookup in workspace and stdlib (both Elixir and erlang) (@lukaszsamson) ![Screenshot](images/screenshot.png?raw=true) @@ -83,6 +84,28 @@ You can control which warnings are shown using the `elixirLS.dialyzerWarnOpts` s ElixirLS's Dialyzer integration uses internal, undocumented Dialyzer APIs, and so it won't be robust against changes to these APIs in future Erlang versions. +## Workspace Symbols + +With Dialyzer integration enabled ElixirLS will build an index of symbols (modules, functions, types and callbacks). The symbols are taken from the current workspace, all dependencies and stdlib (Elixir and erlang). This feature enables quick navigation to symbol definitions. However due to sheer number of different symbols and fuzzy search utilized by the provider, ElixirLS uses query prefixes to improve search results relevance. + +Use the following rules when navigating to workspace symbols: +* no prefix - search for modules + * `:erl` + * `Enu` +* `f ` prefix - search for functions + * `f inse` + * `f :ets.inse` + * `f Enum.cou` + * `f count/0` +* `t ` prefix - search for types + * `t t/0` + * `t :erlang.time_u` + * `t DateTime.` +* `c ` prefix - search for callbacks + * `c handle_info` + * `c GenServer.in` + * `c :gen_statem` + ## Troubleshooting Basic troubleshooting steps: @@ -95,6 +118,7 @@ If your code doesn't compile in ElixirLS, it may be because ElixirLS compiles co ## Known Issues * `.exs` files don't return compilation errors +* `workspaceSymbolProvider` capability currently requires enabled dialyzer ## Building and running diff --git a/apps/language_server/lib/language_server.ex b/apps/language_server/lib/language_server.ex index 9f7ad1f30..346688bc6 100644 --- a/apps/language_server/lib/language_server.ex +++ b/apps/language_server/lib/language_server.ex @@ -10,7 +10,8 @@ defmodule ElixirLS.LanguageServer do children = [ worker(ElixirLS.LanguageServer.Server, [ElixirLS.LanguageServer.Server]), - worker(ElixirLS.LanguageServer.JsonRpc, [[name: ElixirLS.LanguageServer.JsonRpc]]) + worker(ElixirLS.LanguageServer.JsonRpc, [[name: ElixirLS.LanguageServer.JsonRpc]]), + worker(ElixirLS.LanguageServer.Providers.WorkspaceSymbols, [[]]) ] opts = [strategy: :one_for_one, name: ElixirLS.LanguageServer.Supervisor, max_restarts: 0] diff --git a/apps/language_server/lib/language_server/erlang_source_file.ex b/apps/language_server/lib/language_server/erlang_source_file.ex new file mode 100644 index 000000000..9ce576a1d --- /dev/null +++ b/apps/language_server/lib/language_server/erlang_source_file.ex @@ -0,0 +1,46 @@ +defmodule ElixirLS.LanguageServer.ErlangSourceFile do + def get_beam_file(module, :preloaded) do + case :code.get_object_code(module) do + {_module, _binary, beam_file} -> beam_file + :error -> nil + end + end + + def get_beam_file(_module, beam_file), do: beam_file + + def beam_file_to_erl_file(beam_file) do + beam_file + |> to_string + |> String.replace( + Regex.recompile!(~r/(.+)\/ebin\/([^\s]+)\.beam$/), + "\\1/src/\\2.erl" + ) + end + + def module_line(file) do + find_line_by_regex(file, Regex.recompile!(~r/^-module/)) + end + + def function_line(file, function) do + # TODO use arity? + escaped = + function + |> Atom.to_string() + |> Regex.escape() + + find_line_by_regex(file, Regex.recompile!(~r/^#{escaped}\b/)) + end + + defp find_line_by_regex(file, regex) do + index = + file + |> File.read!() + |> String.split(["\n", "\r\n"]) + |> Enum.find_index(&String.match?(&1, regex)) + + case index do + nil -> nil + i -> i + 1 + end + end +end diff --git a/apps/language_server/lib/language_server/protocol.ex b/apps/language_server/lib/language_server/protocol.ex index 90af8f5c4..f6a6853b5 100644 --- a/apps/language_server/lib/language_server/protocol.ex +++ b/apps/language_server/lib/language_server/protocol.ex @@ -127,6 +127,14 @@ defmodule ElixirLS.LanguageServer.Protocol do end end + defmacro workspace_symbol_req(id, query) do + quote do + request(unquote(id), "workspace/symbol", %{ + "query" => unquote(query) + }) + end + end + defmacro signature_help_req(id, uri, line, character) do quote do request(unquote(id), "textDocument/signatureHelp", %{ diff --git a/apps/language_server/lib/language_server/providers/document_symbols.ex b/apps/language_server/lib/language_server/providers/document_symbols.ex index 02d2448e9..d4f06055d 100644 --- a/apps/language_server/lib/language_server/providers/document_symbols.ex +++ b/apps/language_server/lib/language_server/providers/document_symbols.ex @@ -5,34 +5,7 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do https://microsoft.github.io//language-server-protocol/specifications/specification-3-14/#textDocument_documentSymbol """ - @symbol_enum %{ - file: 1, - module: 2, - namespace: 3, - package: 4, - class: 5, - method: 6, - property: 7, - field: 8, - constructor: 9, - enum: 10, - interface: 11, - function: 12, - variable: 13, - constant: 14, - string: 15, - number: 16, - boolean: 17, - array: 18, - object: 19, - key: 20, - null: 21, - enum_member: 22, - struct: 23, - event: 24, - operator: 25, - type_parameter: 26 - } + alias ElixirLS.LanguageServer.Providers.SymbolUtils @defs [:def, :defp, :defmacro, :defmacrop, :defguard, :defguardp, :defdelegate] @@ -261,7 +234,7 @@ defmodule ElixirLS.LanguageServer.Providers.DocumentSymbols do defp build_symbol_information(uri, info) do %{ name: info.name, - kind: @symbol_enum[info.type], + kind: SymbolUtils.symbol_kind_to_code(info.type), range: location_to_range(info.location), selectionRange: location_to_range(info.location), children: build_symbol_information(uri, info.children) diff --git a/apps/language_server/lib/language_server/providers/symbol_utils.ex b/apps/language_server/lib/language_server/providers/symbol_utils.ex new file mode 100644 index 000000000..dec12df2a --- /dev/null +++ b/apps/language_server/lib/language_server/providers/symbol_utils.ex @@ -0,0 +1,34 @@ +defmodule ElixirLS.LanguageServer.Providers.SymbolUtils do + @symbol_enum %{ + file: 1, + module: 2, + namespace: 3, + package: 4, + class: 5, + method: 6, + property: 7, + field: 8, + constructor: 9, + enum: 10, + interface: 11, + function: 12, + variable: 13, + constant: 14, + string: 15, + number: 16, + boolean: 17, + array: 18, + object: 19, + key: 20, + null: 21, + enum_member: 22, + struct: 23, + event: 24, + operator: 25, + type_parameter: 26 + } + + for {kind, code} <- @symbol_enum do + def symbol_kind_to_code(unquote(kind)), do: unquote(code) + end +end diff --git a/apps/language_server/lib/language_server/providers/workspace_symbols.ex b/apps/language_server/lib/language_server/providers/workspace_symbols.ex new file mode 100644 index 000000000..bf4c5e927 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/workspace_symbols.ex @@ -0,0 +1,535 @@ +defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do + @moduledoc """ + Workspace Symbols provider. Generates and returns `SymbolInformation[]`. + + https://microsoft.github.io/language-server-protocol/specifications/specification-3-15/#workspace_symbol + """ + use GenServer + + alias ElixirLS.LanguageServer.ErlangSourceFile + alias ElixirLS.LanguageServer.SourceFile + alias ElixirLS.LanguageServer.Providers.SymbolUtils + alias ElixirLS.LanguageServer.JsonRpc + + @arity_suffix_regex ~r/\/\d+$/ + + @type position_t :: %{ + line: non_neg_integer, + character: non_neg_integer + } + + @type range_t :: %{ + start: position_t, + end: position_t + } + + @type location_t :: %{ + uri: String.t(), + range: range_t + } + + @type symbol_information_t :: %{ + kind: integer, + name: String.t(), + location: location_t + } + + @typep key_t :: :modules | :functions | :types | :callbacks + @typep symbol_t :: module | {module, atom, non_neg_integer} + @typep state_t :: %{ + required(key_t) => [symbol_information_t], + modified_uris: [String.t()] + } + + @symbol_codes for {key, kind} <- [ + modules: :module, + functions: :function, + types: :class, + callbacks: :event + ], + into: %{}, + do: {key, SymbolUtils.symbol_kind_to_code(kind)} + + ## Client API + + @spec symbols(String.t()) :: {:ok, [symbol_information_t]} + def symbols(query) do + results = + case query do + "f " <> fun_query -> + query(:functions, fun_query) + + "t " <> type_query -> + query(:types, type_query) + + "c " <> callback_query -> + query(:callbacks, callback_query) + + module_query -> + query(:modules, module_query) + end + + {:ok, results} + end + + def start_link(opts) do + GenServer.start_link(__MODULE__, :ok, opts |> Keyword.put(:name, __MODULE__)) + end + + def notify_build_complete do + GenServer.cast(__MODULE__, :build_complete) + end + + @spec notify_uris_modified([String.t()]) :: :ok + def notify_uris_modified(uris) do + GenServer.cast(__MODULE__, {:uris_modified, uris}) + end + + ## Server Callbacks + + @impl GenServer + def init(:ok) do + {:ok, + %{ + modules: [], + modules_indexed: false, + types: [], + types_indexed: false, + callbacks: [], + callbacks_indexed: false, + functions: [], + functions_indexed: false, + indexing: false, + modified_uris: [] + }} + end + + @impl GenServer + def handle_call({:query, key, query}, from, state) do + Task.start_link(fn -> + results = get_results(state, key, query) + GenServer.reply(from, results) + end) + + {:noreply, state} + end + + @impl GenServer + # not yet indexed + def handle_cast( + :build_complete, + state = %{ + indexing: false, + modules_indexed: false, + functions_indexed: false, + types_indexed: false, + callbacks_indexed: false + } + ) do + JsonRpc.log_message(:info, "[ElixirLS WorkspaceSymbols] Indexing...") + + module_paths = + :code.all_loaded() + |> process_chunked(fn chunk -> + for {module, beam_file} <- chunk, + path = find_module_path(module, beam_file), + path != nil, + do: {module, path} + end) + + JsonRpc.log_message(:info, "[ElixirLS WorkspaceSymbols] Module discovery complete") + + index(module_paths) + + {:noreply, %{state | indexing: true}} + end + + @impl GenServer + # indexed but some uris were modified + def handle_cast( + :build_complete, + %{ + indexing: false, + modified_uris: modified_uris = [_ | _] + } = state + ) do + JsonRpc.log_message(:info, "[ElixirLS WorkspaceSymbols] Updating index...") + + module_paths = + :code.all_loaded() + |> process_chunked(fn chunk -> + for {module, beam_file} <- chunk, + path = find_module_path(module, beam_file), + SourceFile.path_to_uri(path) in modified_uris, + do: {module, path} + end) + + JsonRpc.log_message( + :info, + "[ElixirLS WorkspaceSymbols] #{length(module_paths)} modules need reindexing" + ) + + index(module_paths) + + modules = + state.modules + |> Enum.reject(&(&1.location.uri in modified_uris)) + + functions = + state.functions + |> Enum.reject(&(&1.location.uri in modified_uris)) + + types = + state.types + |> Enum.reject(&(&1.location.uri in modified_uris)) + + callbacks = + state.callbacks + |> Enum.reject(&(&1.location.uri in modified_uris)) + + {:noreply, + %{ + state + | modules: modules, + modules_indexed: false, + functions: functions, + functions_indexed: false, + types: types, + types_indexed: false, + callbacks: callbacks, + callbacks_indexed: false, + indexing: true, + modified_uris: [] + }} + end + + # indexed and no uris momified or already indexing + def handle_cast(:build_complete, state) do + {:noreply, state} + end + + @impl GenServer + def handle_cast({:uris_modified, uris}, state) do + state = %{state | modified_uris: uris ++ state.modified_uris} + + {:noreply, state} + end + + @impl GenServer + def handle_info({:indexing_complete, key, results}, state) do + state = + state + |> Map.put(key, results ++ state[key]) + |> Map.put(:"#{key}_indexed", true) + + indexed = + state.modules_indexed and state.functions_indexed and state.types_indexed and + state.callbacks_indexed + + {:noreply, %{state | indexing: not indexed}} + end + + ## Helpers + + defp find_module_line(module, path) do + if String.ends_with?(path, ".erl") do + ErlangSourceFile.module_line(path) + else + SourceFile.module_line(module) + end + end + + defp find_function_line(module, function, arity, path) do + if String.ends_with?(path, ".erl") do + ErlangSourceFile.function_line(path, function) + else + SourceFile.function_line(module, function, arity) + end + end + + defp find_module_path(module, beam_file) do + file = + with true <- Code.ensure_loaded?(module), + path when not is_nil(path) <- module.module_info(:compile)[:source], + path_binary = List.to_string(path), + true <- File.exists?(path_binary) do + path_binary + else + _ -> nil + end + + if file do + file + else + with beam_file when not is_nil(beam_file) <- + ErlangSourceFile.get_beam_file(module, beam_file), + erl_file = ErlangSourceFile.beam_file_to_erl_file(beam_file), + true <- File.exists?(erl_file) do + erl_file + else + _ -> nil + end + end + end + + defp get_score(item, query, query_downcase, query_length, arity_suffix) do + item_downcase = String.downcase(item) + + parts = item |> String.split(".") + + cond do + # searching for an erlang module but item is an Elixir module + String.starts_with?(query, ":") and not String.starts_with?(item, ":") -> + 0.0 + + # searching for an Elixir module but item is an erlang module + Regex.match?(~r/^[A-Z]/, query) and String.starts_with?(item, ":") -> + 0.0 + + # searching for an Elixir module or erlang/Elixir function but item has no `.` + String.contains?(query, ".") and not String.contains?(item, ".") -> + 0.0 + + # query specifies arity and item's arity does not match + arity_suffix != nil and not String.ends_with?(item, arity_suffix) -> + 0.0 + + length(parts) > 1 and Enum.at(parts, -1) |> exact_or_contains?(query, query_length) -> + 2.0 + + length(parts) > 1 and + Enum.at(parts, -1) + |> String.downcase() + |> exact_or_contains?(query_downcase, query_length) -> + 1.8 + + exact_or_contains?(item, query, query_length) -> + 1.3 + + exact_or_contains?(item_downcase, query_downcase, query_length) -> + 1.2 + + query_length >= 3 -> + String.jaro_distance(item_downcase, query_downcase) + + true -> + 0.0 + end + end + + defp exact_or_contains?(string, needle = "/" <> _, needle_length) when needle_length < 3 do + String.ends_with?(string, needle) + end + + defp exact_or_contains?(string, needle, needle_length) when needle_length < 3 do + string_no_arity = Regex.replace(@arity_suffix_regex, string, "") + string_no_arity == needle + end + + defp exact_or_contains?(string, needle, _needle_length), do: String.contains?(string, needle) + + defp limit_results(list) do + list + |> Enum.sort_by(&elem(&1, 1), &>=/2) + |> Enum.reduce_while({[], false}, fn {element, score}, {list, match_found} -> + match_found = match_found or score > 1.0 + + if match_found do + if score > 1.0 do + {:cont, {[element | list], match_found}} + else + {:halt, {list, match_found}} + end + else + if length(list) < 15 do + {:cont, {[element | list], match_found}} + else + {:halt, {list, match_found}} + end + end + end) + |> elem(0) + end + + defp query(kind, query) do + case String.trim(query) do + "" -> + [] + + trimmed -> + GenServer.call(__MODULE__, {:query, kind, trimmed}) + end + end + + defp index(module_paths) do + chunked_module_paths = chunk_by_schedulers(module_paths) + + index_async(:modules, fn -> + chunked_module_paths + |> do_process_chunked(fn chunk -> + for {module, path} <- chunk do + line = find_module_line(module, path) + build_result(:modules, module, path, line) + end + end) + end) + + index_async(:functions, fn -> + chunked_module_paths + |> do_process_chunked(fn chunk -> + for {module, path} <- chunk, + {function, arity} <- module.module_info(:exports) do + {function, arity} = strip_macro_prefix({function, arity}) + line = find_function_line(module, function, arity, path) + + build_result(:functions, {module, function, arity}, path, line) + end + end) + end) + + index_async(:types, fn -> + chunked_module_paths + |> do_process_chunked(fn chunk -> + for {module, path} <- chunk, + # TODO: Don't call into here directly + {kind, {type, type_ast, args}} <- + ElixirSense.Core.Normalized.Typespec.get_types(module), + kind in [:type, :opaque] do + line = + case type_ast do + {_, line, _, _} -> line + {_, line, _} -> line + end + + build_result(:types, {module, type, length(args)}, path, line) + end + end) + end) + + index_async(:callbacks, fn -> + chunked_module_paths + |> do_process_chunked(fn chunk -> + for {module, path} <- chunk, + function_exported?(module, :behaviour_info, 1), + # TODO: Don't call into here directly + {{callback, arity}, [{:type, line, _, _}]} <- + ElixirSense.Core.Normalized.Typespec.get_callbacks(module) do + {callback, arity} = strip_macro_prefix({callback, arity}) + + build_result(:callbacks, {module, callback, arity}, path, line) + end + end) + end) + end + + defp index_async(key, fun) do + self = self() + + Task.start_link(fn -> + results = fun.() + + send(self, {:indexing_complete, key, results}) + + JsonRpc.log_message( + :info, + "[ElixirLS WorkspaceSymbols] #{length(results)} #{key} added to index" + ) + end) + end + + @spec get_results(state_t, key_t, String.t()) :: [symbol_information_t] + defp get_results(state, key, query) do + query_downcase = String.downcase(query) + query_length = String.length(query) + arity_suffix = Regex.run(@arity_suffix_regex, query) + + state + |> Map.fetch!(key) + |> process_chunked(fn chunk -> + chunk + |> Enum.map(&{&1, get_score(&1.name, query, query_downcase, query_length, arity_suffix)}) + |> Enum.reject(fn {_item, score} -> score < 0.1 end) + end) + |> limit_results + end + + defp chunk_by_schedulers(enumerable) do + chunk_size = + Enum.count(enumerable) + |> div(System.schedulers_online()) + |> max(1) + + enumerable + |> Enum.chunk_every(chunk_size) + end + + defp process_chunked(enumerable, fun) do + enumerable + |> chunk_by_schedulers + |> do_process_chunked(fun) + end + + defp do_process_chunked(chunked_enumerable, fun) do + chunked_enumerable + |> Enum.map(fn chunk when is_list(chunk) -> + Task.async(fn -> + fun.(chunk) + end) + end) + |> Task.yield_many(:infinity) + |> Enum.flat_map(fn {_task, {:ok, result}} when is_list(result) -> + result + end) + end + + @spec build_result(key_t, symbol_t, String.t(), nil | non_neg_integer) :: symbol_information_t + defp build_result(key, symbol, path, line) do + %{ + kind: @symbol_codes |> Map.fetch!(key), + name: symbol_name(key, symbol), + location: %{ + uri: SourceFile.path_to_uri(path), + range: build_range(line) + } + } + end + + @spec symbol_name(key_t, symbol_t) :: String.t() + defp symbol_name(:modules, module) do + inspect(module) + end + + defp symbol_name(:functions, {module, function, arity}) do + "f #{inspect(module)}.#{function}/#{arity}" + end + + defp symbol_name(:types, {module, type, arity}) do + "t #{inspect(module)}.#{type}/#{arity}" + end + + defp symbol_name(:callbacks, {module, callback, arity}) do + "c #{inspect(module)}.#{callback}/#{arity}" + end + + @spec build_range(nil | non_neg_integer) :: range_t + defp build_range(nil) do + %{ + start: %{line: 0, character: 0}, + end: %{line: 1, character: 0} + } + end + + defp build_range(line) do + %{ + start: %{line: max(line - 1, 0), character: 0}, + end: %{line: line, character: 0} + } + end + + defp strip_macro_prefix({function, arity}) do + case Atom.to_string(function) do + "MACRO-" <> rest -> {String.to_atom(rest), arity - 1} + _other -> {function, arity} + end + end +end diff --git a/apps/language_server/lib/language_server/server.ex b/apps/language_server/lib/language_server/server.ex index 05b5418d7..74de0d062 100644 --- a/apps/language_server/lib/language_server/server.ex +++ b/apps/language_server/lib/language_server/server.ex @@ -26,6 +26,7 @@ defmodule ElixirLS.LanguageServer.Server do Formatting, SignatureHelp, DocumentSymbols, + WorkspaceSymbols, OnTypeFormatting, CodeLens, ExecuteCommand @@ -291,6 +292,7 @@ defmodule ElixirLS.LanguageServer.Server do end defp handle_notification(did_save(uri), state) do + WorkspaceSymbols.notify_uris_modified([uri]) state = update_in(state.source_files[uri], &%{&1 | dirty?: false}) trigger_build(state) end @@ -302,6 +304,11 @@ defmodule ElixirLS.LanguageServer.Server do (type in [1, 3] or not Map.has_key?(state.source_files, uri)) end) + changes + |> Enum.map(& &1["uri"]) + |> Enum.uniq() + |> WorkspaceSymbols.notify_uris_modified() + if needs_build, do: trigger_build(state), else: state end @@ -387,6 +394,15 @@ defmodule ElixirLS.LanguageServer.Server do {:async, fun, state} end + defp handle_request(workspace_symbol_req(_id, query), state) do + fun = fn -> + state.source_files + WorkspaceSymbols.symbols(query) + end + + {:async, fun, state} + end + defp handle_request(completion_req(_id, uri, line, character), state) do snippets_supported = !!get_in(state.client_capabilities, [ @@ -467,6 +483,7 @@ defmodule ElixirLS.LanguageServer.Server do "documentFormattingProvider" => Formatting.supported?(), "signatureHelpProvider" => %{"triggerCharacters" => ["("]}, "documentSymbolProvider" => true, + "workspaceSymbolProvider" => true, "documentOnTypeFormattingProvider" => %{"firstTriggerCharacter" => "\n"}, "codeLensProvider" => %{"resolveProvider" => false}, "executeCommandProvider" => %{"commands" => ["spec"]}, @@ -572,6 +589,8 @@ defmodule ElixirLS.LanguageServer.Server do GenServer.reply(from, contracts) end + WorkspaceSymbols.notify_build_complete() + %{state | analysis_ready?: true, awaiting_contracts: dirty} else state diff --git a/apps/language_server/lib/language_server/source_file.ex b/apps/language_server/lib/language_server/source_file.ex index bb2f7142c..d2e6e9844 100644 --- a/apps/language_server/lib/language_server/source_file.ex +++ b/apps/language_server/lib/language_server/source_file.ex @@ -113,6 +113,17 @@ defmodule ElixirLS.LanguageServer.SourceFile do IO.iodata_to_binary(Enum.reverse(acc)) end + def module_line(module) do + # TODO: Don't call into here directly + case ElixirSense.Core.Normalized.Code.get_docs(module, :moduledoc) do + nil -> + nil + + {line, _} -> + line + end + end + def function_line(mod, fun, arity) do # TODO: Don't call into here directly case ElixirSense.Core.Normalized.Code.get_docs(mod, :docs) do @@ -121,7 +132,7 @@ defmodule ElixirLS.LanguageServer.SourceFile do docs -> Enum.find_value(docs, fn - {{^fun, ^arity}, line, :def, _, _} -> line + {{^fun, ^arity}, line, _, _, _} -> line _ -> nil end) end diff --git a/apps/language_server/test/providers/workspace_symbols_test.exs b/apps/language_server/test/providers/workspace_symbols_test.exs new file mode 100644 index 000000000..2f025e9c5 --- /dev/null +++ b/apps/language_server/test/providers/workspace_symbols_test.exs @@ -0,0 +1,230 @@ +defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbolsTest do + alias ElixirLS.LanguageServer.Providers.WorkspaceSymbols + use ExUnit.Case + + setup_all do + pid = + case WorkspaceSymbols.start_link([]) do + {:ok, pid} -> pid + {:error, {:already_started, pid}} -> pid + end + + state = :sys.get_state(pid) + + fixture_uri = + ElixirLS.LanguageServer.Fixtures.WorkspaceSymbols.module_info(:compile)[:source] + |> List.to_string() + |> ElixirLS.LanguageServer.SourceFile.path_to_uri() + + :sys.replace_state(pid, fn _ -> + %{ + state + | modules_indexed: true, + functions_indexed: true, + types_indexed: true, + callbacks_indexed: true, + modified_uris: [fixture_uri] + } + end) + + WorkspaceSymbols.notify_build_complete() + + wait_until_indexed(pid) + + on_exit(fn -> + :sys.replace_state(pid, fn _ -> state end) + end) + + {:ok, %{}} + end + + test "empty query" do + assert {:ok, []} == WorkspaceSymbols.symbols("") + end + + test "returns modules" do + assert {:ok, + [ + %{ + kind: 2, + location: %{ + range: %{end: %{character: 0, line: 1}, start: %{character: 0, line: 0}}, + uri: uri + }, + name: "ElixirLS.LanguageServer.Fixtures.WorkspaceSymbols" + } + ]} = WorkspaceSymbols.symbols("ElixirLS.LanguageServer.Fixtures.") + + assert uri |> String.ends_with?("test/support/fixtures/workspace_symbols.ex") + + assert {:ok, + [ + %{ + kind: 2, + location: %{ + range: %{end: %{character: 0, line: 1}, start: %{character: 0, line: 0}}, + uri: uri + }, + name: "ElixirLS.LanguageServer.Fixtures.WorkspaceSymbols" + } + ]} = WorkspaceSymbols.symbols("work") + + assert uri |> String.ends_with?("test/support/fixtures/workspace_symbols.ex") + end + + test "returns functions" do + assert { + :ok, + [ + %{ + kind: 12, + location: %{ + range: %{end: %{character: 0, line: 1}, start: %{character: 0, line: 0}}, + uri: uri + }, + name: "f ElixirLS.LanguageServer.Fixtures.WorkspaceSymbols.module_info/1" + }, + %{ + kind: 12, + location: %{ + range: %{end: %{character: 0, line: 1}, start: %{character: 0, line: 0}} + }, + name: "f ElixirLS.LanguageServer.Fixtures.WorkspaceSymbols.module_info/0" + }, + %{ + kind: 12, + location: %{ + range: %{end: %{character: 0, line: 1}, start: %{character: 0, line: 0}} + }, + name: "f ElixirLS.LanguageServer.Fixtures.WorkspaceSymbols.behaviour_info/1" + }, + %{ + kind: 12, + location: %{ + range: %{end: %{character: 0, line: 3}, start: %{character: 0, line: 2}} + }, + name: "f ElixirLS.LanguageServer.Fixtures.WorkspaceSymbols.some_macro/1" + }, + %{ + kind: 12, + location: %{ + range: %{end: %{character: 0, line: 2}, start: %{character: 0, line: 1}} + }, + name: "f ElixirLS.LanguageServer.Fixtures.WorkspaceSymbols.some_function/1" + }, + %{ + kind: 12, + location: %{ + range: %{end: %{character: 0, line: 1}, start: %{character: 0, line: 0}} + }, + name: "f ElixirLS.LanguageServer.Fixtures.WorkspaceSymbols.__info__/1" + } + ] + } = WorkspaceSymbols.symbols("f ElixirLS.LanguageServer.Fixtures.") + + assert uri |> String.ends_with?("test/support/fixtures/workspace_symbols.ex") + + assert {:ok, + [ + %{ + kind: 12, + location: %{ + range: %{end: %{character: 0, line: 2}, start: %{character: 0, line: 1}}, + uri: uri + }, + name: "f ElixirLS.LanguageServer.Fixtures.WorkspaceSymbols.some_function/1" + } + ]} = WorkspaceSymbols.symbols("f fun") + + assert uri |> String.ends_with?("test/support/fixtures/workspace_symbols.ex") + end + + test "returns types" do + assert { + :ok, + [ + %{ + kind: 5, + location: %{ + range: %{end: %{character: 0, line: 8}, start: %{character: 0, line: 7}}, + uri: uri + }, + name: "t ElixirLS.LanguageServer.Fixtures.WorkspaceSymbols.some_type/0" + }, + %{ + kind: 5, + location: %{ + range: %{end: %{character: 0, line: 9}, start: %{character: 0, line: 8}} + }, + name: "t ElixirLS.LanguageServer.Fixtures.WorkspaceSymbols.some_opaque_type/0" + } + ] + } = WorkspaceSymbols.symbols("t ElixirLS.LanguageServer.Fixtures.") + + assert uri |> String.ends_with?("test/support/fixtures/workspace_symbols.ex") + + assert { + :ok, + [ + %{ + kind: 5, + location: %{ + range: %{end: %{character: 0, line: 9}, start: %{character: 0, line: 8}}, + uri: uri + }, + name: "t ElixirLS.LanguageServer.Fixtures.WorkspaceSymbols.some_opaque_type/0" + } + ] + } = WorkspaceSymbols.symbols("t opa") + + assert uri |> String.ends_with?("test/support/fixtures/workspace_symbols.ex") + end + + test "returns callbacks" do + assert { + :ok, + [ + %{ + kind: 24, + location: %{ + range: %{end: %{character: 0, line: 5}, start: %{character: 0, line: 4}}, + uri: uri + }, + name: "c ElixirLS.LanguageServer.Fixtures.WorkspaceSymbols.some_callback/1" + }, + %{ + kind: 24, + location: %{ + range: %{end: %{character: 0, line: 6}, start: %{character: 0, line: 5}} + }, + name: "c ElixirLS.LanguageServer.Fixtures.WorkspaceSymbols.some_macrocallback/1" + } + ] + } = WorkspaceSymbols.symbols("c ElixirLS.LanguageServer.Fixtures.") + + assert uri |> String.ends_with?("test/support/fixtures/workspace_symbols.ex") + + assert {:ok, + [ + %{ + kind: 24, + location: %{ + range: %{end: %{character: 0, line: 6}, start: %{character: 0, line: 5}}, + uri: uri + }, + name: "c ElixirLS.LanguageServer.Fixtures.WorkspaceSymbols.some_macrocallback/1" + } + ]} = WorkspaceSymbols.symbols("c macr") + + assert uri |> String.ends_with?("test/support/fixtures/workspace_symbols.ex") + end + + defp wait_until_indexed(pid) do + state = :sys.get_state(pid) + + if state.modules == [] or state.functions == [] or state.types == [] or state.callbacks == [] do + Process.sleep(500) + wait_until_indexed(pid) + end + end +end diff --git a/apps/language_server/test/support/fixtures/workspace_symbols.ex b/apps/language_server/test/support/fixtures/workspace_symbols.ex new file mode 100644 index 000000000..a9eba566f --- /dev/null +++ b/apps/language_server/test/support/fixtures/workspace_symbols.ex @@ -0,0 +1,10 @@ +defmodule ElixirLS.LanguageServer.Fixtures.WorkspaceSymbols do + def some_function(a), do: a + defmacro some_macro(a), do: Macro.expand(a, __CALLER__) + + @callback some_callback(integer) :: atom + @callback some_macrocallback(integer) :: Macro.t() + + @type some_type :: atom + @type some_opaque_type :: atom +end