Merge pull request #11565 from bayandin/fuzzy-search-and-did-you-mean
Formula fuzzy search and did you mean
This commit is contained in:
commit
1595a908df
@ -31,6 +31,7 @@ end
|
||||
# vendored gems
|
||||
gem "activesupport"
|
||||
gem "concurrent-ruby"
|
||||
gem "did_you_mean" # remove when HOMEBREW_REQUIRED_RUBY_VERSION >= 2.7
|
||||
gem "mechanize"
|
||||
gem "patchelf"
|
||||
gem "plist"
|
||||
|
@ -20,6 +20,7 @@ GEM
|
||||
highline (~> 2.0.0)
|
||||
concurrent-ruby (1.1.9)
|
||||
connection_pool (2.2.5)
|
||||
did_you_mean (1.5.0)
|
||||
diff-lcs (1.4.4)
|
||||
docile (1.4.0)
|
||||
domain_name (0.5.20190701)
|
||||
@ -183,6 +184,7 @@ DEPENDENCIES
|
||||
bootsnap
|
||||
byebug
|
||||
concurrent-ruby
|
||||
did_you_mean
|
||||
mechanize
|
||||
minitest
|
||||
nokogiri
|
||||
|
@ -583,13 +583,10 @@ then
|
||||
|
||||
# Don't allow non-developers to customise Ruby warnings.
|
||||
unset HOMEBREW_RUBY_WARNINGS
|
||||
fi
|
||||
|
||||
# Disable Ruby options we don't need.
|
||||
RUBY_DISABLE_OPTIONS="--disable=did_you_mean,rubyopt"
|
||||
else
|
||||
# Don't disable did_you_mean for developers as it's useful.
|
||||
RUBY_DISABLE_OPTIONS="--disable=rubyopt"
|
||||
fi
|
||||
|
||||
if [[ -z "${HOMEBREW_RUBY_WARNINGS}" ]]
|
||||
then
|
||||
|
@ -268,7 +268,6 @@ module Homebrew
|
||||
puts "To install one of them, run (for example):\n brew install #{formulae_search_results.first}"
|
||||
end
|
||||
|
||||
ofail e.message
|
||||
if (reason = MissingFormula.reason(e.name))
|
||||
$stderr.puts reason
|
||||
return
|
||||
|
@ -91,9 +91,17 @@ class FormulaOrCaskUnavailableError < RuntimeError
|
||||
@name = name
|
||||
end
|
||||
|
||||
sig { returns(String) }
|
||||
def did_you_mean
|
||||
similar_formula_names = Formula.fuzzy_search(name)
|
||||
return "" if similar_formula_names.blank?
|
||||
|
||||
"Did you mean #{similar_formula_names.to_sentence two_words_connector: " or ", last_word_connector: " or "}?"
|
||||
end
|
||||
|
||||
sig { returns(String) }
|
||||
def to_s
|
||||
"No available formula or cask with the name \"#{name}\"."
|
||||
"No available formula or cask with the name \"#{name}\". #{did_you_mean}".strip
|
||||
end
|
||||
end
|
||||
|
||||
@ -129,7 +137,7 @@ class FormulaUnavailableError < FormulaOrCaskUnavailableError
|
||||
|
||||
sig { returns(String) }
|
||||
def to_s
|
||||
"No available formula with the name \"#{name}\"#{dependent_s}."
|
||||
"No available formula with the name \"#{name}\"#{dependent_s}. #{did_you_mean}".strip
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -2,6 +2,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "cache_store"
|
||||
require "did_you_mean"
|
||||
require "formula_support"
|
||||
require "lock_file"
|
||||
require "formula_pin"
|
||||
@ -1671,6 +1672,13 @@ class Formula
|
||||
CoreTap.instance.alias_reverse_table
|
||||
end
|
||||
|
||||
# Returns a list of approximately matching formula names, but not the complete match
|
||||
# @private
|
||||
def self.fuzzy_search(name)
|
||||
@spell_checker ||= DidYouMean::SpellChecker.new(dictionary: Set.new(names + full_names).to_a)
|
||||
@spell_checker.correct(name)
|
||||
end
|
||||
|
||||
def self.[](name)
|
||||
Formulary.factory(name)
|
||||
end
|
||||
|
@ -87,6 +87,8 @@ module Homebrew
|
||||
.search(string_or_regex)
|
||||
.sort
|
||||
|
||||
results += Formula.fuzzy_search(string_or_regex)
|
||||
|
||||
results.map do |name|
|
||||
formula, canonical_full_name = begin
|
||||
f = Formulary.factory(name)
|
||||
|
@ -127,6 +127,7 @@ class Tap
|
||||
@style_exceptions = nil
|
||||
@pypi_formula_mappings = nil
|
||||
@config = nil
|
||||
@spell_checker = nil
|
||||
remove_instance_variable(:@private) if instance_variable_defined?(:@private)
|
||||
end
|
||||
|
||||
|
@ -25,6 +25,7 @@ $:.unshift "#{path}/../#{ruby_engine}/#{ruby_version}/gems/colorize-0.8.1/lib"
|
||||
$:.unshift "#{path}/../#{ruby_engine}/#{ruby_version}/gems/highline-2.0.3/lib"
|
||||
$:.unshift "#{path}/../#{ruby_engine}/#{ruby_version}/gems/commander-4.6.0/lib"
|
||||
$:.unshift "#{path}/../#{ruby_engine}/#{ruby_version}/gems/connection_pool-2.2.5/lib"
|
||||
$:.unshift "#{path}/../#{ruby_engine}/#{ruby_version}/gems/did_you_mean-1.5.0/lib"
|
||||
$:.unshift "#{path}/../#{ruby_engine}/#{ruby_version}/gems/diff-lcs-1.4.4/lib"
|
||||
$:.unshift "#{path}/../#{ruby_engine}/#{ruby_version}/gems/docile-1.4.0/lib"
|
||||
$:.unshift "#{path}/../#{ruby_engine}/#{ruby_version}/extensions/x86_64-darwin-14/2.6.0-static/unf_ext-0.0.7.7"
|
||||
|
112
Library/Homebrew/vendor/bundle/ruby/2.6.0/gems/did_you_mean-1.5.0/lib/did_you_mean.rb
vendored
Normal file
112
Library/Homebrew/vendor/bundle/ruby/2.6.0/gems/did_you_mean-1.5.0/lib/did_you_mean.rb
vendored
Normal file
@ -0,0 +1,112 @@
|
||||
require_relative "did_you_mean/version"
|
||||
require_relative "did_you_mean/core_ext/name_error"
|
||||
|
||||
require_relative "did_you_mean/spell_checker"
|
||||
require_relative 'did_you_mean/spell_checkers/name_error_checkers'
|
||||
require_relative 'did_you_mean/spell_checkers/method_name_checker'
|
||||
require_relative 'did_you_mean/spell_checkers/key_error_checker'
|
||||
require_relative 'did_you_mean/spell_checkers/null_checker'
|
||||
require_relative 'did_you_mean/spell_checkers/require_path_checker'
|
||||
require_relative 'did_you_mean/formatters/plain_formatter'
|
||||
require_relative 'did_you_mean/tree_spell_checker'
|
||||
|
||||
# The +DidYouMean+ gem adds functionality to suggest possible method/class
|
||||
# names upon errors such as +NameError+ and +NoMethodError+. In Ruby 2.3 or
|
||||
# later, it is automatically activated during startup.
|
||||
#
|
||||
# @example
|
||||
#
|
||||
# methosd
|
||||
# # => NameError: undefined local variable or method `methosd' for main:Object
|
||||
# # Did you mean? methods
|
||||
# # method
|
||||
#
|
||||
# OBject
|
||||
# # => NameError: uninitialized constant OBject
|
||||
# # Did you mean? Object
|
||||
#
|
||||
# @full_name = "Yuki Nishijima"
|
||||
# first_name, last_name = full_name.split(" ")
|
||||
# # => NameError: undefined local variable or method `full_name' for main:Object
|
||||
# # Did you mean? @full_name
|
||||
#
|
||||
# @@full_name = "Yuki Nishijima"
|
||||
# @@full_anme
|
||||
# # => NameError: uninitialized class variable @@full_anme in Object
|
||||
# # Did you mean? @@full_name
|
||||
#
|
||||
# full_name = "Yuki Nishijima"
|
||||
# full_name.starts_with?("Y")
|
||||
# # => NoMethodError: undefined method `starts_with?' for "Yuki Nishijima":String
|
||||
# # Did you mean? start_with?
|
||||
#
|
||||
# hash = {foo: 1, bar: 2, baz: 3}
|
||||
# hash.fetch(:fooo)
|
||||
# # => KeyError: key not found: :fooo
|
||||
# # Did you mean? :foo
|
||||
#
|
||||
#
|
||||
# == Disabling +did_you_mean+
|
||||
#
|
||||
# Occasionally, you may want to disable the +did_you_mean+ gem for e.g.
|
||||
# debugging issues in the error object itself. You can disable it entirely by
|
||||
# specifying +--disable-did_you_mean+ option to the +ruby+ command:
|
||||
#
|
||||
# $ ruby --disable-did_you_mean -e "1.zeor?"
|
||||
# -e:1:in `<main>': undefined method `zeor?' for 1:Integer (NameError)
|
||||
#
|
||||
# When you do not have direct access to the +ruby+ command (e.g.
|
||||
# +rails console+, +irb+), you could applyoptions using the +RUBYOPT+
|
||||
# environment variable:
|
||||
#
|
||||
# $ RUBYOPT='--disable-did_you_mean' irb
|
||||
# irb:0> 1.zeor?
|
||||
# # => NoMethodError (undefined method `zeor?' for 1:Integer)
|
||||
#
|
||||
#
|
||||
# == Getting the original error message
|
||||
#
|
||||
# Sometimes, you do not want to disable the gem entirely, but need to get the
|
||||
# original error message without suggestions (e.g. testing). In this case, you
|
||||
# could use the +#original_message+ method on the error object:
|
||||
#
|
||||
# no_method_error = begin
|
||||
# 1.zeor?
|
||||
# rescue NoMethodError => error
|
||||
# error
|
||||
# end
|
||||
#
|
||||
# no_method_error.message
|
||||
# # => NoMethodError (undefined method `zeor?' for 1:Integer)
|
||||
# # Did you mean? zero?
|
||||
#
|
||||
# no_method_error.original_message
|
||||
# # => NoMethodError (undefined method `zeor?' for 1:Integer)
|
||||
#
|
||||
module DidYouMean
|
||||
# Map of error types and spell checker objects.
|
||||
SPELL_CHECKERS = Hash.new(NullChecker)
|
||||
|
||||
# Adds +DidYouMean+ functionality to an error using a given spell checker
|
||||
def self.correct_error(error_class, spell_checker)
|
||||
SPELL_CHECKERS[error_class.name] = spell_checker
|
||||
error_class.prepend(Correctable) unless error_class < Correctable
|
||||
end
|
||||
|
||||
correct_error NameError, NameErrorCheckers
|
||||
correct_error KeyError, KeyErrorChecker
|
||||
correct_error NoMethodError, MethodNameChecker
|
||||
correct_error LoadError, RequirePathChecker if RUBY_VERSION >= '2.8.0'
|
||||
|
||||
# Returns the currently set formatter. By default, it is set to +DidYouMean::Formatter+.
|
||||
def self.formatter
|
||||
@@formatter
|
||||
end
|
||||
|
||||
# Updates the primary formatter used to format the suggestions.
|
||||
def self.formatter=(formatter)
|
||||
@@formatter = formatter
|
||||
end
|
||||
|
||||
self.formatter = PlainFormatter.new
|
||||
end
|
@ -0,0 +1,25 @@
|
||||
module DidYouMean
|
||||
module Correctable
|
||||
def original_message
|
||||
method(:to_s).super_method.call
|
||||
end
|
||||
|
||||
def to_s
|
||||
msg = super.dup
|
||||
suggestion = DidYouMean.formatter.message_for(corrections)
|
||||
|
||||
msg << suggestion if !msg.end_with?(suggestion)
|
||||
msg
|
||||
rescue
|
||||
super
|
||||
end
|
||||
|
||||
def corrections
|
||||
@corrections ||= spell_checker.corrections
|
||||
end
|
||||
|
||||
def spell_checker
|
||||
SPELL_CHECKERS[self.class.to_s].new(self)
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,2 @@
|
||||
warn "Experimental features in the did_you_mean gem has been removed " \
|
||||
"and `require \"did_you_mean/experimental\"' has no effect."
|
@ -0,0 +1,33 @@
|
||||
# frozen-string-literal: true
|
||||
|
||||
module DidYouMean
|
||||
# The +DidYouMean::PlainFormatter+ is the basic, default formatter for the
|
||||
# gem. The formatter responds to the +message_for+ method and it returns a
|
||||
# human readable string.
|
||||
class PlainFormatter
|
||||
|
||||
# Returns a human readable string that contains +corrections+. This
|
||||
# formatter is designed to be less verbose to not take too much screen
|
||||
# space while being helpful enough to the user.
|
||||
#
|
||||
# @example
|
||||
#
|
||||
# formatter = DidYouMean::PlainFormatter.new
|
||||
#
|
||||
# # displays suggestions in two lines with the leading empty line
|
||||
# puts formatter.message_for(["methods", "method"])
|
||||
#
|
||||
# Did you mean? methods
|
||||
# method
|
||||
# # => nil
|
||||
#
|
||||
# # displays an empty line
|
||||
# puts formatter.message_for([])
|
||||
#
|
||||
# # => nil
|
||||
#
|
||||
def message_for(corrections)
|
||||
corrections.empty? ? "" : "\nDid you mean? #{corrections.join("\n ")}"
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,49 @@
|
||||
# frozen-string-literal: true
|
||||
|
||||
module DidYouMean
|
||||
# The +DidYouMean::VerboseFormatter+ uses extra empty lines to make the
|
||||
# suggestion stand out more in the error message.
|
||||
#
|
||||
# In order to activate the verbose formatter,
|
||||
#
|
||||
# @example
|
||||
#
|
||||
# OBject
|
||||
# # => NameError: uninitialized constant OBject
|
||||
# # Did you mean? Object
|
||||
#
|
||||
# require 'did_you_mean/verbose'
|
||||
#
|
||||
# OBject
|
||||
# # => NameError: uninitialized constant OBject
|
||||
# #
|
||||
# # Did you mean? Object
|
||||
# #
|
||||
#
|
||||
class VerboseFormatter
|
||||
|
||||
# Returns a human readable string that contains +corrections+. This
|
||||
# formatter is designed to be less verbose to not take too much screen
|
||||
# space while being helpful enough to the user.
|
||||
#
|
||||
# @example
|
||||
#
|
||||
# formatter = DidYouMean::PlainFormatter.new
|
||||
#
|
||||
# puts formatter.message_for(["methods", "method"])
|
||||
#
|
||||
#
|
||||
# Did you mean? methods
|
||||
# method
|
||||
#
|
||||
# # => nil
|
||||
#
|
||||
def message_for(corrections)
|
||||
return "" if corrections.empty?
|
||||
|
||||
output = "\n\n Did you mean? ".dup
|
||||
output << corrections.join("\n ")
|
||||
output << "\n "
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,87 @@
|
||||
module DidYouMean
|
||||
module Jaro
|
||||
module_function
|
||||
|
||||
def distance(str1, str2)
|
||||
str1, str2 = str2, str1 if str1.length > str2.length
|
||||
length1, length2 = str1.length, str2.length
|
||||
|
||||
m = 0.0
|
||||
t = 0.0
|
||||
range = (length2 / 2).floor - 1
|
||||
range = 0 if range < 0
|
||||
flags1 = 0
|
||||
flags2 = 0
|
||||
|
||||
# Avoid duplicating enumerable objects
|
||||
str1_codepoints = str1.codepoints
|
||||
str2_codepoints = str2.codepoints
|
||||
|
||||
i = 0
|
||||
while i < length1
|
||||
last = i + range
|
||||
j = (i >= range) ? i - range : 0
|
||||
|
||||
while j <= last
|
||||
if flags2[j] == 0 && str1_codepoints[i] == str2_codepoints[j]
|
||||
flags2 |= (1 << j)
|
||||
flags1 |= (1 << i)
|
||||
m += 1
|
||||
break
|
||||
end
|
||||
|
||||
j += 1
|
||||
end
|
||||
|
||||
i += 1
|
||||
end
|
||||
|
||||
k = i = 0
|
||||
while i < length1
|
||||
if flags1[i] != 0
|
||||
j = index = k
|
||||
|
||||
k = while j < length2
|
||||
index = j
|
||||
break(j + 1) if flags2[j] != 0
|
||||
|
||||
j += 1
|
||||
end
|
||||
|
||||
t += 1 if str1_codepoints[i] != str2_codepoints[index]
|
||||
end
|
||||
|
||||
i += 1
|
||||
end
|
||||
t = (t / 2).floor
|
||||
|
||||
m == 0 ? 0 : (m / length1 + m / length2 + (m - t) / m) / 3
|
||||
end
|
||||
end
|
||||
|
||||
module JaroWinkler
|
||||
WEIGHT = 0.1
|
||||
THRESHOLD = 0.7
|
||||
|
||||
module_function
|
||||
|
||||
def distance(str1, str2)
|
||||
jaro_distance = Jaro.distance(str1, str2)
|
||||
|
||||
if jaro_distance > THRESHOLD
|
||||
codepoints2 = str2.codepoints
|
||||
prefix_bonus = 0
|
||||
|
||||
i = 0
|
||||
str1.each_codepoint do |char1|
|
||||
char1 == codepoints2[i] && i < 4 ? prefix_bonus += 1 : break
|
||||
i += 1
|
||||
end
|
||||
|
||||
jaro_distance + (prefix_bonus * WEIGHT * (1 - jaro_distance))
|
||||
else
|
||||
jaro_distance
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,57 @@
|
||||
module DidYouMean
|
||||
module Levenshtein # :nodoc:
|
||||
# This code is based directly on the Text gem implementation
|
||||
# Copyright (c) 2006-2013 Paul Battley, Michael Neumann, Tim Fletcher.
|
||||
#
|
||||
# Returns a value representing the "cost" of transforming str1 into str2
|
||||
def distance(str1, str2)
|
||||
n = str1.length
|
||||
m = str2.length
|
||||
return m if n.zero?
|
||||
return n if m.zero?
|
||||
|
||||
d = (0..m).to_a
|
||||
x = nil
|
||||
|
||||
# to avoid duplicating an enumerable object, create it outside of the loop
|
||||
str2_codepoints = str2.codepoints
|
||||
|
||||
str1.each_codepoint.with_index(1) do |char1, i|
|
||||
j = 0
|
||||
while j < m
|
||||
cost = (char1 == str2_codepoints[j]) ? 0 : 1
|
||||
x = min3(
|
||||
d[j+1] + 1, # insertion
|
||||
i + 1, # deletion
|
||||
d[j] + cost # substitution
|
||||
)
|
||||
d[j] = i
|
||||
i = x
|
||||
|
||||
j += 1
|
||||
end
|
||||
d[m] = x
|
||||
end
|
||||
|
||||
x
|
||||
end
|
||||
module_function :distance
|
||||
|
||||
private
|
||||
|
||||
# detects the minimum value out of three arguments. This method is
|
||||
# faster than `[a, b, c].min` and puts less GC pressure.
|
||||
# See https://github.com/ruby/did_you_mean/pull/1 for a performance
|
||||
# benchmark.
|
||||
def min3(a, b, c)
|
||||
if a < b && a < c
|
||||
a
|
||||
elsif b < c
|
||||
b
|
||||
else
|
||||
c
|
||||
end
|
||||
end
|
||||
module_function :min3
|
||||
end
|
||||
end
|
@ -0,0 +1,46 @@
|
||||
# frozen-string-literal: true
|
||||
|
||||
require_relative "levenshtein"
|
||||
require_relative "jaro_winkler"
|
||||
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
def normalize(str_or_symbol) #:nodoc:
|
||||
str = str_or_symbol.to_s.downcase
|
||||
str.tr!("@", "")
|
||||
str
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,20 @@
|
||||
require_relative "../spell_checker"
|
||||
|
||||
module DidYouMean
|
||||
class KeyErrorChecker
|
||||
def initialize(key_error)
|
||||
@key = key_error.key
|
||||
@keys = key_error.receiver.keys
|
||||
end
|
||||
|
||||
def corrections
|
||||
@corrections ||= exact_matches.empty? ? SpellChecker.new(dictionary: @keys).correct(@key).map(&:inspect) : exact_matches
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def exact_matches
|
||||
@exact_matches ||= @keys.select { |word| @key == word.to_s }.map(&:inspect)
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,69 @@
|
||||
require_relative "../spell_checker"
|
||||
|
||||
module DidYouMean
|
||||
class MethodNameChecker
|
||||
attr_reader :method_name, :receiver
|
||||
|
||||
NAMES_TO_EXCLUDE = { NilClass => nil.methods }
|
||||
NAMES_TO_EXCLUDE.default = []
|
||||
|
||||
# +MethodNameChecker::RB_RESERVED_WORDS+ is the list of reserved words in
|
||||
# Ruby that take an argument. Unlike
|
||||
# +VariableNameChecker::RB_RESERVED_WORDS+, these reserved words require
|
||||
# an argument, and a +NoMethodError+ is raised due to the presence of the
|
||||
# argument.
|
||||
#
|
||||
# The +MethodNameChecker+ will use this list to suggest a reversed word if
|
||||
# a +NoMethodError+ is raised and found closest matches.
|
||||
#
|
||||
# Also see +VariableNameChecker::RB_RESERVED_WORDS+.
|
||||
RB_RESERVED_WORDS = %i(
|
||||
alias
|
||||
case
|
||||
def
|
||||
defined?
|
||||
elsif
|
||||
end
|
||||
ensure
|
||||
for
|
||||
rescue
|
||||
super
|
||||
undef
|
||||
unless
|
||||
until
|
||||
when
|
||||
while
|
||||
yield
|
||||
)
|
||||
|
||||
def initialize(exception)
|
||||
@method_name = exception.name
|
||||
@receiver = exception.receiver
|
||||
@private_call = exception.respond_to?(:private_call?) ? exception.private_call? : false
|
||||
end
|
||||
|
||||
def corrections
|
||||
@corrections ||= begin
|
||||
dictionary = method_names
|
||||
dictionary = RB_RESERVED_WORDS + dictionary if @private_call
|
||||
|
||||
SpellChecker.new(dictionary: dictionary).correct(method_name) - names_to_exclude
|
||||
end
|
||||
end
|
||||
|
||||
def method_names
|
||||
if Object === receiver
|
||||
method_names = receiver.methods + receiver.singleton_methods
|
||||
method_names += receiver.private_methods if @private_call
|
||||
method_names.uniq!
|
||||
method_names
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def names_to_exclude
|
||||
Object === receiver ? NAMES_TO_EXCLUDE[receiver.class] : []
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,20 @@
|
||||
require_relative 'name_error_checkers/class_name_checker'
|
||||
require_relative 'name_error_checkers/variable_name_checker'
|
||||
|
||||
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
|
@ -0,0 +1,49 @@
|
||||
# frozen-string-literal: true
|
||||
|
||||
require_relative "../../spell_checker"
|
||||
|
||||
module DidYouMean
|
||||
class ClassNameChecker
|
||||
attr_reader :class_name
|
||||
|
||||
def initialize(exception)
|
||||
@class_name, @receiver, @original_message = exception.name, exception.receiver, exception.original_message
|
||||
end
|
||||
|
||||
def corrections
|
||||
@corrections ||= SpellChecker.new(dictionary: class_names)
|
||||
.correct(class_name)
|
||||
.map(&:full_name)
|
||||
.reject {|qualified_name| @original_message.include?(qualified_name) }
|
||||
end
|
||||
|
||||
def class_names
|
||||
scopes.flat_map do |scope|
|
||||
scope.constants.map do |c|
|
||||
ClassName.new(c, scope == Object ? "" : "#{scope}::")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def scopes
|
||||
@scopes ||= @receiver.to_s.split("::").inject([Object]) do |_scopes, scope|
|
||||
_scopes << _scopes.last.const_get(scope)
|
||||
end.uniq
|
||||
end
|
||||
|
||||
class ClassName < String
|
||||
attr :namespace
|
||||
|
||||
def initialize(name, namespace = '')
|
||||
super(name.to_s)
|
||||
@namespace = namespace
|
||||
end
|
||||
|
||||
def full_name
|
||||
self.class.new("#{namespace}#{self}")
|
||||
end
|
||||
end
|
||||
|
||||
private_constant :ClassName
|
||||
end
|
||||
end
|
@ -0,0 +1,82 @@
|
||||
# frozen-string-literal: true
|
||||
|
||||
require_relative "../../spell_checker"
|
||||
|
||||
module DidYouMean
|
||||
class VariableNameChecker
|
||||
attr_reader :name, :method_names, :lvar_names, :ivar_names, :cvar_names
|
||||
|
||||
NAMES_TO_EXCLUDE = { 'foo' => [:fork, :for] }
|
||||
NAMES_TO_EXCLUDE.default = []
|
||||
|
||||
# +VariableNameChecker::RB_RESERVED_WORDS+ is the list of all reserved
|
||||
# words in Ruby. They could be declared like methods are, and a typo would
|
||||
# cause Ruby to raise a +NameError+ because of the way they are declared.
|
||||
#
|
||||
# The +:VariableNameChecker+ will use this list to suggest a reversed word
|
||||
# if a +NameError+ is raised and found closest matches, excluding:
|
||||
#
|
||||
# * +do+
|
||||
# * +if+
|
||||
# * +in+
|
||||
# * +or+
|
||||
#
|
||||
# Also see +MethodNameChecker::RB_RESERVED_WORDS+.
|
||||
RB_RESERVED_WORDS = %i(
|
||||
BEGIN
|
||||
END
|
||||
alias
|
||||
and
|
||||
begin
|
||||
break
|
||||
case
|
||||
class
|
||||
def
|
||||
defined?
|
||||
else
|
||||
elsif
|
||||
end
|
||||
ensure
|
||||
false
|
||||
for
|
||||
module
|
||||
next
|
||||
nil
|
||||
not
|
||||
redo
|
||||
rescue
|
||||
retry
|
||||
return
|
||||
self
|
||||
super
|
||||
then
|
||||
true
|
||||
undef
|
||||
unless
|
||||
until
|
||||
when
|
||||
while
|
||||
yield
|
||||
__LINE__
|
||||
__FILE__
|
||||
__ENCODING__
|
||||
)
|
||||
|
||||
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_RESERVED_WORDS + lvar_names + method_names + ivar_names + cvar_names))
|
||||
.correct(name) - NAMES_TO_EXCLUDE[@name]
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,6 @@
|
||||
module DidYouMean
|
||||
class NullChecker
|
||||
def initialize(*); end
|
||||
def corrections; [] end
|
||||
end
|
||||
end
|
@ -0,0 +1,35 @@
|
||||
# frozen-string-literal: true
|
||||
|
||||
require_relative "../spell_checker"
|
||||
require_relative "../tree_spell_checker"
|
||||
|
||||
module DidYouMean
|
||||
class RequirePathChecker
|
||||
attr_reader :path
|
||||
|
||||
INITIAL_LOAD_PATH = $LOAD_PATH.dup.freeze
|
||||
ENV_SPECIFIC_EXT = ".#{RbConfig::CONFIG["DLEXT"]}"
|
||||
|
||||
private_constant :INITIAL_LOAD_PATH, :ENV_SPECIFIC_EXT
|
||||
|
||||
def self.requireables
|
||||
@requireables ||= INITIAL_LOAD_PATH
|
||||
.flat_map {|path| Dir.glob("**/???*{.rb,#{ENV_SPECIFIC_EXT}}", base: path) }
|
||||
.map {|path| path.chomp!(".rb") || path.chomp!(ENV_SPECIFIC_EXT) }
|
||||
end
|
||||
|
||||
def initialize(exception)
|
||||
@path = exception.path
|
||||
end
|
||||
|
||||
def corrections
|
||||
@corrections ||= begin
|
||||
threshold = path.size * 2
|
||||
dictionary = self.class.requireables.reject {|str| str.size >= threshold }
|
||||
spell_checker = path.include?("/") ? TreeSpellChecker : SpellChecker
|
||||
|
||||
spell_checker.new(dictionary: dictionary).correct(path).uniq
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,109 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DidYouMean
|
||||
# spell checker for a dictionary that has a tree
|
||||
# structure, see doc/tree_spell_checker_api.md
|
||||
class TreeSpellChecker
|
||||
attr_reader :dictionary, :separator, :augment
|
||||
|
||||
def initialize(dictionary:, separator: '/', augment: nil)
|
||||
@dictionary = dictionary
|
||||
@separator = separator
|
||||
@augment = augment
|
||||
end
|
||||
|
||||
def correct(input)
|
||||
plausibles = plausible_dimensions(input)
|
||||
return fall_back_to_normal_spell_check(input) if plausibles.empty?
|
||||
|
||||
suggestions = find_suggestions(input, plausibles)
|
||||
return fall_back_to_normal_spell_check(input) if suggestions.empty?
|
||||
|
||||
suggestions
|
||||
end
|
||||
|
||||
def dictionary_without_leaves
|
||||
@dictionary_without_leaves ||= dictionary.map { |word| word.split(separator)[0..-2] }.uniq
|
||||
end
|
||||
|
||||
def tree_depth
|
||||
@tree_depth ||= dictionary_without_leaves.max { |a, b| a.size <=> b.size }.size
|
||||
end
|
||||
|
||||
def dimensions
|
||||
@dimensions ||= tree_depth.times.map do |index|
|
||||
dictionary_without_leaves.map { |element| element[index] }.compact.uniq
|
||||
end
|
||||
end
|
||||
|
||||
def find_leaves(path)
|
||||
path_with_separator = "#{path}#{separator}"
|
||||
|
||||
dictionary
|
||||
.select {|str| str.include?(path_with_separator) }
|
||||
.map {|str| str.gsub(path_with_separator, '') }
|
||||
end
|
||||
|
||||
def plausible_dimensions(input)
|
||||
input.split(separator)[0..-2]
|
||||
.map
|
||||
.with_index { |element, index| correct_element(dimensions[index], element) if dimensions[index] }
|
||||
.compact
|
||||
end
|
||||
|
||||
def possible_paths(states)
|
||||
states.map { |state| state.join(separator) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_suggestions(input, plausibles)
|
||||
states = plausibles[0].product(*plausibles[1..-1])
|
||||
paths = possible_paths(states)
|
||||
leaf = input.split(separator).last
|
||||
|
||||
find_ideas(paths, leaf)
|
||||
end
|
||||
|
||||
def fall_back_to_normal_spell_check(input)
|
||||
return [] unless augment
|
||||
|
||||
::DidYouMean::SpellChecker.new(dictionary: dictionary).correct(input)
|
||||
end
|
||||
|
||||
def find_ideas(paths, leaf)
|
||||
paths.flat_map do |path|
|
||||
names = find_leaves(path)
|
||||
ideas = correct_element(names, leaf)
|
||||
|
||||
ideas_to_paths(ideas, leaf, names, path)
|
||||
end.compact
|
||||
end
|
||||
|
||||
def ideas_to_paths(ideas, leaf, names, path)
|
||||
if ideas.empty?
|
||||
nil
|
||||
elsif names.include?(leaf)
|
||||
["#{path}#{separator}#{leaf}"]
|
||||
else
|
||||
ideas.map {|str| "#{path}#{separator}#{str}" }
|
||||
end
|
||||
end
|
||||
|
||||
def correct_element(names, element)
|
||||
return names if names.size == 1
|
||||
|
||||
str = normalize(element)
|
||||
|
||||
return [str] if names.include?(str)
|
||||
|
||||
::DidYouMean::SpellChecker.new(dictionary: names).correct(str)
|
||||
end
|
||||
|
||||
def normalize(str)
|
||||
str.downcase!
|
||||
str.tr!('@', ' ') if str.include?('@')
|
||||
str
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,4 @@
|
||||
require_relative '../did_you_mean'
|
||||
require_relative 'formatters/verbose_formatter'
|
||||
|
||||
DidYouMean.formatter = DidYouMean::VerboseFormatter.new
|
@ -0,0 +1,3 @@
|
||||
module DidYouMean
|
||||
VERSION = "1.5.0"
|
||||
end
|
Loading…
x
Reference in New Issue
Block a user