Erlang LS指南(五)

如何进行诊断

几天前,NextRoll 发布了 rebar3_hank,这是一个“强大而简单的工具,可以检测 Erlang 代码库周围的死代码”。在他们的原始帖子中,作者提到了 rebar3_hank 和 Erlang LS 提供的一些功能之间的重叠,例如检测未使用的包含文件。

对这个新工具很感兴趣,我决定深入研究它,检查 rebar3_hank 是否可以与 Erlang LS 中的诊断框架集成,以避免在 Erlang 社区内重复工作。

rebar3_hank 和 Erlang LS 都基于源代码创建诊断,但有一些区别。例如,rebar3_hank 作用于整个项目的代码库,而 Erlang LS 作用于单个 Erlang 模块及其严格的依赖关系。此外,rebar3_hank 旨在通过 rebar3 插件用作 CLI,而 Erlang LS 是通过 LSP 协议与您的 IDE 集成的服务器。

很明显,两个工具之间的集成需要一定程度的重构。不过,rebar3_hank 的想法非常有趣,其中大部分都可以在 Erlang LS 中轻松实现。因此,我决定移植其中一个,检测未使用的宏,并借此机会解释为 Erlang LS 贡献一个新的诊断后端的过程。

目标

给定一个 Erlang 模块,如果定义了宏但未使用,我们希望收到警告通知。

001

我们怎样才能做到这一点?

添加新的诊断后端

我们需要做的第一件事是定义一个新的 Erlang 模块来实现 els_diagnostics 行为。 按照惯例,所有诊断模块都命名为 els_[BACKEND]_diagnostics.erl,在我们的例子中,[BACKEND] 将是未使用的宏。 因此,模块的最终名称将是 els_unused_macros_diagnostics.erl。 让我们打开一个新的文本文件并添加以下内容:

-module(els_unused_macros_diagnostics).
-behaviour(els_diagnostics).

els_diagnostics 行为需要实现三个回调函数:

-callback is_default() -> boolean().
-callback source()     -> binary().
-callback run(uri())   -> [diagnostic()].

因此,让我们将以下导出添加到模块中。 我们将在一秒钟内实现所有功能。

-export([ is_default/0
        , source/0
        , run/1
        ]).

回调函数:is_default/0

is_default/0 回调用于指定是否应默认启用当前后端。 在我们的例子中,我们希望默认启用新的后端,所以我们说:

is_default() -> true.

如果最终用户决定禁用此后端,她可以在她的 erlang_ls.config 中添加以下选项:

diagnostics:
  disabled:
    unused_macros

回调函数:sources/0

现在让我们实现我们的第二个回调函数,source/0。 此函数返回后端的人性化名称,由 IDE 呈现(请参见上面屏幕截图中的 UnusedMacros 文本):

source() -> <<"UnusedMacros">>.

run/1函数

我们需要实现的最后一个回调函数是有趣的事情发生的地方。

run/1 函数采用单个参数,即运行诊断的 Erlang 模块的 Uri。 默认情况下,诊断计算是 OnOpen(当模块首次在 IDE 中访问时)和 OnSave(当模块从 IDE 中保存时)。

该函数以 LSP 协议指定的格式返回将由 IDE 呈现的模块的诊断列表。 诊断可以有四种类型:

  • Hint
  • Info
  • Warning
  • Error

暂时,让我们返回一个空列表。

run(_Uri) -> [].

注册后端

在我们可以在 Erlang LS 中使用我们的后端之前,我们还需要做一件事。 我们需要在可用的后端中注册它。 我们可以通过在 els_diagnostics 模块的可用诊断列表中添加一个条目来做到这一点:

available_diagnostics() ->
  [ <<"compiler">>
  , <<"crossref">>
  , <<"dialyzer">>
  , <<"elvis">>
  , <<"unused_includes">>
  , <<"unused_macros">> %% Here is our new shiny diagnostics backend!
].

使用测试驱动开发 (TDD) 方法

