Fringe81アドベントカレンダー2020の13日目の記事です(1日遅れ)
elm-reviewは一言で言うとElmのためのLintツールです。Elmのコードを解析して、Elmの文法では表現できないルールに沿って警告を出すことができます。
- 型アノテーションのない関数(画像で検知してるルール)
- 未使用の関数
- デバッグ出力(Debug.log)
- 開発チームで非推奨な書き方
...などなど1、シンタックスの解析で検出できるものであれば実質何にでも使用することができます。今回はこのelm-reviewを使って、指定した依存関係に反したimportをしているモジュールを検出してみました。
Elmのモジュールおさらい
モジュール宣言
Elmでは1つのファイルに必ず1つのモジュールを宣言します。1つのファイルに複数のモジュールを宣言することはできません。つまり、1ファイル = 1モジュールになっています。
またそのモジュール名はディレクトリ構造によって決まります。例えば、Hoge/Fuga/Piyo.elm
に宣言するモジュール名はHoge.Fuga.Piyo
でなくてはなりません。
module Hoge.Fuga.Piyo exposing (publicFunction)
公開範囲
Elmではモジュール外に公開する型や関数の公開範囲を指定することはできません。そのため、一部のモジュールにだけ公開したいような関数が想定外のモジュールから使われていたとしても、そのルールを指定する方法がないのでコンパイルエラーにすることはできません。
module Pages.Home.Form exposing (form)
module CommonUI.DocumentEditor exposing (editor)
-- 汎用的に使われることを期待したモジュールが特定ページのモジュールをimportしている。
import Pages.Home.Form exposing (form)
このように、期待しない使い方がされていてもコンパイル時に気づくことはできません。もしかしたら開発が進んで実際に困った影響が出るまで気づかないかもしれませんね。
今回はelm-reviewを使ってこれを検知してみます。
ルールを考える
今回検査したいのは「CommonUI配下にあるモジュールがPages配下にあるモジュールをimportしたら警告する」というルールです。これを噛み砕いて文章にすると以下のようになります。
モジュール名がCommonUIから始まっている場合、モジュール名がPagesから始まるモジュールをimportしていたら警告を出す。
もう少し汎用的に使えるように、このようにしてみます。
A. 全てのモジュールは"レイヤー番号"を持ち、レイヤー番号が自モジュールよりも大きいモジュールをimportしていたら警告を出す。
B. Pages.*はレイヤー番号1、CommonUI.*はレイヤー番号0とする。
Bのレイヤー情報を入力として、Aのルールに違反するモジュールを検知する作戦で実装していきます。
ルールを実装する
Aの実装
elm-reviewにはmodule文とimport文を解析するためのAPIがあるので2、これを使ってAを実装します。
newModuleRuleSchema: モジュール単位でルールを適用する。
withModuleDefinitionVisitor: モジュール内のmodule文を解析する。
withImportVisitor: モジュール内のimport文を解析する。
処理の流れは以下のようになっています。
- withModuleDefinitionVisitorを使って検査するモジュールのレイヤー番号を計算する。
- withImportVisitorを使ってimport文を1つずつ見ていき、importしているモジュールのレイヤー番号を計算する。
- 2の結果が1の結果よりも大きければ警告を出す。
elm-reviewで解析する場合、Hoge.Fuga.Pomというモジュールは["Hoge", "Fuga", "Pom"]
というList String
型で表現されます。モジュール宣言の項で説明した通り、Elmのモジュールはディレクトリ構造で決まるので、「"Hoge"配下のモジュールかどうか」はList String
についての関数として定義できます。
type alias ModuleName = List String
-- モジュール名を先頭から比較していき、ルールとなるモジュール名(rule)が全てターゲットになるモジュール名(target)と一致していればTrueを返す。
isMatchWith : ModuleName -> ModuleName -> Bool
isMatchWith rule target =
case (rule, target) of
([], _) ->
True
(r :: rs, t :: ts) ->
if r == t then
isMatchWith rs ts
else
False
(_ :: _, []) ->
False
isMatchWith [ "Pages" ] [ "Pages", "Hoge" ] --> True
isMatchWith [ "Pages" ] [ "Waaa", "Pages" ] --> False
isMatchWith [ "Pages", "Hoge" ] [ "Pages" ] --> False
このisMatchWith関数を使って以下のようなシグネチャの関数を作ることでレイヤー番号を計算します。
type ModuleLayer
= ModuleLayer (List ModuleName)
| DefaultLayer -- どのレイヤーにも属さないモジュールのレイヤー
type ModuleLayerDependancy
= ModuleLayerDependancy (List ModuleLayer)
layerNumber : ModuleLayerDependancy -> ModuleName -> Int
Bの実装
次に入力となるレイヤー情報のBを作成します。
moduleLayerRule : ModuleLayerDependency
moduleLayerRule =
ModuleLayerDependency
[ commonUILayer
, DefaultLayer
, pagesLayer
]
pagesLayer : ModuleLayer
pagesLayer =
ModuleLayer
[ [ "Pages" ]
]
commonUILayer : ModuleLayer
commonUILayer =
ModuleLayer
[ [ "CommonUI" ]
]
使ってみる
検査の対象にするディレクトリ構造はこちら。
初めに、警告が出ないはずのPages --> CommonUI
のimport文だけを書いてみます。
module Pages.Home.Form exposing (..)
import CommonUI.DocumentEditor
次に、警告が出て欲しいPages <-- CommonUI
のimport文を書いて試してみます。
module CommonUI.DocumentEditor exposing (..)
import Pages.Home.Form
検知できました
(DefaultLayerがあるのでPages.*のレイヤー番号は2になっています)
最後に
公開されているelm-reviewのパッケージにはコードの書き方を警告するものが多いですが自由度高くルールを自作できるので、冒頭でも書いた通りシンタックスの解析で検出できるものであれば実質何にでも使用することができます3。
「Elmでああしたい、こうしたい。でもElmの文法だと厳しい...」という時の選択肢に入れておくと良いことがあるかもしれません。