Steepコードリーディング(1日目)

本アドベントカレンダーではRubyの型検査機[Steep](https://github.com/soutaro/steep)のコードリーディングを行う。これは不具合等の発見時にコードの変更をリクエストできるようになることを目的としている。

# Steepコードリーディング(1日目)

```shell
bundle exec steep check
```

で実行されるコードをざっくりと追い、型検査の本体の実装にあたりをつける。

## `bundle exec steep check`コマンドの実装を追う

コマンドラインから実行できるファイルはgemspecのexecutablesで指定される。デフォルトだと`/exe`ディレクトリ内のファイルが指定されているが、Steepでもそのようになっている。

```rb
spec.executables   = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
```

https://github.com/soutaro/steep/blob/290ab9a3d023f1ed8011fb720c420c611e5473b9/steep.gemspec#L29C8-L29C19

`bundle exec steep ...`で `/exe/steep` が実行される。`/exe/steep`の中を見てみると、コマンドの実装自体は`Steep::CLI`クラスで行われていそうだ。

```rb
exit Steep::CLI.new(argv: ARGV.dup, stdout: STDOUT, stderr: STDERR, stdin: STDIN).run
```

https://github.com/soutaro/steep/blob/290ab9a3d023f1ed8011fb720c420c611e5473b9/exe/steep#L11

ここでの`ARGV.dup` は `["check"]`となるはず。

## `Steep::CLI`

https://github.com/soutaro/steep/blob/290ab9a3d023f1ed8011fb720c420c611e5473b9/lib/steep/cli.rb

### `Steep::CLI#run`

最終的に`Steep::CLI#process_ckeck`を実行する。

```rb
def run
  process_global_options or return 1
  setup_command or return 1

  __send__(:"process_#{command}")
end
```

### `Steep::CLI#process_global_options`

`OptionParser`は[optparse](https://docs.ruby-lang.org/ja/latest/library/optparse.html)のクラス。コマンドラインから渡されたオプション毎の処理が定義されている。

```rb
def process_global_options
  OptionParser.new do |opts|
    opts.banner = <<~USAGE
      Usage: steep [options]

      available commands: #{CLI.available_commands.join(', ')}

      Options:
    USAGE

    opts.on("--version") do
      process_version
      exit 0
    end

    handle_logging_options(opts)
  end.order!(argv)

  true
end
```

### `Steep::CLI#setup_command`

`argv.shift&.to_sym`は`:check`となる。ここでは`@command`へのコマンドの代入と有効なコマンドかどうかのチェックを行なっている。

```rb
def setup_command
  return false unless command = argv.shift&.to_sym
  @command = command

  if CLI.available_commands.include?(@command) || @command == :worker || @command == :vendor
    true
  else
    stderr.puts "Unknown command: #{command}"
    stderr.puts "  available commands: #{CLI.available_commands.join(', ')}"
    false
  end
end
```

### `Steep::CLI#process_ckeck`

`Steep::CLI#process_ckeck`では`steep check`のオプションの処理を行いつつ、`Drivers::Check#run`を実行している。指定したオプションの挙動が怪しければこの辺りを見ると良さそう。

```rb
def process_check
  Drivers::Check.new(stdout: stdout, stderr: stderr).tap do |command|
    OptionParser.new do |opts|
      opts.banner = "Usage: steep check [options] [sources]"

      opts.on("--steepfile=PATH") {|path| command.steepfile = Pathname(path) }
      opts.on("--with-expectations[=PATH]", "Type check with expectations saved in PATH (or steep_expectations.yml)") do |path|
        command.with_expectations_path = Pathname(path || "steep_expectations.yml")
      end
      opts.on("--save-expectations[=PATH]", "Save expectations with current type check result to PATH (or steep_expectations.yml)") do |path|
        command.save_expectations_path = Pathname(path || "steep_expectations.yml")
      end
      opts.on("--severity-level=LEVEL", /^error|warning|information|hint$/, "Specify the minimum diagnostic severity to be recognized as an error (defaults: warning): error, warning, information, or hint") do |level|
        command.severity_level = level.to_sym
      end

      opts.on("--group=GROUP", "Specify target/group name to type check") do |arg|
        # @type var group: String
        target, group = arg.split(".")
        target or raise
        command.active_group_names << [target.to_sym, group&.to_sym]
      end

      opts.on("--[no-]type-check", "Type check Ruby code") do |v|
        command.type_check_code = v ? true : false
      end

      opts.on("--validate=OPTION", ["skip", "group", "project", "library"], "Validation levels of signatures (default: group, options: skip,group,project,library)") do |level|
        case level
        when "skip"
          command.validate_group_signatures = false
          command.validate_project_signatures = false
          command.validate_library_signatures = false
        when "group"
          command.validate_group_signatures = true
          command.validate_project_signatures = false
          command.validate_library_signatures = false
        when "project"
          command.validate_group_signatures = true
          command.validate_project_signatures = true
          command.validate_library_signatures = false
        when "library"
          command.validate_group_signatures = true
          command.validate_project_signatures = true
          command.validate_library_signatures = true
        end
      end

      handle_jobs_option command.jobs_option, opts
      handle_logging_options opts
    end.parse!(argv)

    setup_jobs_for_ci(command.jobs_option)

    command.command_line_patterns.push *argv
  end.run
end
```

## `Steep::Drivers::Check`

https://github.com/soutaro/steep/blob/290ab9a3d023f1ed8011fb720c420c611e5473b9/lib/steep/drivers/check.rb

### `Steep::Drivers::Check#run`

`load_config`は`Steepfile`を読んで何かするものっぽい。`Rainbow`はターミナルへの出力をカラフルにする [ku1ik/rainbow](https://github.com/ku1ik/rainbow) のクラス。(client_read, server_write)と(server_read, client_write)でそれぞれ相互に繋がったIOオブジェクトを持ち、それ引数に`LSP::Transport::Io::Reader`や`LSP::Transport::Io::Writer`のインスタンスを生成している。これらは [Shopify/ruby-lsp](https://github.com/Shopify/ruby-lsp) のクラス。

```rb
def run
  project = load_config()

  stdout.puts Rainbow("# Type checking files:").bold
  stdout.puts

  client_read, server_write = IO.pipe
  server_read, client_write = IO.pipe

  client_reader = LSP::Transport::Io::Reader.new(client_read)
  client_writer = LSP::Transport::Io::Writer.new(client_write)

  server_reader = LSP::Transport::Io::Reader.new(server_read)
  server_writer = LSP::Transport::Io::Writer.new(server_write)

  typecheck_workers = Server::WorkerProcess.start_typecheck_workers(
    steepfile: project.steepfile_path,
    args: command_line_patterns,
    delay_shutdown: true,
    steep_command: jobs_option.steep_command,
    count: jobs_option.jobs_count_value
  )

  master = Server::Master.new(
    project: project,
    reader: server_reader,
    writer: server_writer,
    interaction_worker: nil,
    typecheck_workers: typecheck_workers
  )
  master.typecheck_automatically = false
  master.commandline_args.push(*command_line_patterns)

  main_thread = Thread.start do
    Thread.current.abort_on_exception = true
    master.start()
  end

  Steep.logger.info { "Initializing server" }
  initialize_id = request_id()
  client_writer.write({ method: :initialize, id: initialize_id, params: DEFAULT_CLI_LSP_INITIALIZE_PARAMS })
  wait_for_response_id(reader: client_reader, id: initialize_id)

  params = { library_paths: [], signature_paths: [], code_paths: [] } #: Server::CustomMethods::TypeCheck::params

  if command_line_patterns.empty?
    files = Server::TargetGroupFiles.new(project)
    loader = Services::FileLoader.new(base_dir: project.base_dir)

    project.targets.each do |target|
      target.new_env_loader.each_dir do |_, dir|
        RBS::FileFinder.each_file(dir, skip_hidden: true) do |path|
          files.add_library_path(target, path)
        end
      end

      loader.each_path_in_target(target) do |path|
        files.add_path(path)
      end
    end

    project.targets.each do |target|
      target.groups.each do |group|
        if active_group?(group)
          load_files(files, group.target, group, params: params)
        end
      end
      if active_group?(target)
        load_files(files, target, target, params: params)
      end
    end
  else
    command_line_patterns.each do |pattern|
      path = Pathname(pattern)
      path = project.absolute_path(path)
      next unless path.file?
      if target = project.target_for_source_path(path)
        params[:code_paths] << [target.name.to_s, path.to_s]
      end
      if target = project.target_for_signature_path(path)
        params[:signature_paths] << [target.name.to_s, path.to_s]
      end
    end
  end

  Steep.logger.info { "Starting type check with #{params[:code_paths].size} Ruby files and #{params[:signature_paths].size} RBS signatures..." }
  Steep.logger.debug { params.inspect }

  request_guid = SecureRandom.uuid
  Steep.logger.info { "Starting type checking: #{request_guid}" }
    client_writer.write(Server::CustomMethods::TypeCheck.request(request_guid, params))

  diagnostic_notifications = [] #: Array[LanguageServer::Protocol::Interface::PublishDiagnosticsParams]
  error_messages = [] #: Array[String]

  response = wait_for_response_id(reader: client_reader, id: request_guid) do |message|
    case
    when message[:method] == "textDocument/publishDiagnostics"
      ds = message[:params][:diagnostics]
      ds.select! {|d| keep_diagnostic?(d, severity_level: severity_level) }
      if ds.empty?
        stdout.print "."
      else
        stdout.print "F"
      end
      diagnostic_notifications << message[:params]
      stdout.flush
    when message[:method] == "window/showMessage"
      # Assuming ERROR message means unrecoverable error.
      message = message[:params]
      if message[:type] == LSP::Constant::MessageType::ERROR
        error_messages << message[:message]
      end
    end
  end

  Steep.logger.info { "Finished type checking: #{response.inspect}" }

  Steep.logger.info { "Shutting down..." }

  shutdown_exit(reader: client_reader, writer: client_writer)
  main_thread.join()

  stdout.puts
  stdout.puts

  if error_messages.empty?
    loader = Services::FileLoader.new(base_dir: project.base_dir)
    all_files = project.targets.each.with_object(Set[]) do |target, set|
      set.merge(loader.load_changes(target.source_pattern, command_line_patterns, changes: {}).each_key)
      set.merge(loader.load_changes(target.signature_pattern, changes: {}).each_key)
    end.to_a

    case
    when with_expectations_path
      print_expectations(project: project,
                         all_files: all_files,
                         expectations_path: with_expectations_path,
                         notifications: diagnostic_notifications)
    when save_expectations_path
      save_expectations(project: project,
                        all_files: all_files,
                        expectations_path: save_expectations_path,
                        notifications: diagnostic_notifications)
    else
      print_result(project: project, notifications: diagnostic_notifications)
    end
  else
    stdout.puts Rainbow("Unexpected error reported. 🚨").red.bold
    1
  end
rescue Errno::EPIPE => error
  stdout.puts Rainbow("Steep shutdown with an error: #{error.inspect}").red.bold
  return 1
end
```

次回は `load_config` を追っていく。