RuboCop on RBS

rubocopをRBSファイルにも効かせたい - スペクトラム で作ってたものが大体できてきて、rbs v3.5もリリースされたので公開できるようになりました。

これでRuboCopをRBSファイルにも使用できるようになりました。

github.com

例えば

class Foo
def foo: () -> void
end

というRBSをレビューする時「インデント入れてね」と指摘したくなりますよね。Rubyファイルならrubocopでできるのに、RBSファイルではrubocopで指摘できないという問題がありました。あるんです。きっと。というわけで、できるようにしました。

とりあえず初期バージョンでは20以上のルールを用意しています。

autocorrect(自動修正)にも対応しているので、いつものようにrubocop -aしてあげれば

class Foo
  def foo: () -> void
end

とrubocopが修正してくれます。

CLIで実行するもよし、CIに設定するもよし、エディタ上でLSPとして使用するもよしと、RBSの記述へのハードルが下がることを目指しています。

RuboCop on RBS

ちょけたネーミングですがワケがあります。rubocop-rbsの名前は使えなかったのです。それだけです。

rubocop-de-rbsとちょっと迷いました。

つかいかた

使い方は、rubocop-rails等ふつうのrubocop拡張と同じです。.rubocop.ymlrequireに追加するのがオススメです。

Departments

20以上の機能があり、.rubocop.ymlで設定を切り替えられます。 機能追加のリクエスト待っています。

