Slimcop

SlimテンプレートにRuboCopを適用するツール、Slimcopをつくった。

使い方

基本的には、RuboCopと同じような機能をSlimテンプレート向けに提供するツールとなっている。SlimcopはGemとしてインストールでき、CLI向けに slimcop というコマンドを提供する。実行すると、Slimテンプレート中に埋め込まれたRubyのコードを検出し、それらに対してRuboCopで検査を実行する。また特徴的な機能として、auto-correctにも対応している。

$ slimcop --help
Usage: slimcop [options] [file1, file2, ...]
    -a, --auto-correct               Auto-correct offenses.
    -c, --config=                    Specify configuration file.
        --[no-]color                 Force color output on or off.

以下は使用例。コマンドライン引数を省略すると、.rubocop.yml があればその設定を読み取りつつ、カレントディレクトリ内のSlimファイルを走査してRuboCopで検査を行う。

$ slimcop
Inspecting 1 file
C

Offenses:

spec/fixtures/dummy.slim:1:3: C: [Correctable] Style/StringLiterals: Prefer single-quoted strings when you don't need string interpolation or special symbols.
- "a"
  ^^^
spec/fixtures/dummy.slim:3:5: C: [Correctable] Style/StringLiterals: Prefer single-quoted strings when you don't need string interpolation or special symbols.
| #{"c"}
    ^^^

1 file inspected, 2 offenses detected, 2 offenses auto-correctable

運用方法

プロジェクトに導入する場合は、--auto-correct の機能を使いながら既存の違反をすべて修正した上で、新たな違反が導入されないようにCIで検査するのが良いだろう。--auto-gen-config(.rubocop_todo.ymlを生成する機能)ライクなものは用意できていない。そういうケース等で特定のCopの違反を無視させたい場合は、.rubocop.yml を継承した .slimcop.yml を用意しても良いかもしれない。

# .slimcop.yml
inherit_from: .rubocop.yml

Rails/OutputSafety:
  Enabled: false

出力形式をRuboCopと同じにしてあるので、CIにGitHub Actionsを使う場合、rubocop-problem-matchers-actionも利用できる。これを使うと、Pull Requestで違反箇所に注釈を付けてくれるようになる。

GitHub Actionsを使う場合のWorkflowのコード例を紹介しておく。

# .github/workflows/lint.yml
name: Lint

on:
  pull_request:
  push:
    branches:
      - develop

jobs:
  slimcop:
    runs-on: ubuntu-20.04
    env:
      BUNDLE_WITHOUT: default:development:production:test
    steps:
      - uses: actions/checkout@v2
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
          cache-version: slimcop
      - uses: r7kamura/rubocop-problem-matchers-action@v1
      - run: bundle exec slimcop --config .slimcop.yml --no-color

幾つか小テクを使っているので説明しておく。まずactions/checkoutのrefオプション。未指定だとPull Requestではmerge commitがチェックアウトされてしまうので、Pull Requestで提出しているコードと、GitHub Actionsで動くコードが微妙に異なるものになってしまう可能性があり、これを付けないと注釈で報告される位置情報がズレてしまう。

また、Problem Matchersは標準出力を正規表現でキャプチャすることで実現されている訳だが、ANSIエスケープシーケンスを利用した色付きの出力が行われると上手くキャプチャできないので、slimcopには--no-colorオプションを付けている。

BUNDLE_WITHOUTについては、ruby/setup-rubyに bundle install を実行してもらうときに、不要なgroupのGemのインストールを避けるために付けている。多分Slimを使う多くのプロジェクトはWebアプリケーションだと思うので、このWorkflowはRailsのプロジェクトを想定していて、次のようにGemfileを書くことを前提としたつくりになっている。

# Gemfile
group :rubocop do
  gem 'rubocop'
  gem 'rubocop-rails'
  gem 'slimcop'
end

開発背景

Slimテンプレートを利用している既存プロジェクトにslim-lintを導入したいが、slim-lintはauto-correctに対応しておらず、手作業で修正するのは大変すぎる――というのが開発の動機である。

Slimパーサーは、与えられたSlimテンプレートを抽象構文木に変換し、これをRubyのソースコードに変換する。この中間表現である抽象構文木から、Slimテンプレート中に埋め込まれたRubyのコード片を表すノードを検出し、これをRuboCopに食べさせることで違反を検出することはできる。しかしそのRubyのコード片がSlimテンプレート中のどの部分にあるのかという位置情報が含まれていないので、RuboCopでauto-correctした結果をどこに書き戻せば良いのか分からない…という訳で、slim-lintではauto-correct対応が諦められていた。

内部実装

じゃあ位置情報付きのASTを吐けるSlimパーサーをつくれば良いじゃん、ということでこれを適当につくり、このASTをSlimcopに利用することにした。

Slimは元々、Templeの作法に素朴に従った実装になっている。TempleというのはテンプレートエンジンをつくるためのRubyのフレームワークで、String, Symbol, Array, Integerの組み合わせでS式ライクなデータ構造をつくり、これで抽象構文木を表現しようじゃん、という設計になっている。この設計については、TempleのREADMEをざっと読むのが速い。

 [:multi,
   [:static, "Hello "],
   [:slim, :interpolate, "@world"]]

Slimでは、文字列中にRubyコードを埋め込むと上のようなASTが生成される。

 [:multi,
   [:static, "Hello "],
   [:slimi, :interpolate, 6, 12, "@world"]]

