TypeProfでRedmineのコードを解析した結果
自分用の備忘録。
最新のTypeProf*1でRedmineのコードを解析した結果を記録しておく。
準備
v6.1.1のコードを取得し、gem_rbs_collectionやrbs_railsのセットアップを実施する。
スクリプトから準備の部分だけ抜粋すると以下のような感じ。
unless File.exist?("config/database.yml") File.write("config/database.yml", <<~YAML) development: adapter: sqlite3 database: db/development.sqlite3 YAML end gemfile = File.read("Gemfile") system("bundle add rbs_rails -v '0.13.1'", exception: true) unless gemfile.include?("rbs_rails") system("bin/rails db:migrate", exception: true) unless File.exist?("db/schema.rb") system("bundle exec rbs collection init", exception: true) unless File.exist?("rbs_collection.yaml") system("bundle exec rbs collection install", exception: true) unless File.exist?("rbs_collection.lock.yaml") system("bundle exec rbs_rails all", exception: true) unless Dir.exist?("sig/rbs_rails")
実行コマンド
app, lib, test, sig を対象に指定して実行。
$ bundle exec typeprof -o redmine_typeprof.out --show-stats app lib test sig
結果
TypeProf の実行時間は 1m4s でした。 --show-stats の出力は以下の通り。
# TypeProf Evaluation Statistics # # Total methods: 9764 # Fully typed: 3826 # Partially typed: 1070 # Fully untyped: 4868 # # Parameter slots: 3617 # Typed: 2074 (57.3%) # Untyped: 1543 (42.7%) # # Return slots: 9680 # Typed: 4367 (45.1%) # Untyped: 5313 (54.9%) # # Block parameter slots: 110 # Typed: 78 (70.9%) # Untyped: 32 (29.1%) # # Block return slots: 108 # Typed: 48 (44.4%) # Untyped: 60 (55.6%) # # Constants: 256 # Typed: 231 (90.2%) # Untyped: 25 (9.8%) # # Instance variables: 1105 # Typed: 700 (63.3%) # Untyped: 405 (36.7%) # # Class variables: 39 # Typed: 38 (97.4%) # Untyped: 1 (2.6%) # # Global variables: 5 # Typed: 5 (100.0%) # Untyped: 0 (0.0%) # # Overall: 7541/14920 typed (50.5%) # 7379/14920 untyped (49.5%)
app/ サブディレクトリ別 typed率
| サブディレクトリ | files | methods | typed/total slots | typed率 |
|---|---|---|---|---|
| app/models/ | 85 | 1,596 | 2,044 / 3,050 | 67.0% |
| app/controllers/ | 56 | 552 | 587 / 1,071 | 54.8% |
| app/helpers/ | 41 | 429 | 578 / 1,190 | 48.6% |
| app/jobs/ | 2 | 9 | 10 / 24 | 41.7% |
| app/validators/ | 1 | 1 | 1 / 4 | 25.0% |
| app/ 合計 | 185 | 2,587 | 3,220 / 5,339 | 60.3% |
untyped の主な要因
- actionpackのRBSに
untypedが多い - helperは主にview(ERB)から呼ばれるが、
*.erbはTypeProfの解析対象外 - gemの提供するメソッドの型定義が存在しない(gem_rbs_collectionに存在しない)
ActiveSupport::Concern, included,sendなどの動的メソッド
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
RBSのdocs/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 を辿る処理をシンプルに実装する
Ruby の Enumerator を使うと簡単に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) に変わります。
既存の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 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
の続き。
回答 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 は覚えておくと便利そう。