0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SteepコードリーディングAdvent Calendar 2024

Day 1

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

Last updated at Posted at 2024-11-30

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

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

bundle exec steep check

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

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

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

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

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

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

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

Steep::CLI

Steep::CLI#run

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

def run
  process_global_options or return 1
  setup_command or return 1

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

Steep::CLI#process_global_options

OptionParseroptparseのクラス。コマンドラインから渡されたオプション毎の処理が定義されている。

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へのコマンドの代入と有効なコマンドかどうかのチェックを行なっている。

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を実行している。指定したオプションの挙動が怪しければこの辺りを見ると良さそう。

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

Steep::Drivers::Check#run

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

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 を追っていく。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?