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

feat: add hover language feature for modules and module function calls #104

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Still in heavy development, currently supporting the following features:
- Workspace Symbols
- Document Symbols
- Go To Definition
- Hover

## Editor Support

Expand Down
17 changes: 16 additions & 1 deletion lib/next_ls.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ defmodule NextLS do
TextDocumentDocumentSymbol,
TextDocumentDefinition,
TextDocumentFormatting,
TextDocumentHover,
WorkspaceSymbol
}

Expand Down Expand Up @@ -105,7 +106,8 @@ defmodule NextLS do
document_formatting_provider: true,
workspace_symbol_provider: true,
document_symbol_provider: true,
definition_provider: true
definition_provider: true,
hover_provider: true
},
server_info: %{name: "NextLS"}
}, assign(lsp, root_uri: root_uri)}
Expand Down Expand Up @@ -159,6 +161,19 @@ defmodule NextLS do
{:reply, symbols, lsp}
end

def handle_request(%TextDocumentHover{params: %{text_document: %{uri: uri}, position: position}}, lsp) do
hover =
try do
NextLS.Hover.fetch(lsp, lsp.assigns.documents[uri], position)
rescue
e ->
GenLSP.error(lsp, Exception.format_banner(:error, e, __STACKTRACE__))
nil
end

{:reply, hover, lsp}
end

def handle_request(%WorkspaceSymbol{params: %{query: query}}, lsp) do
filter = fn sym ->
if query == "" do
Expand Down
190 changes: 190 additions & 0 deletions lib/next_ls/hover.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
defmodule NextLS.Hover do
alias GenLSP.Structures.{
Hover,
MarkupContent,
Position,
Range
}

alias NextLS.Runtime

@spec fetch(lsp :: GenLSP.LSP.t(), document :: [String.t()], position :: Position.t()) :: Hover.t() | nil
def fetch(lsp, document, position) do
with {module, function, range} <- get_surround_context(document, position),
docs when is_binary(docs) <- fetch_docs(lsp, document, module, function) do
%Hover{
contents: %MarkupContent{
kind: "markdown",
value: docs
},
range: range
}
end
end

defp get_surround_context(document, position) do
hover_line = position.line + 1
hover_column = position.character + 1

case Code.Fragment.surround_context(Enum.join(document, "\n"), {hover_line, hover_column}) do
%{context: {:dot, {:alias, module}, function}} = context ->
{to_module(module), to_atom(function), build_range(context)}

%{context: {:dot, {:unquoted_atom, erlang_module}, function}} = context ->
{to_atom(erlang_module), to_atom(function), build_range(context)}

%{context: {context_type, module}} = context when context_type in [:alias, :struct] ->
{to_module(module), nil, build_range(context)}

%{context: {:unquoted_atom, erlang_module}} = context ->
{to_atom(erlang_module), nil, build_range(context)}

%{context: {context_type, function}} = context when context_type in [:local_call, :local_or_var] ->
{nil, to_atom(function), build_range(context)}

_other ->
nil
end
end

defp fetch_docs(lsp, document, module, nil) do
with {:ok, {_, _, _, _, docs, _, _} = docs_v1} <- request_docs(lsp, document, module) do
print_doc(module, nil, docs, docs_v1)
end
end

defp fetch_docs(lsp, document, nil, function) do
[Kernel, Kernel.SpecialForms]
|> Stream.map(&fetch_docs(lsp, document, &1, function))
|> Enum.find(&(!is_nil(&1)))
end

defp fetch_docs(lsp, document, module, function) do
with {:ok, {_, _, _, _, _, _, functions_docs} = docs_v1} <- request_docs(lsp, document, module),
{_, _, _, function_docs, _} <- find_function_docs(functions_docs, function) do
print_doc(module, function, function_docs, docs_v1)
end
end

defp request_docs(lsp, document, module, attempt \\ 1) do
case send_fetch_docs_request(lsp, module) do
{:error, :not_ready} ->
nil

{:ok, {:error, :module_not_found}} ->
if attempt < 2 do
aliased_module = find_aliased_module(document, module)

if aliased_module do
request_docs(lsp, document, from_quoted_module_to_module(aliased_module), attempt + 1)
end
end

other ->
other
end
end

defp send_fetch_docs_request(lsp, module) do
Runtime.call(lsp.assigns.runtime, {Code, :fetch_docs, [module]})
end

