Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add workspaceSymbol capability #110

Merged
merged 13 commits into from
Feb 13, 2020
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 current workspace, all dependances 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 improove serch results relevance.
axelson marked this conversation as resolved.
Show resolved Hide resolved

Use the following rules when navigating to workspace symbols:
* no prefix - serch for modules
axelson marked this conversation as resolved.
Show resolved Hide resolved
* `: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:
Expand All @@ -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

Expand Down
139 changes: 103 additions & 36 deletions apps/language_server/lib/language_server/providers/workspace_symbols.ex
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do
lukaszsamson marked this conversation as resolved.
Show resolved Hide resolved
lukaszsamson marked this conversation as resolved.
Show resolved Hide resolved
@moduledoc """
Workspace Symbols provider. Generates and returns `SymbolInfo[]`.
Workspace Symbols provider. Generates and returns `SymbolInformation[]`.

https://microsoft.github.io//language-server-protocol/specifications/specification-3-14/#workspace_symbol
https://microsoft.github.io/language-server-protocol/specifications/specification-3-15/#workspace_symbol
"""
use GenServer

Expand All @@ -11,6 +11,8 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do
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
Expand Down Expand Up @@ -90,9 +92,14 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do
{:ok,
%{
modules: [],
modules_indexed: false,
types: [],
types_indexed: false,
callbacks: [],
callbacks_indexed: false,
functions: [],
functions_indexed: false,
indexing: false,
modified_uris: []
}}
end
Expand All @@ -108,12 +115,22 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do
end

@impl GenServer
def handle_cast(:build_complete, %{modified_uris: []} = state) do
# 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()
|> chunk_by_schedulers(fn chunk ->
|> process_chunked(fn chunk ->
for {module, beam_file} <- chunk,
path = find_module_path(module, beam_file),
path != nil,
Expand All @@ -124,16 +141,23 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do

index(module_paths)

{:noreply, state}
{:noreply, %{state | indexing: true}}
end

@impl GenServer
def handle_cast(:build_complete, %{modified_uris: modified_uris} = state) do
# 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()
|> chunk_by_schedulers(fn chunk ->
|> process_chunked(fn chunk ->
for {module, beam_file} <- chunk,
path = find_module_path(module, beam_file),
SourceFile.path_to_uri(path) in modified_uris,
Expand Down Expand Up @@ -167,29 +191,42 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do
%{
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 =
if state.modules == [] or state.types == [] or state.callbacks == [] or
state.functions == [] do
state
else
%{state | modified_uris: uris ++ state.modified_uris}
end
state = %{state | modified_uris: uris ++ state.modified_uris}

{:noreply, state}
end

@impl GenServer
def handle_info({:results, key, results}, state) do
{:noreply, state |> Map.put(key, results ++ state[key])}
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
Expand Down Expand Up @@ -235,12 +272,10 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do
end
end

defp get_score(item, query) do
defp get_score(item, query, query_downcase, query_length, arity_suffix) do
item_downcase = String.downcase(item)
query_downcase = String.downcase(query)

parts = item |> String.split(".")
arity_suffix = Regex.run(~r/\/\d+$/, query)

cond do
# searching for an erlang module but item is an Elixir module
Expand All @@ -259,24 +294,40 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do
arity_suffix != nil and not String.ends_with?(item, arity_suffix) ->
0.0

length(parts) > 1 and Enum.at(parts, -1) |> String.contains?(query) ->
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() |> String.contains?(query_downcase) ->
Enum.at(parts, -1)
|> String.downcase()
|> exact_or_contains?(query_downcase, query_length) ->
1.8

String.contains?(item, query) ->
exact_or_contains?(item, query, query_length) ->
1.3

String.contains?(item_downcase, query_downcase) ->
exact_or_contains?(item_downcase, query_downcase, query_length) ->
1.2

true ->
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)
Expand Down Expand Up @@ -311,9 +362,11 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do
end

defp index(module_paths) do
chunked_module_paths = chunk_by_schedulers(module_paths)

index_async(:modules, fn ->
module_paths
|> chunk_by_schedulers(fn chunk ->
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)
Expand All @@ -322,8 +375,8 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do
end)

index_async(:functions, fn ->
module_paths
|> chunk_by_schedulers(fn chunk ->
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})
Expand All @@ -335,8 +388,8 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do
end)

index_async(:types, fn ->
module_paths
|> chunk_by_schedulers(fn chunk ->
chunked_module_paths
|> do_process_chunked(fn chunk ->
for {module, path} <- chunk,
# TODO: Don't call into here directly
{kind, {type, type_ast, args}} <-
Expand All @@ -354,8 +407,8 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do
end)

index_async(:callbacks, fn ->
module_paths
|> chunk_by_schedulers(fn chunk ->
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
Expand All @@ -375,7 +428,7 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do
Task.start_link(fn ->
results = fun.()

send(self, {:results, key, results})
send(self, {:indexing_complete, key, results})

JsonRpc.log_message(
:info,
Expand All @@ -386,24 +439,38 @@ defmodule ElixirLS.LanguageServer.Providers.WorkspaceSymbols do

@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)
|> chunk_by_schedulers(fn chunk ->
|> process_chunked(fn chunk ->
chunk
|> Enum.map(&{&1, get_score(&1.name, query)})
|> 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, fun) do
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)
Expand Down
Loading