elm-reviewを使ってモジュールの依存関係に制限を付ける

[Fringe81アドベントカレンダー2020](https://qiita.com/advent-calendar/2020/fringe81)の13日目の記事です(1日遅れ)

---

[elm-review](https://package.elm-lang.org/packages/jfmengels/elm-review/latest/)は一言で言うとElmのためのLintツールです。Elmのコードを解析して、Elmの文法では表現できないルールに沿って警告を出すことができます。

![スクリーンショット 2020-12-14 1.06.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32674/2413e40b-82f4-f3f3-f899-34aa3cf5c529.png)

- 型アノテーションのない関数(画像で検知してるルール)
- 未使用の関数
- デバッグ出力(Debug.log)
- 開発チームで非推奨な書き方

...などなど[^1]、シンタックスの解析で検出できるものであれば実質何にでも使用することができます。今回はこのelm-reviewを使って、指定した依存関係に反したimportをしているモジュールを検出してみました。

## Elmのモジュールおさらい

### モジュール宣言

Elmでは1つのファイルに必ず1つのモジュールを宣言します。1つのファイルに複数のモジュールを宣言することはできません。つまり、**1ファイル = 1モジュール**になっています。

またそのモジュール名はディレクトリ構造によって決まります。例えば、`Hoge/Fuga/Piyo.elm`に宣言するモジュール名は`Hoge.Fuga.Piyo`でなくてはなりません。

```elm
module Hoge.Fuga.Piyo exposing (publicFunction)
```

### 公開範囲

Elmではモジュール外に公開する型や関数の公開範囲を指定することはできません。そのため、一部のモジュールにだけ公開したいような関数が想定外のモジュールから使われていたとしても、そのルールを指定する方法がないのでコンパイルエラーにすることはできません。

```elm
module Pages.Home.Form exposing (form)
```

```elm
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を実装します。

[実装したコード(雰囲気だけ感じてください)](https://github.com/misoton665/elm-review-module-layer-dependency/blob/master/src/NoViolationOfModuleLayerDependency.elm#L10)

newModuleRuleSchema: モジュール単位でルールを適用する。
withModuleDefinitionVisitor: モジュール内のmodule文を解析する。
withImportVisitor: モジュール内のimport文を解析する。

処理の流れは以下のようになっています。
1. withModuleDefinitionVisitorを使って検査するモジュールのレイヤー番号を計算する。
2. withImportVisitorを使ってimport文を1つずつ見ていき、importしているモジュールのレイヤー番号を計算する。
3. 2の結果が1の結果よりも大きければ警告を出す。

elm-reviewで解析する場合、Hoge.Fuga.Pomというモジュールは`["Hoge", "Fuga", "Pom"]`という`List String`型で表現されます。[モジュール宣言](#モジュール宣言)の項で説明した通り、Elmのモジュールはディレクトリ構造で決まるので、「"Hoge"配下のモジュールかどうか」は`List String`についての関数として定義できます。

```elm
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
```

```elm
isMatchWith [ "Pages" ] [ "Pages", "Hoge" ] --> True

isMatchWith [ "Pages" ] [ "Waaa", "Pages" ] --> False

isMatchWith [ "Pages", "Hoge" ] [ "Pages" ] --> False
```

このisMatchWith関数を使って以下のようなシグネチャの関数を作ることでレイヤー番号を計算します。

```elm
type ModuleLayer
    = ModuleLayer (List ModuleName)
    | DefaultLayer -- どのレイヤーにも属さないモジュールのレイヤー


type ModuleLayerDependancy
    = ModuleLayerDependancy (List ModuleLayer)


layerNumber : ModuleLayerDependancy -> ModuleName -> Int
```

### Bの実装

次に入力となるレイヤー情報のBを作成します。

[実装したコード(この記事の例とは少し異なります)](https://github.com/misoton665/elm-review-module-layer-dependency/blob/master/example/review/src/ReviewConfig.elm#L13)

```elm
moduleLayerRule : ModuleLayerDependency
moduleLayerRule =
    ModuleLayerDependency
        [ commonUILayer
        , DefaultLayer
        , pagesLayer
        ]


pagesLayer : ModuleLayer
pagesLayer =
    ModuleLayer
        [ [ "Pages" ]
        ]


commonUILayer : ModuleLayer
commonUILayer =
    ModuleLayer
        [ [ "CommonUI" ]
        ]
```

## 使ってみる

検査の対象にするディレクトリ構造はこちら。

![スクリーンショット 2020-12-14 3.01.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32674/6e3725a6-dce7-2221-a35f-e03d42c87791.png)

初めに、警告が出ないはずの`Pages --> CommonUI`のimport文だけを書いてみます。

```elm
module Pages.Home.Form exposing (..)

import CommonUI.DocumentEditor
```

![スクリーンショット 2020-12-14 3.04.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32674/98089642-3eae-5bed-5eb3-e0697f74bf90.png)
問題なし:eyes:


次に、警告が出て欲しい`Pages <-- CommonUI`のimport文を書いて試してみます。

```elm
module CommonUI.DocumentEditor exposing (..)

import Pages.Home.Form
```
![スクリーンショット 2020-12-14 3.15.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32674/a4dedd80-b83b-4cf0-e6e4-2d29d1dd6d73.png)
検知できました:+1:
(DefaultLayerがあるのでPages.*のレイヤー番号は2になっています)

## 最後に

公開されているelm-reviewのパッケージにはコードの書き方を警告するものが多いですが自由度高くルールを自作できるので、冒頭でも書いた通りシンタックスの解析で検出できるものであれば実質何にでも使用することができます[^3]。

「Elmでああしたい、こうしたい。でもElmの文法だと厳しい...」という時の選択肢に入れておくと良いことがあるかもしれません。

[^1]: 公開されているルールは[ここから](https://klaftertief.github.io/elm-search/?q=Review.Rule.Rule)検索できます。
[^2]: 内部で[elm-syntax](https://package.elm-lang.org/packages/stil4m/elm-syntax/latest/)が使われています
[^3]: elm-reviewのREADME.mdには「いつelm-reviewを使うべきか」の[ガイドライン](https://github.com/jfmengels/elm-review/tree/2.3.8#when-to-write-or-enable-a-rule)が載っています。何でもかんでもelm-reviewのルールにしないでね、というメッセージにもなっています。一度読んでみてください。