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