2018-04-02

did_you_meanコードリーディング

did_you_mean(1.2.0)のコードリーディングをしました。

まずlib/did_you_mean.rbでNameErrorとKeyErrorにDidYouMean::Correctableをprependします

module DidYouMean
# ...
  SPELL_CHECKERS = Hash.new(NullChecker)
  SPELL_CHECKERS.merge!({
    "NameError"     => NameErrorCheckers,
    "NoMethodError" => MethodNameChecker,
    "KeyError"      => KeyErrorChecker
  })

  NameError.prepend DidYouMean::Correctable
  KeyError.prepend DidYouMean::Correctable

DidYouMean::Correctableは以下のように定義されています。to_sメソッドを定義しており、PlainFormatter.message_forでエラーメッセージを追加しています。引数のcorrectionsはNameErrorの場合はNameErrorCheckers#correctionsになります。

module DidYouMean
  module Correctable
    def original_message
      method(:to_s).super_method.call
    end

    def to_s
      msg = super.dup

      if !cause.respond_to?(:corrections) || cause.corrections.empty?
        msg << DidYouMean.formatter.message_for(corrections)
      end

      msg
    rescue
      super
    end

    def corrections
      spell_checker.corrections
    end

    def spell_checker
      @spell_checker ||= SPELL_CHECKERS[self.class.to_s].new(self)
    end
  end
end

NameErrorCheckers#newはメッセージの内容でクラス名(定数)の誤りなのか変数名、メソッド名の誤りなのかを判別しClassNameCheckerやVariableNameCheckerのクラスをインスタンス化します。

module DidYouMean
  class << (NameErrorCheckers = Object.new)
    def new(exception)
      case exception.original_message
      when /uninitialized constant/
        ClassNameChecker
      when /undefined local variable or method/,
           /undefined method/,
           /uninitialized class variable/,
           /no member '.*' in struct/
        VariableNameChecker
      else
        NullChecker
      end.new(exception)
    end
  end
end

VariableNameCheckerはコンストラクタでインスタンス変数名、クラス変数名、メソッド名を取得し、#correctionsではそれらを引数にSpellChecker#correctを呼び出します。

module DidYouMean
  class VariableNameChecker
    attr_reader :name, :method_names, :lvar_names, :ivar_names, :cvar_names

    NAMES_TO_EXCLUDE = { 'foo' => [:fork] }
    NAMES_TO_EXCLUDE.default = []
    RB_PREDEFINED_OBJECTS = [:false, :true, :nil]

    def initialize(exception)
      @name       = exception.name.to_s.tr("@", "")
      @lvar_names = exception.respond_to?(:local_variables) ? exception.local_variables : []
      receiver    = exception.receiver

      @method_names = receiver.methods + receiver.private_methods
      @ivar_names   = receiver.instance_variables
      @cvar_names   = receiver.class.class_variables
      @cvar_names  += receiver.class_variables if receiver.kind_of?(Module)
    end

    def corrections
      @corrections ||= SpellChecker
                     .new(dictionary: (RB_PREDEFINED_OBJECTS + lvar_names + method_names + ivar_names + cvar_names))
                     .correct(name) - NAMES_TO_EXCLUDE[@name]
    end
  end
end

SpellChecker#correctはdid_you_meanのメイン処理で、誤った文字列とインスタンス変数などの各変数との類似度を計算し、閾値以上の類似度の文字列を取り出します。

module DidYouMean
  class SpellChecker
    def initialize(dictionary: )
      @dictionary = dictionary
    end

    def correct(input)
      input     = normalize(input)
      threshold = input.length > 3 ? 0.834 : 0.77

      words = @dictionary.select {|word| JaroWinkler.distance(normalize(word), input) >= threshold }
      words.reject! {|word| input == word.to_s }
      words.sort_by! {|word| JaroWinkler.distance(word.to_s, input) }
      words.reverse!

      # Correct mistypes
      threshold   = (input.length * 0.25).ceil
      corrections = words.select {|c| Levenshtein.distance(normalize(c), input) <= threshold }

      # Correct misspells
      if corrections.empty?
        corrections = words.select do |word|
          word   = normalize(word)
          length = input.length < word.length ? input.length : word.length

          Levenshtein.distance(word, input) < length
        end.first(1)
      end

      corrections
    end

類似度はジャロウィンクラー距離とレーベンシュタイン距離を使って計測、フィルタリングしています。

PlainFormatter.message_forは類似しているクラス名、定数名、変数名、メソッド名の配列を引数に文字列を生成しています。

module DidYouMean
  class PlainFormatter
    def message_for(corrections)
      corrections.empty? ? "" : "\nDid you mean?  #{corrections.join("\n               ")}"
    end
  end
end
このエントリーをはてなブックマークに追加