😺

RuboCop 1.13へバージョンアップ後、undefined method codepointsとなったときの解決法

7 min read

はじめに

以前、RuboCopを1.11から1.13へバージョンアップを行いました。
その後GitHub Actionsのreviewdogを実行すると、RuboCop実行時にundefined methodとなってしまいました。
今回はそのエラーと解決法を備忘録として残します。

使用バージョン

RuboCop 1.13
RuboCop AST 1.4.1

現象

GitHub Actionsでreviewdogを使用してRuboCopを実行すると、Style/NegatedIfElseCondition検知時に、undefined method codepoints for nil:NilClassとなりました。

 Running rubocop with reviewdog 🐶 ...
 An error occurred while Style/NegatedIfElseCondition cop was inspecting 
 ・・・
  undefined method `codepoints' for nil:NilClass
  /opt/hostedtoolcache/Ruby/2.6.7/x64/lib/ruby/gems/2.6.0/gems/unicode-display_width-2.0.0/lib/unicode/display_width.rb:11:in `of'
  /opt/hostedtoolcache/Ruby/2.6.7/x64/lib/ruby/gems/2.6.0/gems/rubocop-1.15.0/lib/rubocop/formatter/clang_style_formatter.rb:55:in `report_highlighted_area'
  /opt/hostedtoolcache/Ruby/2.6.7/x64/lib/ruby/gems/2.6.0/gems/rubocop-1.15.0/lib/rubocop/formatter/clang_style_formatter.rb:31:in `report_offense'
  /opt/hostedtoolcache/Ruby/2.6.7/x64/lib/ruby/gems/2.6.0/gems/rubocop-1.15.0/lib/rubocop/formatter/clang_style_formatter.rb:12:in `block in report_file'
  ・・・

先に結論

以下のどちらかを行うことで解決します

  • RuboCopを1.14以上にバージョンアップ
  • RuboCop ASTを1.4.2以上にバージョン

調査

問題が起きたコード

エラーログを見ると、undefined method codepoints for nil:NilClassとなったコードは、Unicode::DisplayWidthのgemのコードを指していました。

display_width.rb
    def self.of(string, ambiguous = 1, overwrite = {}, options = {})
      res = string.codepoints.inject(0){ |total_width, codepoint|
        index_or_value = INDEX
        codepoint_depth_offset = codepoint
        DEPTHS.each{ |depth|
          index_or_value         = index_or_value[codepoint_depth_offset / depth]
          codepoint_depth_offset = codepoint_depth_offset % depth
          break unless index_or_value.is_a? Array
        }
        width = index_or_value.is_a?(Array) ? index_or_value[codepoint_depth_offset] : index_or_value
        width = ambiguous if width == :A
        total_width + (overwrite[codepoint] || width || 1)
      }

      res -= emoji_extra_width_of(string, ambiguous, overwrite) if options[:emoji]
      res < 0 ? 0 : res
    end

https://github.com/janlelis/unicode-display_width/blob/ef4731c2ed9b1e884d6cff4d3ea12e88a6b8c148/lib/unicode/display_width.rb#L11

ここのres = string.codepoints.inject(0)でundefined methodとなっているようです。
stringは引数のため、呼び出し元であるRuboCopのコードを確認します。

clang_style_formatter.rb
      def report_highlighted_area(highlighted_area)
        space_area  = highlighted_area.source_buffer.slice(0...highlighted_area.begin_pos)
        source_area = highlighted_area.source
        output.puts("#{' ' * Unicode::DisplayWidth.of(space_area)}" \
                    "#{'^' * Unicode::DisplayWidth.of(source_area)}")
      end

https://github.com/rubocop/rubocop/blob/8333e8f3a3be1608a5f695918a2bc0b826a2a60e/lib/rubocop/formatter/clang_style_formatter.rb#L55

エラーログから、呼び出し元はUnicode::DisplayWidth.of(source_area)のようです。
このsource_areaのセット元であるhighlighted_area.sourceがnilだったのが問題のようです。
このhighlighted_areareport_highlighted_areaメソッドの引数のため、呼び出し元をたどります。

clang_style_formatter.rb
      def report_offense(file, offense)
        output.printf(
          "%<path>s:%<line>d:%<column>d: %<severity>s: %<message>s\n",
          path: cyan(smart_path(file)),
          line: offense.line,
          column: offense.real_column,
          severity: colored_severity_code(offense),
          message: message(offense)
        )

        begin
          return unless valid_line?(offense)

          report_line(offense.location)
          report_highlighted_area(offense.highlighted_area)
        rescue IndexError
          # range is not on a valid line; perhaps the source file is empty
        end
      end

https://github.com/rubocop/rubocop/blob/8333e8f3a3be1608a5f695918a2bc0b826a2a60e/lib/rubocop/formatter/clang_style_formatter.rb#L31

呼び出し元はreport_highlighted_area(offense.highlighted_area)となっています。

なお、このoffenseはRuboCop::Cop::Offenseクラスとなります。このクラスではRuboCopのスタイル違反を表す役割を持っています。

https://github.com/rubocop/rubocop/blob/8333e8f3a3be1608a5f695918a2bc0b826a2a60e/lib/rubocop/cop/offense.rb

それでは、offense.highlighted_areaが何を指しているのか、このOffenseクラスのhighlighted_areaメソッドを見てみます。

offense.rb
      # @api public
      #
      # @return [Parser::Source::Range]
      #   the range of the code that is highlighted
      def highlighted_area
        Parser::Source::Range.new(source_line, column, column + column_length)
      end

https://github.com/rubocop/rubocop/blob/8333e8f3a3be1608a5f695918a2bc0b826a2a60e/lib/rubocop/cop/offense.rb#L142

Parser::Source::Rangeでは、指定ソースに対する文字の範囲を表すようです。

https://github.com/whitequark/parser/blob/master/lib/parser/source/range.rb
そのため、highligted_areaメソッドでは、RuboCopで検知したソースとその範囲を持たせたクラスを返すことになります。

今回、このParser::Source::Rangeクラスのsourceメソッドでnilとなっているので、sourceメソッドを見てみます。

range.rb
      ##
      # @return [String] all source code covered by this range.
      #
      def source
        @source_buffer.slice(self.begin_pos...self.end_pos)
      end

https://github.com/whitequark/parser/blob/2cfccb9ab18d44f1cea2680a32448d1374cb7c61/lib/parser/source/range.rb#L132

Parser::Source::Rangeクラスの初期化時に渡したソースを指定範囲でスライスしています。今回はこの結果がnilとなったようです。

この結果から、RuboCopでの検知はしたけれども、検知したコードをうまく取れないということが考えられます。

Style/NegatedIfElseConditionの変更の確認

コードを見ていった結果、検知したコードを取れないことが分かったので、次はStyle/NegatedIfElseConditionの変更やエラー内容をもとに確認していきました。
その中で、1つ気になるIssueがありました。

https://github.com/rubocop/rubocop/issues/9731

このIssueを大まかにまとめると、if-else分岐の範囲取得ではコメントを含んだ範囲を取得するのですが、https://github.com/rubocop/rubocop-ast/issues/179 
で記載されているast_with_commentsメソッドの影響により、範囲取得が間違ってしまうようです。

if !a_condition
  # comment            # <== start
  flag = false         #
  do_a                 #
end                    #
                       #
if !another_condition  #
  # comment            #
  flag = false         #
  do_b                 # <== end
else
  do_c
end

このIssueは以下Pull Requestにより、ast_with_commentsメソッドを使用しない方法へ変更されました。
なお、このPull RequestはRuboCop 1.14に反映されているようです。

https://github.com/rubocop/rubocop/pull/9756

また、RuboCop ASTでも、ast_with_commentsメソッドを修正するコミットが行われ、反映されました。
このコミットはRuboCop AST 1.4.2で反映されているようです。

https://github.com/marcandre/rubocop-ast/commit/2ec51940b9b51a808cb81f5066f29ece277fd5a7

今回の、Style/NegatedIfElseConditionが検知したコードは、RuboCopの以下RSpecに記載されているようなパターンと一致していました。

  it 'works with duplicate nodes' do
    expect_offense(<<~RUBY)
      # outer comment
      do_a
      if !condition
      ^^^^^^^^^^^^^ Invert the negated condition and swap the if-else branches.
        # comment
        do_a
      else
        do_c
      end
    RUBY

https://github.com/rubocop/rubocop/blob/2053dbe34e2eb68462a6f55e3816f1f179d8b64d/spec/rubocop/cop/style/negated_if_else_condition_spec.rb#L266

そのため、これらPull Requestを反映されたバージョンにバージョンアップすることで解決可能と分かりました。

解決法

調査の結果、RuboCopやRuboCop ASTの指定バージョンに問題があることが分かったので、以下方法のどちらかを行うことで解決できます。

  • RuboCopを1.14以上にバージョンアップ
  • RuboCop ASTを1.4.2以上にバージョンアップ

おわりに

RuboCopバージョンアップ時の問題と解決法を記載しました。
調査を進めていくなかで、Style/NegatedIfElseConditionでは、コードのみを捉えた検知ではなく、コメント範囲を含んだ部分まで検知しているのかと初めて知りました。
この記事が誰かのお役に立てれば幸いです。