今回つくったSlimiでは、上のように開始位置と終了位置を追加することにした。ちなみにRubyで文字列を扱う上ではその方が管理しやすいので、この位置情報はバイト単位ではなく文字単位でのインデックスである。Slimのソースコードが text というStringに入っているとすると、text[6...12] で該当のRubyのコードが取り出せるような形になっている。今更だが、実際のところはRubyのコードを表す文字列からその長さが取れるので、6という開始位置の情報しか要らず、12という終了位置の情報は不要である。

Slimの全ての機能を実装する必要はなく、Slimiの生成するAST内を走査して位置情報を取り払う(slimi interpolateをslim interpolateに変換する)フィルターを書けば、Slimが生成するものと同等のASTに変換できるので、あとの部分(最適化処理やTempleに適した形に変換する処理等)はSlimの既存実装に任せれば良い。正しくパースできているかどうかも、Slimの返すASTとフィルター適用後のASTとを比較すれば分かるので、テストに利用できる。

RuboCopとの連携部分について。基本的には次のオブジェクトを渡せば違反 (RuboCop::Cop::Offense) の配列を得られる仕組みになっているので、それぞれ用意して、適切な形でRuboCopに渡してあげれば良い。

  1. Rubyのコード (String)
  2. RuboCopの設定 (RuboCop::Config)

auto-correctしたい場合は、RuboCop::Cop::Offense#corrector から得られるProcオブジェクトとRubyのコードを表す文字列を渡せば、変換後の文字列が得られる。このとき、ただ単純にRubyのコードを渡す代わりに、Rubyのコードを含む文字列とそのコードの開始位置を同時に渡せば、文字列内の該当部分をいい感じに変換してくれる仕組みがRuboCopに備わっている。よって、Slimのソースコードとslimi interpolateから得られる開始位置を渡せば上手くいく。RuboCopはこの使い方を想定していたのだろうか。

Slimcopの実行結果の出力には、RuboCop::Formatterの実装を再利用している。このフォーマッターは、RuboCop::Cop::Offenseを渡すと適切な形式でその情報を出力してくれる。しかし、この段階で得られているOffenseオブジェクトの位置情報はSlimソースコード内での位置情報ではなく、渡したRubyコード内での位置情報なので、そのまま渡すと位置情報がズレてしまう。そこで、ここでもASTから得られる開始位置を使って報告される位置情報を調整している。

課題1: 分断されたRubyコード片

Slimcopは、埋め込まれたRubyコード片のうち、一部しか検査できない。

- if articles.empty?
  p 記事が存在しません。
- else
  ul
    - articles.each do |article|
      li
        = link_to article do
          p= article.title

この典型的なSlimテンプレートの例からは次の4つのRubyコード片が得られるが、このうち単体でRubyのコードとしてvalidなのは4つ目だけだ。

  1. if articles.empty?
  2. articles.each do |article|
  3. link_to article do
  4. article.title

現在は、パースを試して失敗すれば無視するという実装になっている。この場合、適切に end} を補うなどして検査できる形に変換してから試行したいところだ。

課題2: RuboCopとの兼ね合い

本当はRuboCopのプラグインとして実装したい。

元々仕事先で使おうと考えてつくったので、早速3200枚のSlimテンプレートを抱えるRailsプロジェクトにSlimcopを導入し、auto-correctを利用しながらすべての違反を取り除くことに成功した。課題として、これだけ大量にSlimファイルがあると実行時間が無視できない長さになってくるので、rubocop --parallel 相当の機能を実装したいところ。また、運用の項目でも触れたが、rubocop --auto-gen-config 相当の機能があれば導入が楽になりそうである。

しかし、そもそもRuboCopが「Rubyのコードが埋め込まれているソースコードを処理する機能」を備えていれば、いろいろと自前で実装しなくてもrubocop-slimみたいなプラグインを少し書くだけで済む。これをサポートするなら、RuboCop側では「1つのファイルパスから1つのRubyコードを得る」という現状の実装から、「1つのファイルパスから複数のRubyコードとその開始位置を得る」という実装に変更する必要があるだろうけれど、そこまで難しいことでは無いだろうと思う。この機能がRuboCopにサポートされると、ERBやHamlやSlimといったテンプレート達がその恩恵を享けられるほか、何か別の用途で便利に使えるかもしれない(例: ファイルパスからRubyのコードを動的に生成してその場で検査する等)。

課題3: slim-template/slimとの兼ね合い

せっかくSlimパーサーを書いたので、Slimcopの用途だけでなく普通にSlimの代わりに使っていっても良いと思う。

そのためには、もっと沢山のテストケースがあった方が良い。少なくとも、今回書いたSlimiがどれだけSlimの仕様に準拠できているか、実行できる形式で確かめたい。実はSlimiではサポートしていないマイナー機能も幾つかある。Slimテンプレートには(多分)これまで有名な別実装が存在していなかったこともあり、内部実装に依存しない良い感じのテストケース群が存在していない。haml-specやcommonmarkライクな、slim-specみたいなものがあると良いと思う。

slim-template/slimは3年以上新しいバージョンがリリースされていないので、最早あまり活発に開発される雰囲気ではないのかもしれない。

最近の話だと例えば、生成されるHTMLに生成元テンプレートのパスを埋め込む機能がRails 6.0から導入されたのだが、これの対応もしてほしいなと思っていたりするものの動きが無い。今回Slimパーサーを書いたのは、このまま開発が完全停止した場合の保険も兼ねてという意味合いも込めている。