Static analysis is finally coming to Ruby, so I'll give it a try. [RBS] [Type Prof] [Steep]

Introduction

The other day, Ruby 3.0.0-preview2 was released on December 8th. https://www.ruby-lang.org/ja/news/2020/12/08/ruby-3-0-0-preview2-released/ The word "static analysis" is in it! In this article, I will immediately use "RBS", "TypeProf", and "Steep" related to static analysis of Ruby. I will describe how to use it without touching on the detailed theory and internal implementation.

Use Ruby 3.0.0-preview2

Update rbenv

If you have not installed it, please install it.

shell


$ brew upgrade rbenv ruby-build

3.0.0-Installing preview2

shell


$ rbenv install 3.0.0-preview2
# 3.0.0-Compromise with preview1
$ rbenv install 3.0.0-preview1
$ rbenv local 3.0.0-preview1

Post-work test project configuration

root/
  - lib/
    - user.rb
  - sig/
    - user.rbs
  - .ruby-version
  - main.rb
  - Gemfile
  - Gemfile.lock
  - Steepfile

Addition of RBS, TypeProf, Steep

Then install TypeProf and Steep used for type checking.

shell


#Creating a Gemfile template
bundle init

Gemfile


# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

# gem "rails"
gem "rbs"
gem "typeprof"
gem "steep"

shell


#Install rbs, typeprof and steep
bundle install

Add User class

lib/user.rb


class User
  def initialize(name:, age:)
    @name, @age = name, age
  end
  attr_reader :name, :age
end

Add main

main.rb


require "./lib/user"

def main
  user = User.new(name: "John", age: 20)
  puts user.name
end

if __FILE__ == $0
  main
end

Operation check

shell


$ ruby main.rb
John

RBS

RBS is a language for writing types of Ruby programs. Static analysis tools such as TypeProf can analyze Ruby programs more accurately by using RBS. RBS defines the types of classes and modules in Ruby programs. You can describe methods, instance variables, constants and their types, and relationships such as inheritance and mixins. RBS is designed to support common patterns in Ruby programs and provides features such as union types, method overloads, and generics. In addition, "interface type" supports duck typing. Ruby 3.0 includes rbs gem, a library for processing type definitions written in this RBS language.

In other words, to summarize it very easily

--Define the type of Ruby program in RBS. --You can also define the structure of the program. --RBS is included from Ruby 3.0.

Try using RBS

Write the type definition file (.rbs)

sig/user.rbs


class User
  attr_reader name : String
  attr_reader age : Integer
  def initialize : (name: String, age: Integer) -> nil
end

It also defines the type of main.rb.

sig/main.rbs


class Object
  private
  def main: -> nil
end

Since I was able to write the type information, I will check it with steep.

Steep settings

shell


$ bundle exec steep init

Steepfile


# target :lib do
#   signature "sig"
#
#   check "lib"                       # Directory name
#   check "Gemfile"                   # File name
#   check "app/models/**/*.rb"        # Glob
#   # ignore "lib/templates/*.rb"
#
#   # library "pathname", "set"       # Standard libraries
#   # library "strong_json"           # Gems
# end

# target :spec do
#   signature "sig", "sig-private"
#
#   check "spec"
#
#   # library "pathname", "set"       # Standard libraries
#   # library "rspec"
# end

target :lib do
  signature "sig"
  check "lib"
  check "main.rb"
end

I specified the type information in the sig directory and the check targets in the lib directory and main.rb.

Check with Steep

shell


$ bundle exec steep check

If you exit without displaying anything, there is no problem.

Try changing to an implementation different from the type information

main.rb


require "./lib/user"

def main
  user = User.new(name: "John", age: "20") #Change age to String
  puts user.name
end

if __FILE__ == $0
  main
end

shell


$ bundle exec steep check
main.rb:4:37: IncompatibleAssignment: lhs_type=::Integer, rhs_type=::String ("20")
  ::String <: ::Integer
   ::Object <: ::Integer
    ::BasicObject <: ::Integer
==> ::BasicObject <: ::Integer does not hold
zsh: exit 1     bundle exec steep check

The mounting location and contents are displayed. I implemented a simple implementation of type information and checking. Next, we will use TypeProf, which makes it easier to write type information.

TypeProf

TypeProf is a type analysis tool included in the Ruby package. The current main use of TypeProf is a type of type inference. Enter plain Ruby code without type annotations, parse what methods are defined and used, and generate a prototype of the type signature in RBS format.

Since the installation is complete, I will use it immediately.

Try using TypeProf

shell


$ bundle exec typeprof lib/user.rb
# Classes
class User
  attr_reader name: untyped
  attr_reader age: untyped
  def initialize: (name: untyped, age: untyped) -> [untyped, untyped]
end

In this way, it will be automatically generated in RBS format to some extent from the implementation. However, it is untyped because it is not possible to know which type is passed to name or age only by the definition of User class.

Now, let's run typeprof on main.rb, which is actually using the User class.

shell


$ bundle exec typeprof main.rb
# Classes
class Object
  private
  def main: -> nil
end

class User
  attr_reader name: String
  attr_reader age: Integer
  def initialize: (name: String, age: Integer) -> [String, Integer]
end

In main.rb,"John"is actually specified for name and 20 is specified for age, so the type information of the User class is accurate. However, the return value of initialize is[String, Integer]. In fact, the expected return value is nil, so we'll modify the implementation.

Implementation improvements from TypeProf output

lib/user.rb


class User
  def initialize(name:, age:)
    @name, @age = name, age
    return #Clarify that there is no return value by describing return
  end
  attr_reader :name, :age
end

Try running typeprof again.

shell


$ bundle exec typeprof main.rb
# Classes
class Object
  private
  def main: -> nil
end

class User
  attr_reader name: String
  attr_reader age: Integer
  def initialize: (name: String, age: Integer) -> nil
end

The return value of initialize is now nil!

Flow summary

  1. Implementation
  2. Generate RBS with TypeProf
  3. Check with Steep

▼ Repository of this content https://github.com/naro143/ruby-static-program-analysis-trial

Summary

I'm happy to see the addition of static analysis capabilities to Ruby. I'm not used to the description of RBS, but if I use TypeProf well, it seems to be easier to some extent. I'm also happy that the implementation will be more explicit so that TypeProf can be generated correctly. Personally, there is no problem with the behavior such as "hit a method that does not exist and nil is returned", but it is also static that implementations that are not intended and multiple candidates are proposed at the time of definition jump. I hope it will be improved by the analysis.

Recommended Posts

Static analysis is finally coming to Ruby, so I'll give it a try. [RBS] [Type Prof] [Steep]
Easily try Ruby 3.0.0 type checking (rbs)