SteepのAnnotationに関する備忘録 #asakusa_bashi_rbs

Steepのmanual/annotations.mdを読みながら、実際にコードを書いて覚えたことをブログにまとめる。

変数

変数の型を String? から String にするときに便利そう。

# @type var value: String
value = %w[a b c].sample
puts('Hi, ' + value)

アノテーションがない場合、 sample: () -> String? なので型検査エラーになる。

app/user.rb:2:14: [error] Cannot pass a value of type `(::String | nil)` as an argument of type `::string`
│   (::String | nil) <: ::string
│     (::String | nil) <: (::String | ::_ToStr)
│       nil <: (::String | ::_ToStr)
│         nil <: ::String
│
│ Diagnostic ID: Ruby::ArgumentTypeMismatch
│
└ puts('Hi, ' + value)
                ~~~~~

Detected 1 problem from 1 file

インスタンス変数

変数と同じく、インスタンス変数の型を変えるときに便利そう。

# ruby
class User
  def initialize(name)
    @name = name
  end

  def hi
    # @type ivar @name: String
    puts('Hi, ' + @name)
  end

  def bye
    puts('bye, ' + @name)
  end
end

# rbs
class User
  @name: String?

  def initialize: (String?) -> void

  def hi: () -> void

  def bye: () -> void
end

これは bye の方だけ型検査エラーになる。

app/user.rb:12:19: [error] Cannot pass a value of type `(::String | nil)` as an argument of type `::string`
│   (::String | nil) <: ::string
│     (::String | nil) <: (::String | ::_ToStr)
│       nil <: (::String | ::_ToStr)
│         nil <: ::String
│
│ Diagnostic ID: Ruby::ArgumentTypeMismatch
│
└     puts('bye, ' + @name)
                     ~~~~~

Detected 1 problem from 1 file

クラスとメソッド

Rubyのコード内にアノテーションを書くと、RBSなしでもSteepで型チェックできる。

# @type const User : User
class User
  # @type method say: (String, Integer) -> void
  def say(name, age)
    puts(name + age) # 型検査エラー
  end
end


User.new.say(1, 2) # ここは型エラーにならない

このコードをSteepで検査すると、 say メソッドの中だけ型検査の対象になっていることがわかる。

app/user.rb:5:16: [error] Cannot pass a value of type `::Integer` as an argument of type `::string`
│   ::Integer <: ::string
│     ::Integer <: (::String | ::_ToStr)
│       ::Integer <: ::String
│         ::Numeric <: ::String
│           ::Object <: ::String
│             ::BasicObject <: ::String
│
│ Diagnostic ID: Ruby::ArgumentTypeMismatch
│
└     puts(name + age) # 型検査エラー
                  ~~~

Detected 1 problem from 1 file

pure

メソッドに副作用がないことを明示するときに指定する。*1

# ruby
class User
  def initialize(name)
    @name = name
  end

  def name
    @name
  end
end

user = User.new(nil)
if user.name
  puts("Hi, " + user.name)
end

# rbs
class User
  def initialize: (String?) -> void

  %a{pure}
  def name: () -> String?
end

%a{pure} を指定しない場合、以下のような型検査エラーが起きる。

app/user.rb:13:16: [error] Cannot pass a value of type `(::String | nil)` as an argument of type `::string`
│   (::String | nil) <: ::string
│     (::String | nil) <: (::String | ::_ToStr)
│       nil <: (::String | ::_ToStr)
│         nil <: ::String
│
│ Diagnostic ID: Ruby::ArgumentTypeMismatch
│
└   puts("Hi, " + user.name)
                  ~~~~~~~~~

Detected 1 problem from 1 file

*1:ドキュメントには記載がない

RBSのuntyped, void, top, botの違いを理解する #asakusa_bashi_rbs

RBSdocs/syntax.mdに記載されている untyped, void, top, bot の違いを理解したけど、また忘れそうなのでブログにまとめておく。

untyped

どんな型でも代入できて、その変数を型検査の対象にしない場合に使う。

# ruby
class User
  def greet(user)
    user.something
  end
end

# rbs
class User
  def greet: (untyped) -> void
end

something メソッドの定義は存在しないのに、Steepの型検査でエラーは出ない。

No type error detected. 🫖

void

メソッドの戻り値を使わない場合に使う。

# ruby
class User
  def foo
    bar.upcase
  end

  def bar
    "hello"
  end
