# typed: true # frozen_string_literal: true module Spoom module Sorbet module Errors extend T::Sig DEFAULT_ERROR_URL_BASE = "https://srb.help/" # Parse errors from Sorbet output class Parser extend T::Sig HEADER = [ "👋 Hey there! Heads up that this is not a release build of sorbet.", "Release builds are faster and more well-supported by the Sorbet team.", "Check out the README to learn how to build Sorbet in release mode.", "To forcibly silence this error, either pass --silence-dev-message,", "or set SORBET_SILENCE_DEV_MESSAGE=1 in your shell environment.", ] sig { params(output: String, error_url_base: String).returns(T::Array[Error]) } def self.parse_string(output, error_url_base: DEFAULT_ERROR_URL_BASE) parser = Spoom::Sorbet::Errors::Parser.new(error_url_base: error_url_base) parser.parse(output) end sig { params(error_url_base: String).void } def initialize(error_url_base: DEFAULT_ERROR_URL_BASE) @errors = [] @error_line_match_regex = error_line_match_regexp(error_url_base) @current_error = nil end sig { params(output: String).returns(T::Array[Error]) } def parse(output) output.each_line do |line| break if /^No errors! Great job\./.match?(line) break if /^Errors: /.match?(line) next if HEADER.include?(line.strip) next if line == "\n" if (error = match_error_line(line)) close_error if @current_error open_error(error) next end append_error(line) if @current_error end close_error if @current_error @errors end private sig { params(error_url_base: String).returns(Regexp) } def error_line_match_regexp(error_url_base) url = Regexp.escape(error_url_base) %r{ ^ # match beginning of line (\S[^:]*) # capture filename as something that starts with a non-space character # followed by anything that is not a colon character : # match the filename - line number seperator (\d+) # capture the line number :\s # match the line number - error message separator (.*) # capture the error message \s#{url} # match the error code url prefix (\d+) # capture the error code $ # match end of line }x end sig { params(line: String).returns(T.nilable(Error)) } def match_error_line(line) match = line.match(@error_line_match_regex) return unless match file, line, message, code = match.captures Error.new(file, line&.to_i, message, code&.to_i) end sig { params(error: Error).void } def open_error(error) raise "Error: Already parsing an error!" if @current_error @current_error = error end sig { void } def close_error raise "Error: Not already parsing an error!" unless @current_error @errors << @current_error @current_error = nil end sig { params(line: String).void } def append_error(line) raise "Error: Not already parsing an error!" unless @current_error @current_error.more << line end end class Error include Comparable extend T::Sig sig { returns(T.nilable(String)) } attr_reader :file, :message sig { returns(T.nilable(Integer)) } attr_reader :line, :code sig { returns(T::Array[String]) } attr_reader :more sig do params( file: T.nilable(String), line: T.nilable(Integer), message: T.nilable(String), code: T.nilable(Integer), more: T::Array[String] ).void end def initialize(file, line, message, code, more = []) @file = file @line = line @message = message @code = code @more = more end # By default errors are sorted by location sig { params(other: T.untyped).returns(Integer) } def <=>(other) return 0 unless other.is_a?(Error) [file, line, code, message] <=> [other.file, other.line, other.code, other.message] end sig { returns(String) } def to_s "#{file}:#{line}: #{message} (#{code})" end end sig { params(errors: T::Array[Error]).returns(T::Array[Error]) } def self.sort_errors_by_code(errors) errors.sort_by { |e| [e.code, e.file, e.line, e.message] } end end end end