RBS/Layout/*

インデントやスペースの指摘、およびautocorrectによる自動修正ができます。 rbsリポジトリに記載されているスペースやインデントを正しいものとしています。

全てのトークン間で多すぎるスペースを指摘することもできます。 このためにRBSトークン別にパースする機能を入れてもらいました。 Implement token list API by ksss · Pull Request #1829 · ruby/rbs · GitHub

  • コメントのインデントがずれてると指摘
  • overload間の空改行があると指摘
  • class/module/interfaceとendのインデントが合ってないと指摘
  • スペースが無駄に多すぎると指摘
  • class/module/interfaceがあるたびにインデントをスペース2つ分深くなってないと指摘
  • overloadの:|の縦が揃ってないと指摘
  • ->の前後は1スペースじゃないと指摘
  • ブロックを表す{}の前後は1スペースじゃないと指摘
  • def foo::の前はスペースをなくすよう指摘
  • :|のあとは1スペースになるよう指摘
  • 行末の余分なスペースを指摘

RBS/Lint/*

RBSシンタックスエラーや、rbs validateでワーニングやエラーになる構文を指摘してくれます。

  • シンタックスエラー
  • 型引数を全く使っていないと警告
  • Array等、型引数を指定しなければいけない型なのに、型引数がついていなかったり多かったりしたら警告
  • メソッド引数にvoidを使用したら警告

rbs validateを実行しなくてもRuboCopで指摘してくれるのですぐに問題に気づけます。

若干SteepのLSP機能と被っている場合もありますが、RuboCop on RBSでは軽量に分かるものを、Steepでは名前解決が必要な重いものを見れればいいのかなと思っています。

RBS/Style/*

この書き方より、こっちの方がいいですよ系。 主に型の書き方で無意味な重複があったりすると教えてくれます。 自動修正にも対応しているので便利。

  • ブロックの返り値にboolを使っている時は指摘
  • TrueClass, FalseClass, NilClassを使っていると指摘
  • Integer | Integerのような重複があると指摘
  • initializeの返り値はvoidでないと指摘
  • untyped | Stringは結局untypedなので指摘
  • nil?のような重複した定義を指摘
  • true | falseを使用した場合、boolに直すよう指摘

どうやってやってるの?

rubocopは、Rubyとしてパースし、シンタックスエラーが発生したらon_other_fileを呼び出しますという挙動になっています。

このon_other_fileRBSとしてパースして、さまざまなCopに利用しています。RBS::Parser.parse_signatureでパースしたASTには位置情報も含まれているのでこれを利用しています。

また、RBS::Parser.lexというAPIも増えたので、これで全てのトークンの位置情報を細かく取得できます。これもガッツリ使っています。なのでrubocop-on-rbsrbs v3.5から使うことができます。

VSCode対応

VSCode対応もできています。

ですが https://github.com/rubocop/vscode-rubocop/pull/28 で提案中なので自分でextensionをbuildするか、~/.vscode/extensions/を直接書きかえる必要があります。(方法はrubocop-on-rbsのREADME.mdに書きました。)

エディタでリアルタイムフィードバックがあると次元の違う開発体験ができるので、ぜひ実現させたい機能です。

将来的に

ruby/rbsruby/gem_rbs_collectionに導入して、PRがきたらCIで機械的に指摘できたらいいなと思っています。

もちろんあなたのリポジトリにも使っていただけると大変嬉しいです。

raapがrbsで使われ始めた

RBSをテストコードにするツールraaprbsリポジトリで使われ始めた。

Introduce RaaP for testing of signature by ksss · Pull Request #1810 · ruby/rbs · GitHub

rbsのCIでraapを走らせている。 現状はSet classでしか使っていないが、これから範囲を広げていきたい。

経緯

raapを作っていたら、「これはRBS::UnitTestを自動化するツールだな」と思い始めてきた。

テストコードを手で書くより、自動で100パターン実行してくれた方が、管理するものが減って、人間の思い込みを排除した確認ができ、早く、変更にも強い。

もともとrbsリポジトリに置かれた記述されたRBSファイルに対するテスト("RBSの記述は正しいか"を確認するコード)は結構カバレッジが低い部分もあり、これを埋めたかった。

なのでraapをclass単位で実行して落ちたテストケースをコピペしてテストを足して型を直すという活動をやっていたが「もうこれはいっそraapをrbsリポジトリで使った方が早いのでは?」と思い始めた。

例えばSet classのRBSに対するテストはほとんど無に等しい状態で、実際型も正しくはなかったしメソッド名もtypoしてた。これを見つけたのもraapだ。

raapの完成度を上げ、十分使えそうになってきたのでrbsリポジトリに導入を提案した。

これから

まずはテストが不十分なところをraapで埋めつつ型を直していきたい。

あんまり厳密すぎても不便なだけになることもあるので、その辺のバランスを探りつつ、rbsにもraapにも還元していく流れを作っていきたい。

ゆくゆくはgem化されているスタンダードライブラリーでの使用も目論んでいる。

gem化されたスタンダードライブラリーはメンテナーが見ているわけだけど、メンテナーにRBS用のテストコードまで面倒見てもらうのは重い気がしているので、raapで自動化できたらいいんじゃない?と夢見ている。

さらにはユーザーライブラリーでの利用を広げるなど「型をテストにする」という切り口でどこまでいけるのか試してみたい。

rubocopをRBSファイルにも効かせたい

背景

RBSも利用が増えてくると、細かい"こうしたほうがいい"が出てくることがあると予想される。

例えば、

  • インデント
  • initializeの返り値はvoidを使う
  • untyped?ではなくuntypedを使う
  • メソッド引数にvoidを使わない

などなど。

こうしたことを指摘するツールとしては、Rubyではrubocopが覇権をとっており、大体の環境ですでに使われている。

このエコシステムにRBSも乗っかりたい。

問題点

VSCode上で警告を表示できない

大きな問題点として、VSCode上の波線(squigglyというらしい)が表示できないという問題がある。

エディタでインタラクティブに問題点が見える体験はかなり重要だと思っているので対応したい。

しかしながら何が原因なのかはわからない。

試しにrubocop orgが公式にホストしているrubocop-mdを使ってみたが、こちらも特にエディタ上ではなにも表示されなかった(ruby-lsp)。

原因を究明するのに時間を使うべきか、より機能追加に時間を使うべきか、悩みどころである。

名前をrubocop-rbsにできない

今回作っているものはrubocop-rbsと言うしかないものなのだが、

rubocop-rbsというgemはすでに存在している。

これはRBSを見て、Ruby側のメソッド定義の引数の形式と数のマッチングをみるもののようだ。

名前は早い者勝ちなのでしょうがないが、この作っているものの名前に悩んでいる。

今は仮にrubocop-rbs-sigとしている。

機能

とりあえず、

  • シンタックスエラー表示
  • initializeの返り値はvoidを使う
  • untyped?ではなくuntypedを使う

できた。

sig/rubocop/rbs/sig.rbs:5:31: C: [Correctable] RBS/Sig/InitializeReturnType: #initialize method should return void
        def initialize: () -> nil
                              ^^^
sig/rubocop/rbs/sig.rbs:6:19: C: [Correctable] RBS/Sig/Untyped: untyped? should be untyped
        def foo: (untyped?) -> (Integer | untyped)
                  ^^^^^^^^
sig/rubocop/rbs/sig.rbs:6:33: C: [Correctable] RBS/Sig/Untyped: Integer | untyped should be untyped
        def foo: (untyped?) -> (Integer | untyped)
                                ^^^^^^^^^^^^^^^^^
  • autocorrect
  • 機能別にCopを分ける

といったこともできている。

実装方法

rubocopはRubyに特化したツールであり、RBSRubyとは違う。

Ruby以外のファイルにrubocopを実行する参考例としては、rubocop-erbrubocop-mdがみつかった。これを参考にした。

rubocopではターゲットファイルがRubyとしてパースできなかった場合、on_other_fileを呼び出すようになっている。

rubocop/lib/rubocop/cop/commissioner.rb at 673495b70e8b9e42d82d85526292945d47189f10 · rubocop/rubocop · GitHub

このon_other_fileRBSとしてパースし、パースできたらRBSとして扱うようにしてみている。

Rubyとしてパースして、RBSとしてもパースしているので無駄はある気はするが、ここを変えようとするとrubocopにかなり無茶させないといけなくなり、コード量も増える気がするので一旦こうしている。

つまり

やればできそう。

ブロック引数と返り値は似ている

RaaPを開発していて、ブロック引数の扱いについて解像度が上がったのでメモとして書いておく。

これまでの考え方

Array#mapを例に見てみる。

[1, 2, 3].map do |i|
  i.to_s
end

これまではこう考えていた。 メソッドの最終的な返り値のためには、iはぶっちゃけなんでもよく、ブロックの返り値さえ想定された型なら良いだろうと。

実装イメージとしてはこうしていた。

array = Array.new(3) { random_integer }
return_value = random_string
block = Proc.new
  return_value
end

array.times(&block)

しかしながら、これでは動作検証とは言えないケースが出てきた。

例えばSet#divideはブロック引数の数で挙動が変わるという面白いメソッドで、実装も明確に

if func.arity == 2

とブロック引数の数で挙動が分岐している。

set/lib/set.rb at 4e95d55294f0e577529ca8702c4d2c3ae90247e7 · ruby/set · GitHub

この挙動もサポートしたい。

Procを動的に作る

func.arity == 2を達成するにはブロック用のProcの引数部分を動的に生成しなければならない。

Rubyでは現状Procを動的に組み立てるメソッドはない。evalするしかない。

というわけで、型からブロック用のProcをevalで生成するコードを書いた。もっといい方法があったら知りたい……。

与えるか、与えられるか

さらにInteger#timesの例を考えてみる。

10.times do |i|
  # ...
end

このiは型から生成しているわけではなく、メソッドの挙動として勝手に生成されている。

これまでの考え方としては、返り値以外は全て自動的に値を生成するもので、メソッドの返り値だけが唯一の型チェック対象だとしていた。

ランダムな食材をメソッドクックに与えると、できあがった料理となって出てくる。こう考えていた。

しかしながら、ブロック引数も実はメソッドが生成している。返り値のように。ならば、その値が本当に型通りなのか確かめたい。なぜならRaaPはテスティングツールだから。

ブロック引数は、実は返り値に似ていて、型チェック対象なのだ。

まとめると、

  • メソッド引数 -> 型から値を生成する
  • メソッド返り値 -> 値を型チェックする
  • ブロック引数 -> 値を型チェックする
  • ブロック返り値 -> 型から値を生成する

メソッドとブロックは反対の関係にあるように見えておもしろい。

実装する

Integer#timesの型が() { (String) -> void } -> selfだったら、間違っていると教えてあげるべきなのだ。

わかってしまえば、なんてことのない当たり前のことだ。

というわけで、ブロック引数の型チェック機能も実装した。

試しにわざとInteger#timesのブロック引数をStringにしてみると、TypeErrorが発生してテストは失敗するようになった。

[RaaP] INFO: # Integer
[RaaP] INFO: ## def times: () { (::String) -> void } -> self
....F
[RaaP] INFO: Failure: [TypeError] block argument type mismatch: expected `(::String)`, got [0]

(::String)をブロック引数に期待していたが、実際は[0]が来たのでおかしいよとRaaPが教えてくれている。

さらに、わざとブロック引数を空にしてみる。

[RaaP] INFO: # Integer
[RaaP] INFO: ## def times: () { () -> void } -> self
......E....EE....EE.E.EE.EE..E...E..E.E.E.....E.EE...E...E....E..EEE..EE....E.E.E.EEE.E.EEE..EE..E.E.
() { () -> void } -> self

[RaaP] INFO: success: 61, skip: 0, exception: 40, time: 17ms

Integer#timesはレシーバーがマイナスのときはブロックを全く実行しないので、マイナスの場合は成功する。 プラスの時はdebug出力するとわかるがArgumentErrorを出しているので、exceptionとして計上されている。

あながち間違いとも言いにくいのだが、これは失敗でもいい気がするなあ。。。

ともかく検知はできたのでいい感じ。

RubyKaigiで発表しないけどスライド書いた

speakerdeck.com

RBSをテストコードにする - スペクトラム で紹介したRaaPがいい感じに育ってきた。

RubyKaigiごっことして紹介スライドを作った。とくにしゃべる予定はない。

Coverage

大きなアップデートとしては、Coverage機能を追加し、デフォルトで有効にしたことだ。

CoverageはRubyの方ではなく、RBSのほうのCoverageだ。

引数

RaaPではランダムな挙動をベースにしているので、1回の動作につき引数生成に様々な選択がなされる。

  • オプション引数はランダムにあったりなかったりする。
  • rest引数は0個〜3個をランダムに生成する。
  • Union型(Integer | Stringみたいなやつ)は1つだけをランダムに選択する。
  • Optional型(Integer?みたいなやつ)はInteger?(つまりnil)かをランダムに選択する。

これらの条件が複数重なったとき、本当に記述したRBSが使われているのか、書く意味があったのか、不安になってくる。

そこで、どの型を使用したのかCoverageを表示することによって安心感を得ることができる。

もしなかなか使用されないなら、試行回数を増やして確率を上げることもできる。

返り値

また、返り値はランダムではないにせよ、以下のような判定になる。

  • Union型ならどれか一つにあてはまっていればよい
  • Optional型なら、とある型かnilかのどちらかであればよい

RaaPでは型が間違っていたら失敗として教えてくれるが、範囲が広すぎて緩すぎる型になっていても失敗はしない。

返り値に書いた型が本当に意味のあるものだったか、全く使われていないなら削除も検討できる。

RBSのIntersection(&)について

自分向けのまとめです。

Intersection(&)

RBSにはIntersection型があります。Object & _Each[Integer]のように&で繋げている型のことです。

その前にUnion(|)とは

String | Symbolのように、|で繋いだ型をUnion型と言います。こちらは理解は簡単です。

「String もしくは Symbol のどちらか一方」という解釈になります。使用頻度は多めです。

Intersectionの使い方

それをふまえてObject & _Each[Integer]は何を意味しているのでしょうか?

Objectかつ_Eachインターフェースを持つってどういうこと?Objecteachメソッドはないよ???」

というのが、私が最初にIntersection型に抱いた感想でした。

型について学んでいくうちに、何となく言語化できたパターンがあったので書き残しておきます。

最低限classを保証したい

一番多い利用ケースは、BasicObjectの排除だと思います。 例えばeachメソッドを使うから_Eachを指定したいんだけど、nil?is_a?等もじつは使ってた場合。 この場合、_Each_Nil_Isaなんていう別のInterfaceを作ってもいいのですが、このnil?is_a?を使用しているのは、インターフェースというよりは単にObject class以上のオブジェクト(ややこしい)を想定しているだけである場合が多いです。こんな場合に使えるのがObject &という指定方法です。これで

「最低限Object classのインスタンスであることを保証しつつ、eachメソッドが使えること」

という型を表現できます。「最低限Object classのインスタンスである」ということは、言い換えれば「BasicObjectは除く」とも考えられます。

型同士を足し算したい

では本当にインターフェースとして複数のメソッドを使えることを表したい場合はどのような方法があるでしょうか?

1) 新しいinterfaceを作る

新しく以下のようなinterfaceを作るパターンです。

interface _Each_Nil_Isa
  def each: 略
  def nil?: 略
  def is_a?: 略
end

これでもいいのですが、もし_Eachが修正されたらそれに追従したい場合もありますよね。

2) 新しいinterfaceを作りつつ、元からあるものはincludeする

それならば

interface _Each_Nil_Isa
  include _Each
  def nil?: ...
  def is_a?: ...
end

のように元からあるインターフェースはincludeで合成して使用するのもアリだと思います。

3) Intersectionを使う

さらに別の方法がIntersectionを使用する方法です。

2)の方法は実は間違っていて、実際は型変数を使用します。

interface _Each_Nil_Isa[T]
  include _Each[T]
  def nil?: ...
  def is_a?: ...
end

しかしながらnil?is_a?は型変数を使用していません。_Eachにしか型変数は必要無い点がモヤりますよね。

そこで、新しいinterfaceを作るけど_Eachは除外してみます。

interface _Nil_Isa
  def nil?: ...
  def is_a?: ...
end

さらにこれを_Eachとくっつければいいわけです。

_Each[Integer] & _Nil_Isa

こんな感じで、型同士を足し算したいときに&を使えると覚えておくとよさそうです。

要はフィルター

2つの手法はどちらもif文の&&のように、単に条件を追加してフィルターしているとも考えられます。

Foo & _ToF

という型だったら、Foo型でフィルターしつつ、かつ_ToFでもフィルターしています。こう考えると、Intersection型とも友達になれるんじゃないでしょうか。

Intersection型の制約

なんでもかんでもIntersectionで繋げれるでしょうか?

例えばString & Integerとすると、「Stringのインスタンスであり、かつIntegerのインスタンスである」という意味になります。しかし、このインスタンスは存在し得ません。Rubyは1つのclassのみ継承できるので、Intersectionに書けるclassは1つまでに必然的になってしまいます。

moduleはいくらでもIntersectionで繋げられます。moduleはいくらでもincludeできるので、すべてのmoduleがincludeされていればいいわけです。

interfaceもいくらでもIntersectionで繋げられます。class/moduleはis_a?で調べてtrueが返ってくるインスタンスであればよいですが、interfaceは指定のメソッドを持っていればいいだけです。_ToF & _ToI & _ToSとつなげても全く不自然ではありません。

つまり

「Intersection型は、繋げれるのはclassは1まで、あとはいくらでもOK」

というルールが見えてきます。

moduleのself-typeもIntersection

moduleの定義にmoduleのselfが何かを設定できます。例えばEnumerable moduleは _Each をself-typeにしており、eachメソッドが使えることを保証しています。

self-typeの説明は最小限にして、このself-type、実は何個でも書けます。

module Foo : _ToI, _ToF, _ToA
end

この複数のself-typeは全ての条件を満たしていることを表しているので、Intersectionの関係にあります。 実際にsteepで存在しないメソッドを書いてみるとself-typeをのぞけるので見てみてください。

まとめ

Intersectionはともだち

RBSがテストになるとおもしろいんじゃないか日記6

前回までのあらすじ

RBSがテストになるとおもしろいんじゃないか日記1 - スペクトラム

RBSがテストになるとおもしろいんじゃないか日記2 - スペクトラム

RBSがテストになるとおもしろいんじゃないか日記3 - スペクトラム

RBSがテストになるとおもしろいんじゃないか日記4 - スペクトラム

RBSがテストになるとおもしろいんじゃないか日記5 - スペクトラム

追加コードなしにこだわる理由

RaaPでは基本的な姿勢として、テストコードを書かなくても動くことを目指しています。(もちろんRBSは書く) その理由を言語化してみます。

そもそもここで言うコードとは

プロパティベーステストはプロパティというルールをコードで記述して、このルールがどんな入力でも成立するかを検証するテスト手法です。 QuickCheckやproper・propcheckを読んだなら、当然以下のようなコードを思いつくでしょう。

it "2回reverseしたら元に戻るはず" do
  forall(list(integer())) do |ary|
    expect(ary.reverse.reverse).to eq(ary)
  end
end

RaaPではこういうことをできなくはない(内部のテストでは使用している)のですが、あえてファーストサポートとしないように意識しており、あくまでCLIツールとしての利用をファーストサポートとして考えています。

利用しやすさ

利用に関して、障壁は少なければ少ないほど良いです。コード書かなくていいなら最も利用しやすく、lsコマンドやtopコマンドのようにカジュアルに使うことができます。

利用される数が多ければ多いほど、良いフィードバックも増えてプロダクトの成長にもつながります。

互換性の問題からの解放

rspecではv2からv3で大きな非互換性があったことが記憶に新しいと思いますが、、、10年以上前(2013年)のことでした……。まじか。

ともかく、コードあるところに互換性の問題は常にあります。一度決めたAPIはなかなか変えれません。

しかしながらコードがなければ互換性に気を揉まなくても良くなります。よりより設計を思いついたらすぐに取り入れることができます。これはプロダクトの成長スピードに関わってくると思います。

CLIの挙動の非互換はもちろんありえますが、コードの非互換よりは小さな問題でしょう。

縛り

縛りを設けることで呪力の総量を上げるのはプログラマーの基本中の基本です。

目的を絞りコンセプトを一貫させることによって、より深いプロダクトになるはずです。

ほんとに何とかなるの?

当然全てのパターンでは成立しません。例えばArray.newはマイナスの値を入れるとエラーになるし、RBSとしてパースできる文字列しか::RBS::Parser.parse_typeは受け付けません。このような場合ではカスタムコードを書かざるをえないでしょう。なんならRaaP本体ではRubyの基本的なclassに対して結構カスタムコードを書いてしまっています。

しかしながら、書いてしまうとこれまで否定してきた問題に直面することになります。

この辺の問題とどうバランスを取っていくのかが、今後の見どころです。