end

# rbs
class User
  def foo: () -> String
  def bar: () -> void
end

void である戻り値に対して upcase のメソッドを呼び出しているため、Steep の型検査でエラーが出る。

app/user.rb:3:8: [error] Type `void` does not have method `upcase`
│ Diagnostic ID: Ruby::NoMethod
│
└     bar.upcase
          ~~~~~~

top

どんな型でも代入できるが、その変数でメソッド呼び出しを禁止したい場合に使う。TypeScriptのunknown型と同じ。

# ruby
class User
  def greet(user)
    puts user.name
  end
end

user = User.new
user.greet("Pikachu")
user.greet(1)

# rbs
class User
  def greet: (top) -> void
end

型検査で引数に String と Integer を渡している部分ではエラーになっていないが、name のメソッドを呼ぶところはエラーが出る。

app/user.rb:3:14: [error] Type `top` does not have method `name`
│ Diagnostic ID: Ruby::NoMethod
│
└     puts user.name
                ~~~~

bot

例外が必ず発生するか、無限ループで終了しないメソッドで使う。TypeScriptのnever型と同じ。

# ruby
class User
  def foo
    raise NotImplementedError
  end

  def bar
    loop do
      puts "hello"
      sleep 1
    end
  end

  def buz
    puts "hi, Eevee"
  end
end

# rbs
class User
  def foo: () -> bot
  def bar: () -> bot
  def buz: () -> bot
end

Steepで型検査すると foo, bar は問題ないが、 buz は実装が誤っているためエラーが出る。

app/user.rb:13:6: [error] Cannot allow method body have type `nil` because declared as type `bot`
│   nil <: bot
│
│ Diagnostic ID: Ruby::MethodBodyTypeMismatch
│
└   def buz
        ~~~

RubyVM::AbstractSyntaxTree で Node を辿る処理をシンプルに実装する

RubyEnumerator を使うと簡単にRubyのコードを処理できて便利だったので、ブログに書いておく。

ast = RubyVM::AbstractSyntaxTree.parse(<<~RUBY)
  class User < ApplicationRecord
    def say(text)
      puts text
    end

    def sum(x, y)
      x + y
    end
  end
RUBY

enum = Enumerator.new { |y|
  nodes = ast.children
  until nodes.empty?
    nodes.select! { _1.is_a?(RubyVM::AbstractSyntaxTree::Node) }
    nodes.each { y << _1 }
    nodes = nodes.flat_map(&:children)
  end
}

method_names = enum.select { _1.type == :DEFN }.map { _1.children[0] }
pp method_names
#=> [:say, :sum]

【追記】Rails v7.1.0 で `can't be blank` が `can’t be blank` に変わる(リバートされました)

既存アプリやライブラリへの影響が大きく、この変更に対してネガティブなフィードバックも多かったためリバートされました。 github.com


概要

表題の通り、Rails v7.1.0 で APOSTROPHE (U+0027) が SINGLE QUOTATION MARK (U+2019) に変わります。

github.com

既存のRailsアプリをアップグレードする際に影響が大きそうなので、記事を書きました。

影響範囲

テストでエラーメッセージを検証していた場合、Rails v7.1.0 のアップグレードによって検証に失敗するようになります。

Expected: "can't be blank"
  Actual: "can’t be blank"

今回の変更を知らない場合、このテストのエラーメッセージだけで ' と ’ の違いを見分けるのは厳しそう。

SINGLE QUOTATION MARK (U+2019) の入力方法

日本語入力モードで「Shift + 7」を押すと入力できました。*1

変更が戻る可能性

マージされるときも「I'll merge this one and see how people will react to them during the pre-releases.」と書いてあり、マージ後にrails/rails#45463でいくつかコメントが挙がっているので、もしかすると変更がリバートされる可能性はあります。

気になる方やこの変更に対して懸念がある方は v7.1.0.rc が出る前にGitHubで絵文字リアクションやコメントを投稿しておくと良さそうです。

*1:私はJIS配列キーボード使ってるので、英字キーボードだと入力方法が異なるかもしれない

プルリクで更新されたgemの一覧を出力する

Gemfile.lock のdiffから更新されたgemを探す方法を調べたのでメモ

動作確認に使ったプルリク

github.com

GitHub APIでプルリクの情報を取得する