此时,我们可以开始执行 run/1 函数的主体。 然后,为了尝试事情是否按预期工作,我们可以:

  • 将我们的 Erlang LS 版本重建为 escript
  • 在我们的 IDE 中打开一个新文件
  • 重启 Erlang LS
  • 在我们的新文件中添加一个未使用的宏
  • 保存文件
  • 确保在保存时产生警告
  • 检查 Erlang LS 日志,看看为什么事情没有按我们预期的方式工作
  • 清除并重复

这种方法效果甚微,它会大大减慢我们的反馈循环,并且会使为 Erlang LS 做出贡献的整个体验变得痛苦。

幸运的是,有一个更好的方法:从测试用例开始。 毕竟,如果我们打算贡献我们的新后端,无论如何我们都需要添加一个测试用例。

但是,我们如何为这样的功能实现测试用例呢? 这听起来像很多工作,我们不知道从哪里开始。 这就是 Erlang LS 测试框架发挥作用的地方。

我们需要做的第一件事是向 Erlang LS 测试应用程序添加一个最小示例(由于历史原因,它被命名为 code_navigation,应该重命名)。 您可以在项目的 priv 目录中找到它。

我们的最小示例可能如下所示:

$ cat priv/code_navigation/src/diagnostics_unused_macros.erl
-module(diagnostics_unused_macros).

-export([main/0]).

-define(USED_MACRO, used_macro).
-define(UNUSED_MACRO, unused_macro).

main() ->
?USED_MACRO.

在上面的代码中,我们定义了两个宏,我们只使用了其中一个。 因此,我们希望在第 6 行发出UNUSED_MACRO警告。

我们还需要将我们的最小示例注册到 Erlang LS 测试框架中。 为此,我们在 els_test_utils 模块的源列表中添加一行:

sources() ->
  [ ...
  , diagnostics_unused_macros
  , ...
  ]

现在,我们可以专注于实际的测试用例。 由于它是一个诊断测试,我们可以扩展已经存在的 els_diagnostics_SUITE 模块。 测试套件利用了 Erlang/OTP 中的 Common Test 框架,如果不熟悉请参考官方文档。

首先,我们导出新的测试用例,我们称之为unused_macros:

-export([ ...
        , unused_macros/1
        . ...
        ])

然后,我们可以实现新测试用例的主体:

unused_macros(Config) ->
  Uri = ?config(diagnostics_unused_macros_uri, Config),
  els_mock_diagnostics:subscribe(),
  ok = els_client:did_save(Uri),
  Diagnostics = els_mock_diagnostics:wait_until_complete(),
  Expected = [ #{ message => <<"Unused macro: UNUSED_MACRO">>
                , range =>
                    #{ 'end' => #{ character => 20
                                 , line => 5
                                 }
                     , start => #{ character => 8
                                 , line => 5
                                 }
                     }
                , severity => ?DIAGNOSTIC_WARNING
                , source => <<"UnusedMacros">>
                }
             ],
  ?assertEqual(Expected, Diagnostics),
  ok.

这里发生了很多事情,所以让我们一起来看代码,从头开始:

Uri = ?config(diagnostics_unused_macros_uri, Config),

在这里,我们从 Common Test Config 中获取包含我们最小示例的 Erlang 模块的 Uri。 这感觉有点神奇,因为我们从未在任何地方填充该变量。 这是怎么回事?

还记得我们在上面的 sources/1 函数中注册了我们的新测试模块吗? 这导致 Erlang LS 测试框架不仅索引该文件,还创建了几个方便的变量,可以在编写测试用例时使用。 [MODULE_NAME]_uri 是这些变量之一。 另一个是 [MODULE_NAME]_text,其中包含模块的实际源代码。 但是现在让我们继续我们的测试用例:

els_mock_diagnostics:subscribe(),

由于我们想测试一个新的诊断后端,我们订阅诊断流,以便我们可以拦截和验证它们。 同样,我们使用 Erlang LS 测试框架提供的实用功能。

ok = els_client:did_save(Uri),
Diagnostics = els_mock_diagnostics:wait_until_complete(),

然后,我们模拟 IDE 保存文件并等待诊断(异步计算)完成。

Expected = [ #{ message => <<"Unused macro: UNUSED_MACRO">>
              , range =>
                  #{ 'end' => #{ character => 20
                               , line => 5
                               }
                   , start => #{ character => 8
                               , line => 5
                               }
                   }
              , severity => ?DIAGNOSTIC_WARNING
              , source => <<"UnusedMacros">>
              }
           ],