defp find_aliased_module(document, module) do
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is potentially better to extend tracer and collect all aliases instead of analyzing it when hovering. Let me know if this is a blocker and needs to be rearranged

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My intuition for how to implement this feature was going to be using the reference table to find the right thing and then look up the docs (or, just stick the docs in the symbol table), but i did have concerns about being able to lookup docs for code you've written but hasn't compiled yet, which using your implementation would solve for.

I'll probably get time tonight to review this, but even if we want to use the ref and symbol tables, we can probably merge this and change it later.

Copy link
Contributor Author

@sineed sineed Jul 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds a better idea. My approach doesn't support imported functions which you can find quite often in files using Phoenix.LiveView and Phoenix.Controller. But the reference table has this info OOTB.
As for the code not yet compiled I don't see it a big problem as long as user needs to just save the file. If you don't agree there may be two steps:

  1. It checks references table
  2. If nothing found it uses Code.Fragment.surround_context/2

I'm moving it back to draft but waiting for your feedback

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think moving to the reference table is a good idea, so you can move forward with that if you want.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not quite satisfied with results: only 6 out of 15 assertions that I added for the hovering test were passed. I may need to extend code in Tracer a bit

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could make it feel at least "ok" using two-step approach. Data from reference may not produce good results. For example, I was lucky using Atom.to_string/1 in my example code as there is no reference for such function (there is a reference but for the :erlang.atom_to_binary function).

I also found that there is no event when Erlang module is being referenced. There may be a workaround for it when Erlang function is found though.

I also tried to register references for aliases but it doesn't work well for multi aliases. For example this

  alias Bar.{
    Fiz,
    Baz
  }

Will produce two events with the same line and column information (both start and end). In such case it is problematic to decide what reference to use without additional code around it.

I have only one good news that now imported functions are supported when hovering.

One more thing to note: Erlang docs are not in the build unless the KERL_BUILD_DOCS=yes is set before compiling the source.

module = to_quoted_module(module)

ast =
document
|> Enum.join("\n")
|> Code.string_to_quoted(
unescape: false,
token_metadata: true,
columns: true
)

{_ast, aliased_module} =
Macro.prewalk(ast, nil, fn
# alias A, as: B
{:alias, _, [{:__aliases__, _, aliased_module}, [as: {:__aliases__, _, ^module}]]} = expr, _ ->
{expr, aliased_module}

# alias A.{B, C}
{:alias, _, [{{:., _, [{:__aliases__, _, namespace}, :{}]}, _, aliases}]} = expr, acc ->
aliases = Enum.map(aliases, fn {:__aliases__, _, md} -> md end)

if module in aliases do
{expr, namespace ++ module}
else
{expr, acc}
end

# alias A.B.C
{:alias, _, [{:__aliases__, _, aliased_module}]} = expr, acc ->
offset = length(aliased_module) - length(module)

if Enum.slice(aliased_module, offset..-1) == module do
{expr, aliased_module}
else
{expr, acc}
end

expr, acc ->
{expr, acc}
end)

aliased_module
end

defp build_range(%{begin: {line, start}, end: {_, finish}}) do
%Range{
start: %Position{line: line - 1, character: start - 1},
end: %Position{line: line - 1, character: finish - 1}
}
end

defp from_quoted_module_to_module(quoted_module) do
quoted_module
|> Enum.map(&Atom.to_string/1)
|> Enum.join(".")
|> to_charlist()
|> to_module()
end

defp to_module(charlist) when is_list(charlist) do
String.to_atom("Elixir." <> to_string(charlist))
end

defp to_atom(charlist) when is_list(charlist) do
charlist |> to_string() |> String.to_atom()
end

defp to_quoted_module(module) when is_atom(module) do
module
|> Atom.to_string()
|> String.split(".")
|> List.delete_at(0)
|> Enum.map(&String.to_atom/1)
end

defp find_function_docs(docs, function) do
Enum.find(docs, fn
{{type, ^function, _}, _, _, _, _} when type in [:function, :macro] -> true
_ -> false
end)
end

defp print_doc(_module, _function, :none, _docs_v1) do
nil
end

defp print_doc(_module, _function, doc, {_, _, _, "text/markdown", _, _, _}) do
doc["en"]
end

defp print_doc(module, nil, _doc, {_, _, :erlang, "application/erlang+html", _, _, _} = docs_v1) do
:shell_docs.render(module, docs_v1, %{ansi: false}) |> Enum.join()
end

defp print_doc(module, function, _doc, {_, _, :erlang, "application/erlang+html", _, _, _} = docs_v1) do
:shell_docs.render(module, function, docs_v1, %{ansi: false}) |> Enum.join()
end
end
Loading