📚

credoにオプションを追加してspecを自動補完させたい

2023/12/23に公開

前書き

この記事はAkatsuki Games Advent Calendar 2023 23日目の記事です。
22日目の記事はFluffy Mossさんの「UPNG.jsとJS-Interpreterでエモい画像カードを作る」でした。
画像ファイルは画像の管理をするものとしてしか扱った事が無かったのでスクリプトの埋め込みも出来るんだなと勉強になりました。
私のアイコン画像にもエモさを持たせたいなと思いました...!
エモさと言えば、私のアイコンを自慢したいです...
アイコンは私が実際に乗っているBianchi Ariaというロードバイクの画像です。(Bianchiといえばチェレステカラーが有名ですが、Summertime Dreamという比較的最近出たちょっと珍しい色のものに乗ってます)
光の差し具合によって青〜紫に変わるようになっていてすごく綺麗です。
アイコン画像にエモさを感じて頂いたところで本題に入りましょう。

はじめに

皆さん、Elixirの@specアノテーションは利用していますか?
Elixirは動的型付けのプログラミング言語ですが、specで型のアノテーションを入れることにより静的型付け言語のような開発体験を得ることが出来ます。
specを入れると関数の引数に何が入ってくるかすぐに分かったり、dialyzerを噛ませてミスに気付けたり...特に大きなプロダクトコードを触る時に便利でしょう。
ただし、構文上強制されていない以上@specを付けるのを忘れてしまう事もあるかと思います。
そんな時にcredoのCredo.Check.Readability.Specsを有効化したいなと思うことがあるでしょう。
そして試しにプロダクトコードで有効化してみたところissueが数百件...直すの大変だし諦めるか...となっていないでしょうか。
私はつい最近そのようなことがありました。
そこで、この記事ではcredoにspecの自動補完機能を追加しハッピーなクリスマスを迎えることを目指したいと思います。

事前準備 / 環境

利用しているElixirのバージョンは1.15.7-otp-26、Erlangのバージョンは26.1.2です。
credoのバージョンは1.6.7をベースにブランチを切っています。(最新じゃなくてすみません...)

型の解析にはelixirファイルコンパイル後のbeamファイルが必要なので、解析対象のコードをmix compileしておく必要があります。
また、ローカル上で動かしたい場合はmix.exsファイルのdepsに

 [{:credo, path: "to/your_local_path/credo", override: true}]

のような指定をするとローカルのcredoを参照できます

もしくは私のリポジトリを指定して実行することも可能です↓

  [{:credo, git: "https://github.com/yasuno0327/credo.git", branch: "autocorrect-spec"}]

やること

Elixirの静的解析ツールであるcredoに対して自動補完オプションを追加し、credoが検知したCheck.Readability.Specsのissueに対してspecの自動補完をしてみます。
やることを大まかなフェーズに分けると以下のようになります。

  1. credoに自動補完オプションを追加する
  2. issueの内容/コードを解析し、specを生成する
  3. specを対象の関数に挿入する

完成後のコードを見たい場合は私のGithubリポジトリ上に上げていますので、良かったら見てみてください。
PR: https://github.com/yasuno0327/credo/pull/1/files

では、早速それぞれの項目を実装していきましょう。

credoに自動修正オプションを追加する

credoにはissueの自動修正機能がそもそもありません。
自動修正するにはオプションの追加から始める必要があります。
とは言っても、実は既にcredo側でプロポーザルが出ているので、これをベースに実装していけば良さそうです。
本家credoでのgithub issue: https://github.com/rrrene/credo/issues/944
実装のプロポーザルPR: https://github.com/rrrene/credo/pull/985

自動修正オプションの実行までは以下のようなフローになります。

  • mix credo suggest --autofixをshell上で実行
  • 指定されたチェック項目(Credo.Check.Readability.Specs)の実行
  • AST(抽象構文木)を解析し、specが指定されていない関数を探してissue化して構造体へ保存
  • autofixオプションの指定を検知してCredo.CLI.Task.RunAutofixへ
  • 保存されたissueを元に各チェック項目側のモジュールに実装されたautofix関数を実行する

issueの内容/コードを解析し、specを生成する

ここが、この記事の本題です。
どうspecを生成するかなのですが、ちょうど私がエディタ拡張で使っているelixir-lsにはspecを自動補完する機能が備わっています。
elixir-lsの自動補完機能を真似て実装を行えば良さそうです。
elixir-lsではdialyzerの型推論(SuccessTyping)機能をそのまま利用してspecを生成しています。

