How To: Code Lenses
In our previous tutorial we learned how to implement a diagnostics backend for the Erlang Language Server. This time we will dig into the world of Code Lenses.
The Goal
Given an Erlang module containing a number of function defintions, we want to display the number of references to each function above its respective definition. Here is how the code lens will look like in VS Code.
At the end of this tutorial you will:
- Know what a code lens is
- Learn how to implement a code lens in Erlang LS
- Move a step closer to becoming an Erlang LS contributor
Without further ado, let's start.
What is a Code Lens, anyway?
A Code Lens is defined by Wade Anderson as:
an actionable contextual information interspersed in your source code
That's a very fancy way to say that a code lens is an arbitrary piece of text which appears in the IDE, next to your code. The text often provides insights about a portion of the code, as in the example we just saw above.
Code lenses can also be actionable. The user can activate a lens by clicking on it or by using a keyboard shortcut, to perform an action. The triggered action can be anything. Here is an Emacs code lens which allows the user to execute a given Common Test testcase:
Code lenses are contextual, meaning that they are aware of the surrounding context. In the above example, the Run test lens is aware of which specific testcase should be executed on click.
Now that we understand what a code lens is, let's implement one in Erlang LS.
Implementing a New Code Lens Backend
Erlang LS provides a framework to make development of code lenses as
simple as possible. To create our new code lens, the first thing we
need to do is to decide a name for it and to create a new Erlang
module implementing the els_code_lens
behaviour. Let's call the new
code lens function_references.
-module(els_code_lens_function_references).
-behaviour(els_code_lens).
The els_code_lens
behaviour requires three callback functions to
be implemented:
-callback is_default() -> boolean().
-callback pois(els_dt_document:item()) -> [poi()].
-callback command(els_dt_document:item(), poi(), state()) -> els_command:command().
We will see in a second what each callback function is supposed to
do. For now, let's add the following exports to our
els_code_lens_function_references
module:
-export([ is_default/0
, pois/1
, command/3
]).
Now we can focus on each individual callback function.
The is_default/0
callback
The is_default/0
callback is used to specify if the current backend
should be enabled by default or not. In our case, we want the new
backend to be enabled by default, so we say:
is_default() -> true.
Should the end user decide to disable this backend, she can just add
to her erlang_ls.config
the following option:
lenses:
disabled:
function_references
The pois/1
callback
In Erlang LS jargon, POI stands for Point of Interest. The term refers to the interesting bits that are part of a code base. Points of Interest are indexed by Erlang LS and stored in an in-memory database. A POI could refer to a function definition, a macro definition, a record usage, you name it. Erlang LS provides a set of utilities that allow for easy search and manipulation of Points Of Interest.
The pois/1
function takes a single argument, the current
document. Its return value is the list of POIs for which the lens
should be activated for. In our case, we want our lens to be visible
next to each function definition. Therefore, we write:
pois(Document) ->
els_dt_document:pois(Document, [function]).
The command/3
callback
The last mandatory callback we need to implement is the command/3
one. The callback takes three arguments: the current Document, a
specific POI and a State. For the sake of this tutorial, we will
ignore the State
and focus on the first two arguments only.
The Command
The function needs to return a command. A command is an LSP data structure which contains:
- A title - the text which is rendered next to each selected POI
- A CommandId - an identifier for the command that gets executed on click
- The CommandArgs - the list of arguments to pass to the command
Erlang LS provides an helper function to create such a data structure: the els_command:make_command/3
function.
Then, our command/3
function will look something like this:
command(Document, POI, _State) ->
Title = title(Document, POI),
CommandId = command_id(),
CommandArgs = command_args(),
els_command:make_command(Title, CommandId, CommandArgs).
We will now describe each paramater in detail and learn how to compute
them, starting from the Title
.
The Title
The Title
is the text that we want to present in the text editor,
next to our Point of Interest (a.k.a. the POI). In our case, we want
to display the following text:
Used [N] times
To be able to compute the number N
, we need to know how many
references to the current function are spread across our code base. We
can therefore query the Erlang LS database via the
els_dt_references:find_by_id/2
helper function:
title(Document, POI) ->
%% Extract the module name from the current document
#{uri := Uri} = Document,
M = els_uri:module(Uri),
%% Extract the function name and arity from the current POI
#{id := {F, A}} = POI,
%% Query the Erlang LS DB for references to the current function
{ok, References} = els_dt_references:find_by_id(function, {M, F, A}),
%% Calculate the number of references
N = length(References),
%% Format the title for the code lens
unicode:characters_to_binary(io_lib:format("Used ~p times", [N])).
The els_dt_references:find_by_id/2
function takes two arguments: the
Kind
of references we are looking for (function
in our case) and
the fully qualified Id
of the current Point of Interest. For a
function definition, the fully qualified identifier is a {M, F, A}
tuple, representing the Module, the Function Name and the Arity
of our function. As you can see above, we can extract the module M
from the Document
and the F
and A
from the current POI
.
CommandId and CommandArgs
The CommandId
is an arbitrary identifier for the command we want to
run when the user clicks on our code lens. In our case, this action
will be a no-op, but we still need to pick a name for our
command. Let's call it function-references
:
command_id() -> <<"function-references">>.
Since our command will be a no-op (we do not want anything to happen if the user clicks on the lens), our command will not require any arguments:
command_args() -> [].
We are essentially done. Here is our full
els_code_lens_function_references
module, for completeness:
-module(els_code_lens_function_references).
-behaviour(els_code_lens).
-export([ is_default/0
, pois/1
, command/3
]).
is_default() ->
true.
pois(Document) ->
els_dt_document:pois(Document, [function]).
command(Document, POI, _State) ->
Title = title(Document, POI),
CommandId = command_id(),
CommandArgs = command_args(),
els_command:make_command(Title, CommandId, CommandArgs).
title(Document, POI) ->
#{uri := Uri} = Document,
M = els_uri:module(Uri),
#{id := {F, A}} = POI,
{ok, References} = els_dt_references:find_by_id(function, {M, F, A}),
N = length(References),
unicode:characters_to_binary(io_lib:format("Used ~p times", [N])).
command_id() ->
<<"function-references">>.
command_args() ->
[].
Registering the code lens
There is one more thing that we need to do before we can use our new
shiny code lens: we need to tell Erlang LS that it exists. That can be
achieved by adding our new code lens to the list of available_lenses
in the els_code_lens
module:
available_lenses() ->
[ ...
, <<"function-references">>
].
That's all.
Adding tests
Our code lens at this point should be functional, but we cannot be sure until we write a test for it! Erlang LS provides a testing framework which can be used for this purpose. In this section we will assume that you have a bit of familiarity with the Erlang LS testing framework already. If you would like a more gentle introduction to testing in Erlang LS, please refer to the previous Diagnostics Tutorial.
Creating a test module
Let's create a test module named code_lens_function_references
within the code_navigation
test application:
$ cat apps/els_lsp/priv/code_navigation/src/code_lens_function_references.erl
-module(code_lens_function_references).
-export([ a/0 ]).
-spec a() -> ok.
a() ->
b(),
c().
-spec b() -> ok.
b() ->
c().
-spec c() -> ok.
c() ->
ok.
Registering the new testing module
Simply open the els_test_utils
module and add the new module to the
list of sources. This will ensure the new module is properly indexed
and some helper functions are available for it.
sources() ->
[ ...
, code_lens_function_references
, ...
].
Writing a testcase
Let's then open the els_code_lens_SUITE
module and add a testcase,
where we check whether the new code lens works as expected in the new
module.
function_references(Config) ->
Uri = ?config(code_lens_function_references_uri, Config),
#{result := Result} = els_client:document_codelens(Uri),
Expected = [ lens(5, 0) # First lens on line 5, 0 references
, lens(10, 1) # Second lens on line 10, 1 reference
, lens(14, 2) # Third lens on line 14, 2 references
],
?assertEqual(Expected, Result),
ok.
In the above testcase, we are fetching the Uri
of the newly added
test module by leveraging the Erlang LS testing framework. We then use
the els_client
to invoke the document_codelens
method for the
given Uri
and we finally ensure that we receive the expected list of code
lenses. lens/2
is an auxiliary function which constructs the data
structure expected by the LSP protocol as follows:
lens(Line, Usages) ->
Title = unicode:characters_to_binary(
io_lib:format("Used ~p times", [Usages])),
#{ command =>
#{ arguments => []
, command => els_command:with_prefix(<<"function-references">>)
, title => Title
}
, data => []
, range =>
#{ 'end' => #{character => 1, line => Line}
, start => #{character => 0, line => Line}
}
}.
Let's run the test and ensure it passes.
$ rebar3 ct --suite apps/els_lsp/test/els_code_lens_SUITE --case function_references --group tcp
[...]
===> Running Common Test suites...
%%% els_code_lens_SUITE: .
All 1 tests passed.
It looks like we are done here.
Optional Callbacks
Even if they are not needed for this tutorial, it is worth mentioning
that two more optional callback functions are available as part of
the els_code_lens
behaviour:
-callback init(els_dt_document:item()) -> state().
-callback precondition(els_dt_document:item()) -> boolean().
Let's describe them for completeness.
The init/1
callback
The init/1
callback allows us to perform some computation once per
file and to pass around the computed values in the form a State to
subsequent callback functions (remember the State
argument which we
ignored in the command/3
callback?). This is used, for example, in
the suggest_spec
code lens to run TypEr
once for each Erlang
module and to still be able to display one lens for each function.
The precondition/1
callback
The precondition/1
callback allows us to only enable a given lens
for a specific type of documents. For example, the following
implementation enables the ct_run_test
lens only for Common Test
suites, identified by the presence of an include_lib
directive for the
ct.hrl
file:
precondition(Document) ->
Includes = els_dt_document:pois(Document, [include_lib]),
case [POI || #{id := "common_test/include/ct.hrl"} = POI <- Includes] of
[] ->
false;
_ ->
true
end.
Conclusion
At this point you should be able to try out your new code lens. The above code lens is already available in Erlang LS. You can see the whole contribution at:
https://github.com/erlang-ls/erlang_ls/pull/947
I hope this tutorial helped you to get a better understanding about code lenses in general and how to implement one in Erlang LS. Looking forward to the wonderful lenses you will implement!