本アドベントカレンダーでは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
OptionParser
はoptparseのクラス。コマンドラインから渡されたオプション毎の処理が定義されている。
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_config
はSteepfile
を読んで何かするものっぽい。Rainbow
はターミナルへの出力をカラフルにする ku1ik/rainbow のクラス。(client_read, server_write)と(server_read, client_write)でそれぞれ相互に繋がったIOオブジェクトを持ち、それ引数にLSP::Transport::Io::Reader
やLSP::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
を追っていく。