issue対象のモジュール、関数の情報を取得

まず、自動補完を行うには補完対象のモジュールや関数の情報が必要です。
これはcredoがspecのチェックをする際にCredo.Issueの構造体内に保存してくれているので、autofixの関数に渡してこの構造体の情報を利用します。
また、ファイルにspecアノテーションを挿入するにはファイルの中身の情報も必要になってくるのでIssue構造体内のfile_pathから対象ファイルを割り出して読み込んでおきます。

defmodule Credo.CLI.Task.RunAutofix do
  def call(exec, opts, read_fun \\ &File.read!/1, write_fun \\ &File.write!/2) do
   ...
   issues = Keyword.get_lazy(opts, :issues, fn -> Execution.get_issues(exec) end)

   issues
   |> group_by_file()
   |> Enum.each(fn {file_path, issues} ->
     file = read_fun.(file_path)

    {corrected, _shift} = Enum.reduce(issues, {file, 0}, fn issue, acc ->
      run_autofix(issue, acc, exec)
    end)
  end
  
  defp group_by_file(issues) do
    Enum.reduce(issues, %{}, fn issue, acc ->
      Map.update(acc, issue.filename, [issue], &[issue | &1])
    end)
  end
end

渡したissueには以下のような情報が入っています

%Credo.Issue{
  filename: "filename", # issueが発生したファイルの名前
  line_no: 12, # issueが発生した箇所(行数)
  scope: "Test.Hoge.test_func" # issueが発生した関数
}

このscopeとline_noがissue対象箇所の情報として利用出来そうです。
また、ファイルは対象箇所をline_noから引っ張ってこれるようにsplitしておきます。

# fileはissueが発生したファイルをFile.read!/1したもの
file_each_lines = String.split(file, "\n")

また、今後型の解析で利用するので対象モジュールのbeamファイルの場所も探しておきます。

defp target_modules do
  beam_wildcard = "**/*.beam"
  build_path = Mix.Project.build_path()
  [build_path, beam_wildcard]
  |> Path.join()
  |> Path.wildcard()
  |> Enum.reduce(%{}, fn beam_path, acc ->
    mod =
      beam_path
      |> Path.basename(".beam")
      |> String.to_atom
    Map.put(acc, mod, to_charlist(beam_path))
  end)
end

# target_modules[module]で対象moduleのbeamファイルへのパスを取得出来る

PLT(Persistent Lookup Table)の作成

spec情報を生成するためにdialyzerの機能を使うのでPLTを作成する必要があります。
PLTはdialyzerが利用する解析結果を溜め込んだファイルであり、解析を開始するにはelixirやerlangが最初から持っている基本的なパッケージ情報を解析し、PLTファイルを初期化する必要があります。
参考にしているのは、elixir-lspのこちらの実装です: https://github.com/elixir-lsp/elixir-ls/blob/master/apps/language_server/lib/language_server/dialyzer/manifest.ex

defmodule Credo.Check.Readability.Specs.Plt do
  @elixir_apps [:elixir, :ex_unit, :mix, :iex, :logger, :eex]
  @erlang_apps [:erts, :kernel, :stdlib, :compiler]
  
  defp build_plt do
    IO.puts("Building PLT...")
    modules_to_paths =
      for app <- @erlang_apps ++ @elixir_apps,
        path <- beam_paths(app),
        into: %{},
        do: {pathname_to_module(path), String.to_charlist(path)}

    modules =
      modules_to_paths
      |> Map.keys()
      |> expand_references()

    files =
      for mod <- modules,
      path = modules_to_paths[mod] || get_beam_file(mod),
      is_list(path),
      do: path

    plt_path()
    |> Path.dirname()
    |> File.mkdir_p!()

    plt_path_of_charlist = plt_path() |> to_charlist()

    :dialyzer.run(
      analysis_type: :plt_build,
      files: files,
      from: :byte_code,
      output_plt: plt_path_of_charlist
    )

    :dialyzer_cplt.from_file(plt_path_of_charlist)
  end
end

ここでやっていることは、インストール済みの:mixや:ex_unitのbeamファイルを取得し、:dialyzer.runで渡したbeamファイルを解析、pltファイルをoutput_pltに生成しています。
そして、:dialyzer_cplt.from_fileでpltの情報をファイルから引っ張ってきて呼び出し元に返します。
このpltの情報を更新/利用して型の推論を行います。

Success Typingを利用した型推論処理

さて、ここまででようやく事前準備が終わりました。
ここからはspecの補完に向けた型推論処理をしていきます。
(と言っても、dialyzerのotpを叩くだけなんですけどね...)
ここまででPLTファイルは初期情報しか入っていないので、解析対象のモジュールの情報を渡して更新する必要があります。
先程のtarget_modulesから取得したbeam_fileを渡してPLTを更新します。

defmodule Credo.Check.Readability.Specs.Plt do
  require Record
  
  Record.defrecordp(
    :analysis_26,
    :analysis,
    analysis_pid: :undefined,
    type: :succ_typings,
    defines: [],
    doc_plt: :undefined,
    files: [],
    include_dirs: [],
    start_from: :byte_code,
    plt: :undefined,
    use_contracts: true,
    behaviours_chk: false,
    timing: false,
    timing_server: :none,
    callgraph_file: [],
    mod_deps_file: [],
    solvers: :undefined
  )
  
  def analyze_file(active_plt, file) do
    analysis_config = analysis_26(plt: active_plt, files: [file], solvers: [])
    parent = self()

    pid =
      spawn_link(fn ->
        :dialyzer_analysis_callgraph.start(parent, @default_warns, analysis_config)
      end)

    main_loop(pid)
  end
end

defp main_loop(backend_pid) do
  receive do
    {^backend_pid, :done, new_plt, _new_doc_plt} ->
      new_plt

    {:EXIT, ^backend_pid, {:error, reason}} ->
      IO.inspect(reason)

    {:EXIT, ^backend_pid, reason} when reason != :normal ->
      IO.inspect(reason)

    _ ->
      main_loop(backend_pid)
  end
end

:dialyzer_analysis_callgraph.startを使うことによって、beamファイルを解析し対象モジュールの関数の情報が入ったplt情報が返ってきます。
今回は更新後のplt情報だけ必要で、その中の型推論情報を取得します。

defmodule Credo.Check.Readability.Specs.SuccessTyping do
  def suggest(active_plt, module, function, line_no) do
    for {{mod, fun, arity}, success_typing} <- success_typings(active_plt, module) do
      line = find_function_line(mod, fun, arity)
      {{mod, fun, arity}, line, success_typing}
    end
    |> find_target(module, function, line_no)
  end

  defp success_typings(plt, module) do
    case :dialyzer_plt.lookup_module(plt, module) do
      {:value, list} ->
        for {{module, fun, arity}, ret, args} <- list do
          t = :erl_types.t_fun(args, ret)
          sig = :dialyzer_utils.format_sig(t)
          {{module, fun, arity}, sig}
        end
    end
  end
  
  defp find_function_line(module, fun, arity) do
    case ElixirSense.Core.Normalized.Code.get_docs(module, :docs) do
      nil ->
        nil

      docs ->
        Enum.find_value(docs, fn
          {{^fun, ^arity}, line, _, _, _, _meta} -> line
          _ -> nil
        end)
    end
  end
  
  defp find_target(success_typing_with_line, module, function, line_no) do
    Enum.find_value(success_typing_with_line, fn
      # 同名だがarityが違う関数に対応させるため行数でマッチングしておく
      {{^module, ^function, _arity}, ^line_no, success_typing} ->
        success_typing
      _ -> nil
    end)
  end
end

:dialyzer_plt.lookup_moduleで更新したpltの情報から対応するモジュールの情報を引っ張ってきて、パターンマッチで対象になっている関数の情報を探し、型推論された返り値(ret)と引数(arguments)の情報を取得しています。
find_function_lineの実装内では、elixir-senseで取得した関数の行数とpltから取得してきた関数の行数でパターンマッチしていますが、elixir_senseの行情報が間違っていたりplt内から取得してきた行の情報はnilの場合があり、arityの情報を渡してパターンマッチさせるほうが精度が良いかもしれません(試せて無いです)

取得した型情報をElixirのspecアノテーションに変換する

ここまでで型情報の取得までは出来たので、後はspecに変換するのみです。
取得した型情報(success_typing変数)はerlangになっているので、erl2exを利用してelixirコードに変換します。

defmodule Credo.Check.Readability.Specs.Translator do
  alias Erl2exVendored.Convert.{Context, ErlForms}
  alias Erl2exVendored.Pipeline.{Parse, ModuleData, ExSpec}

  def translate_spec(nil, _module, _function), do: nil

  def translate_spec(success_typing, module, function) do
    {[%ExSpec{specs: [spec]} | _rest], _} =
      # erl_parseを使っている都合上、erlangでの予約語などが入っているとエラーになる
      # そのため、一旦fooで置き換えている
      "-spec foo#{success_typing}."
      |> Parse.string()
      |> hd()
      |> elem(0)
      |> ErlForms.conv_form(%Context{in_type_expr: true, module_data: %ModuleData{}})

    spec
    |> Macro.postwalk(&tweak_specs/1)
    |> improve_defprotocol_spec(module, function)
    |> Macro.to_string()
    |> Code.format_string!(line_length: :infinity)
    |> IO.iodata_to_binary()
    |> String.replace_prefix("foo", to_string(function))
    |> then(&"@spec #{&1}")
  end
end

これでspecへの変換は完了しました!
後はファイルへの挿入をすれば完成です。

defmodule Credo.Check.Readability.Specs do
  # 同じファイルに複数issueがある場合にfileを更新していくと、関数の対象行がずれていく
  # line_shiftにズレた分の行数を加算して行って、ズレを調整する
  def autofix({file, line_shift}, issue) do
    ...
         # translate_specで取得したspecアノテーション文字列
    case suggest_spec_str do
      nil ->
        IO.puts("Cannot analyze function of #{module}.#{function}, line: #{line_no}")
        {file, line_shift}
      spec ->
        IO.puts("Autocorrected #{module}.#{function}, line: #{line_no}")
        spec_line_count = spec |> String.split("\n") |> length()
        function_line_index = line_no + line_shift - 1
        function_line = file_each_lines |> Enum.at(function_line_index)
        spec = __MODULE__.Translator.indent_spec(spec, function_line)
        updated_file = insert_to_file(spec, file_each_lines, function_line_index)

        {updated_file, line_shift + spec_line_count_with_comment}
    end
  end
end

実行してみる

実行前に一度mix compileしておき、beamファイルを更新します

mix compile

一度credoを実行してissueの件数を確認してみます。

mix credo

Please report incorrect results: https://github.com/rrrene/credo/issues

Analysis took 3.2 seconds (0.5s to load, 2.6s running 50 checks on 1204 files)
8211 mods/funs, found 270 code readability issues.

300件近いissueが検知されました。
これを手動で全部修正するのは辛そうです...

自動補完を実行してみます。
実行は以下のコマンドで可能です。

mix credo suggest --autofix

Autocorrected ...Module, line: ...
...

補完後に再度mix credoでチェックしてみます

mix credo

Please report incorrect results: https://github.com/rrrene/credo/issues

Analysis took 2.5 seconds (0.4s to load, 2.1s running 50 checks on 1204 files)
8211 mods/funs, found 54 code readability issues.

issueが270 -> 54件に減ったので約220件補完出来ました!
残りの54件については解析出来ていない理由がいくつかあり

  • find_function_lineで正しい行数が取得できておらず、find_targetでのline_noのマッチングに失敗している
  • PLTから取得してきた行数の情報がnilになっている
  • credoで作成されたissue構造体の中に正しいscopeが入っていない
  • quoteなどを使って動的に定義されている関数が補完出来ない
    などでした。

1つ目, 2つ目に関してはissue54件の中にarityが違う同名関数が無い事を確認した上でline_noのマッチングを外すことにより最終的に40件減らし、残り10件程度まで補完することが出来ました。
まだ試せてはいないのですが、elixir_senseで取得したline_noでのマッチングを辞めてastなどからarityを取得してきてマッチングさせると補完出来ないものが減るんじゃないかなと考えています。

3つ目,4つ目に関しては全て

quote do
end

の中で動的に定義されている関数でした。
この二つについては、自分の手でspecを付けてもcredoでissueとして検知されてしまい、調べてみるとこちらのissueに辿り着きました。
https://github.com/rrrene/credo/issues/984

credoは現状ファイルを静的に解析していて、動的な関数定義に対応していないとのことです。

まとめ

この記事ではspecのチェックを有効化するために、既存のspecに関するissueをdialyzerの型推論機能で自動補完させました。
コードが汚いのはご容赦ください...
これで(完全ではないですが、少なくともspecチェック有効化以降は)安心してElixir上で型のある生活を送れるのでは無いでしょうか...?
少なくとも私はハッピーなクリスマスを迎えられそうです。

最後に、アカツキゲームスでは一緒に働くエンジニアを募集しています。
カジュアル面談もやっていますので、気軽にご応募ください。
応募はこちらから:https://herp.careers/v1/aktskgames/requisition-groups/47f46396-e08a-4b2f-8f9b-b2fc79e63b83

Discussion