$ gh api '/repos/rails/rails/pulls/48955' --jq '.base.sha, .head.sha'
1e824aa8e0655b4717cf612e31010bdd808f4fdf
2d61a206f445ccd52645c8d47ce97de39a39ea9d

GitHub APIで変更前後のGemfileを取得する

$ gh api '/repos/rails/rails/contents/Gemfile.lock?ref=1e824aa8e0655b4717cf612e31010bdd808f4fdf' --jq '.content' | base64 --decode > Gemfile.lock.before
$ gh api '/repos/rails/rails/contents/Gemfile.lock?ref=2d61a206f445ccd52645c8d47ce97de39a39ea9d' --jq '.content' | base64 --decode > Gemfile.lock.after

Gemfile.lock の差分を調べる

$ BUNDLE_GEMFILE=Gemfile.lock.before ruby -rbundler -e \
  'puts ["Gemfile.lock.after", "Gemfile.lock.before"].map { Bundler::LockfileParser.new(File.read(_1)).specs }.inject(:-)'
nio4r (2.5.9)
puma (6.3.0)

Gemfileがないディレクトリだと Could not locate Gemfile or .bundle/ directory のエラーが出たので、 BUNDLE_GEMFILE を指定することでエラーを回避している。

Rubyやgit-gsubを使って正規表現で複数行を置換する part2

sinsoku.hatenablog.com

の続き。

回答 3

Ruby-0777 を指定するとファイル単位で読み込む方法を教えて頂きました。

$ cat foo.txt
foo

$ cat bar.txt
bar

$ cat buz.txt
foo
bar
buz

$ ruby -i -n0777 -e 'puts "hi, " + $_' foo.txt bar.txt buz.txt

$ cat foo.txt
hi, foo

$ cat bar.txt
hi, bar

$ cat buz.txt
hi, foo
bar
buz

詳細

-0777 のオプションは初めて知ったけど、 man ruby に記載されていました。

-0[octal]. (The digit “zero”.) Specifies the input record separator ($/) as an octal number. If no digit is given, the null character is taken as the separator. Other switches may follow the digits. -00 turns Ruby into paragraph mode. -0777 makes Ruby read whole file at once as a single string since there is no legal character with that value.

まとめ

複数のファイルを一括で編集するときに -0777 は覚えておくと便利そう。

Rubyやgit-gsubを使って正規表現で複数行を置換する

150個を超える *.tf をまとめて編集する方法を調べたので、備忘録としてまとめておく。

経緯

やりたかったこと

以下のようなコードが大量にあるとき、全てのファイルから指定のチームidを持つ team { ... } を削除したい。

resource "github_repository_collaborators" "foo" {
  repository = github_repository.foo.name
  team {
    permission = "push"
    team_id    = "rubyist"
  }
  team {
    permission = "admin"
    team_id    = "ruby-committers"
  }
}

ruby-jp に相談

Rubyが得意なのでRubyで置換する方法を調べていたが、うまくいかなかったので ruby-jp に相談した。

すると、何人かの方から回答を頂いた。

投稿した質問

Rubyでファイルの中身を置換する簡単な方法は何かあったりしないでしょうか?
ruby -e 'File.read("foo.txt").gsub(/def/, "!!").then { File.write("foo.txt", _1) }' みたいな処理を簡単に書けないものかなと。

回答1

Ruby-i -p を使う方法を教えて頂きました。

$ ruby -i -p -e '$_.gsub!(/def/, "!!")' foo.txt

これらのオプションは初めて知って勉強になった。

-i[extension] edit ARGV files in place (make backup if extension supplied)

-p assume loop like -n but print line also like sed

あと、 $_ も初めてみた。

docs.ruby-lang.org

ただ、これだと team { ... } のような複数行を処理できないため、別の方法が必要になった。

回答1-a

回答1 を参考に -i を使いつつ、複数行を取得して正規表現で置換することで目的は達成できた。

$ ruby -i -e 'puts readlines.join.gsub(/  team {[\s\w="\.\[\]]+(ruby-committers)[\s\w="\.\[\]]+}\n/, "")'

回答2

Ruby ではないですが、git-gsub を使う方法も教えて頂きました。

$ git gsub "(?s)team {[^{]+?ruby-committers.+?}" ""

正規表現もシンプルですし、 git-gsub は普段使いしやすそうなので、これも知ることができて勉強になった。

まとめ

ruby-jp に助けられたし、勉強になった。ありがとうございます。