?assertEqual(Expected, Diagnostics),
ok.

在这里,我们验证我们的后端是否生成了一条警告消息(注意 source 属性)。 警告消息预计在第 6 行,字符 8 和 20 之间(对应于宏名称的位置)。

?DIAGNOSTIC_WARNING 宏定义在 erlang_ls.hrl 头文件中,所以让我们将它包含在导出列表下方:

-include("erlang_ls.hrl").

现在让我们执行我们的测试用例并检查结果。 请注意我们需要如何为测试用例指定一个组,因为所有 Erlang LS 测试都可以针对两个 LSP 支持的传输(TCP 和 stdio)运行。

$ rebar3 ct --suite els_diagnostics_SUITE --case unused_macros --group tcp
===> Verifying dependencies...
===> Analyzing applications...
===> Compiling erlang_ls
===> Running Common Test suites...
%%% els_diagnostics_SUITE:
[...]
expected: [#{message => <<"Unused macro: UNUSED_MACRO">>,
             range =>
               #{'end' => #{character => 20,line => 5},
                 start => #{character => 8,line => 5}},
             severity => 2,source => <<"UnusedMacros">>}]
got: []
line: 582

不出所料,测试用例失败了,因为我们还没有实现 run/1 函数,但是我们返回了一个硬编码的空列表。 让我们现在解决这个问题。

查找未使用的宏

我们终于可以跳到本教程的有趣部分,实现未使用宏的检测机制。 像往常一样,让我们先看看整个代码,然后解释它的行为。

run(Uri) ->
  {ok, [Document]} = els_dt_document:lookup(Uri),
  UnusedMacros = find_unused_macros(Document),
  [make_diagnostic(Macro) || Macro <- UnusedMacros].

首先,我们通过 Uri 查询 Erlang LS 数据库。 然后,我们在返回的文档上调用 find_unused_macros/1 函数——我们仍然需要实现它。 对于每个已识别的宏,我们以 LSP 协议预期的格式生成诊断。 同样,我们仍然需要实现我们的 make_diagnostic/1 函数。

现在让我们关注 find_unused_macros/1 函数。 该函数的目标是识别给定文档中未使用的宏:

find_unused_macros(Document) ->
  Definitions = els_dt_document:pois(Document, [define]),
  Usages = els_dt_document:pois(Document, [macro]),
  UsagesIds = [Id || #{id := Id} <- Usages],
  [POI || #{id := Id} = POI <- Definitions, not lists:member(Id, UsagesIds)].

同样,这里发生了很多事情,所以让我们逐行浏览代码。

首先,我们识别所有的宏定义,由define 键识别:

Definitions = els_dt_document:pois(Document, [define]),

然后,我们识别所有的宏用法,通过macro key识别:

Usages = els_dt_document:pois(Document, [macro]),

您可以参考 Erlang LS 中的 els_parser 模块,了解有关 POI(兴趣点)和可用键的详细信息。

对于每个宏用法,我们提取各自的 id 并返回没有相应用法的宏定义列表:

 UsagesIds = [Id || #{id := Id} <- Usages],
  [POI || #{id := Id} = POI <- Definitions, not lists:member(Id, UsagesIds)].

最后缺少的位是 make_diagnostic/1 函数,它将每个 POI 转换为诊断:

make_diagnostic(#{id := Id, range := POIRange} = _POI) ->
  Range = els_protocol:range(POIRange),
  MacroName = atom_to_binary(Id, utf8),
  Message = <<"Unused macro: ", MacroName/binary>>,
  Severity = ?DIAGNOSTIC_WARNING,
  Source = source(),
  els_diagnostics:make_diagnostic(Range, Message, Severity, Source).

该函数本质上是实用函数 els_diagnostics:make_diagnostic/4 的包装。 让我们详细分析一下。

  Range = els_protocol:range(POIRange),

这里我们使用 Erlang LS 提供的辅助函数将 POI 的范围(未使用的宏定义)转换为 LSP 协议所需的格式。

 MacroName = atom_to_binary(Id, utf8),
  Message = <<"Unused macro: ", MacroName/binary>>,

然后,我们使用有问题的宏的 id(名称)构建诊断消息。

 Severity = ?DIAGNOSTIC_WARNING,

我们将指定为消息的严重程度为警告。

Source = source(),

我们调用 source/0 函数来指定诊断的来源(用于 IDE 中的渲染目的)。

els_diagnostics:make_diagnostic(Range, Message, Severity, Source).

最后,我们使用构造的参数调用 els_diagnostics:make_diagnostic/4 函数来生成诊断。

应该是这样的。 让我们再次尝试执行测试用例:

$ rebar3 ct --suite els_diagnostics_SUITE --case unused_macros --group tcp
===> Verifying dependencies...
===> Analyzing applications...
===> Compiling erlang_ls
===> Running Common Test suites...
%%% els_diagnostics_SUITE:
All 1 tests passed.

成功! 现在测试通过了,我们能够识别 Erlang LS 中未使用的宏!

完整的后端

下面是后端的完整实现,供参考:

-module(els_unused_macros_diagnostics).
-behaviour(els_diagnostics).

-export([ is_default/0
, source/0
, run/1
]).

-include("erlang_ls.hrl").

is_default() -> true.

source() -> <<"UnusedMacros">>.

run(Uri) ->
{ok, [Document]} = els_dt_document:lookup(Uri),
UnusedMacros = find_unused_macros(Document),
[make_diagnostic(Macro) || Macro <- UnusedMacros].

find_unused_macros(Document) ->
Definitions = els_dt_document:pois(Document, [define]),
Usages = els_dt_document:pois(Document, [macro]),
UsagesIds = [Id || #{id := Id} <- Usages],
[POI || #{id := Id} = POI <- Definitions, not lists:member(Id, UsagesIds)].

make_diagnostic(#{id := Id, range := POIRange} = _POI) ->
Range = els_protocol:range(POIRange),
MacroName = atom_to_binary(Id, utf8),
Message = <<"Unused macro: ", MacroName/binary>>,
Severity = ?DIAGNOSTIC_WARNING,
Source = source(),
els_diagnostics:make_diagnostic(Range, Message, Severity, Source).

当然,上面的实现非常简单,可以在很多方面进行改进,但是此时您应该了解为 Erlang LS 实现新的诊断后端意味着什么。

结论

上面的后端已经集成在 Erlang LS 中,但作为一个可选的后端。 您可以在以下位置查看全部贡献:

为开源做贡献可能是一种令人生畏的经历,尤其是当您没有无限的可用时间时。 我希望这个小教程可以帮助你朝这个方向发展,我期待着你能为 Erlang LS 做出贡献。

玩得开心!

Related Posts

2021 年你需要知道的关于 Erlang 的一切

今天,我们将看一个相当古老且有些古怪的东西。 你们大多数人可能没有注意到的语言。 虽然 Erlang 不像某些现代编程语言那样流行,但它安静地运行着 WhatsApp 和微信等每天为大量用户提供服务的应用程序。 在这篇文章中,我将告诉你关于这门语言的更多事情、它的历史,以及你是否应该考虑自己学习它。 ## 什么是 Erlang,它在哪里使用? Erl

Read More

Erlang JIT中基于类型的优化

这篇文章探讨了 Erlang/OTP 25 中基于类型的新优化,其中编译器将类型信息嵌入到 BEAM 文件中,以帮助JIT(即时编译器)生成更好的代码。 ## 两全其美 OTP 22 中引入的基于SSA的编译器处理步骤进行了复杂的类型分析,允许进行更多优化和更好的生成代码。然而,Erlang 编译器可以做什么样的优化是有限制的,因为 BEAM 文件必须

Read More

Erlang JIT之路

自从Erlang 存在,就一直有让它更快的需求和野心。这篇博文是一堂历史课,概述了主要的 Erlang 实现以及如何尝试提高 Erlang 的性能。 ## Prolog 解释器 Erlang 的第一个版本是在 1986 年在 Prolog 中实现的。那个版本的 Erlang 对于创建真正的应用程序来说太慢了,但它对于找出Erlang语言的哪些功能有用,哪

Read More