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
|
# vendored gems
|
||||||
gem "activesupport"
|
gem "activesupport"
|
||||||
gem "concurrent-ruby"
|
gem "concurrent-ruby"
|
||||||
|
gem "did_you_mean" # remove when HOMEBREW_REQUIRED_RUBY_VERSION >= 2.7
|
||||||
gem "mechanize"
|
gem "mechanize"
|
||||||
gem "patchelf"
|
gem "patchelf"
|
||||||
gem "plist"
|
gem "plist"
|
||||||
|
@ -20,6 +20,7 @@ GEM
|
|||||||
highline (~> 2.0.0)
|
highline (~> 2.0.0)
|
||||||
concurrent-ruby (1.1.9)
|
concurrent-ruby (1.1.9)
|
||||||
connection_pool (2.2.5)
|
connection_pool (2.2.5)
|
||||||
|
did_you_mean (1.5.0)
|
||||||
diff-lcs (1.4.4)
|
diff-lcs (1.4.4)
|
||||||
docile (1.4.0)
|
docile (1.4.0)
|
||||||
domain_name (0.5.20190701)
|
domain_name (0.5.20190701)
|
||||||
@ -183,6 +184,7 @@ DEPENDENCIES
|
|||||||
bootsnap
|
bootsnap
|
||||||
byebug
|
byebug
|
||||||
concurrent-ruby
|
concurrent-ruby
|
||||||
|
did_you_mean
|
||||||
mechanize
|
mechanize
|
||||||
minitest
|
minitest
|
||||||
nokogiri
|
nokogiri
|
||||||
|
@ -583,14 +583,11 @@ then
|
|||||||
|
|
||||||
# Don't allow non-developers to customise Ruby warnings.
|
# Don't allow non-developers to customise Ruby warnings.
|
||||||
unset HOMEBREW_RUBY_WARNINGS
|
unset HOMEBREW_RUBY_WARNINGS
|
||||||
|
|
||||||
# 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
|
fi
|
||||||
|
|
||||||
|
# Disable Ruby options we don't need.
|
||||||
|
RUBY_DISABLE_OPTIONS="--disable=rubyopt"
|
||||||
|
|
||||||
if [[ -z "${HOMEBREW_RUBY_WARNINGS}" ]]
|
if [[ -z "${HOMEBREW_RUBY_WARNINGS}" ]]
|
||||||
then
|
then
|
||||||
export HOMEBREW_RUBY_WARNINGS="-W1"
|
export HOMEBREW_RUBY_WARNINGS="-W1"
|
||||||
|
@ -268,7 +268,6 @@ module Homebrew
|
|||||||
puts "To install one of them, run (for example):\n brew install #{formulae_search_results.first}"
|
puts "To install one of them, run (for example):\n brew install #{formulae_search_results.first}"
|
||||||
end
|
end
|
||||||
|
|
||||||
ofail e.message
|
|
||||||
if (reason = MissingFormula.reason(e.name))
|
if (reason = MissingFormula.reason(e.name))
|
||||||
$stderr.puts reason
|
$stderr.puts reason
|
||||||
return
|
return
|
||||||
|
@ -91,9 +91,17 @@ class FormulaOrCaskUnavailableError < RuntimeError
|
|||||||
@name = name
|
@name = name
|
||||||
end
|
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) }
|
sig { returns(String) }
|
||||||
def to_s
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -129,7 +137,7 @@ class FormulaUnavailableError < FormulaOrCaskUnavailableError
|
|||||||
|
|
||||||
sig { returns(String) }
|
sig { returns(String) }
|
||||||
def to_s
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require "cache_store"
|
require "cache_store"
|
||||||
|
require "did_you_mean"
|
||||||
require "formula_support"
|
require "formula_support"
|
||||||
require "lock_file"
|
require "lock_file"
|
||||||
require "formula_pin"
|
require "formula_pin"
|
||||||
@ -1671,6 +1672,13 @@ class Formula
|
|||||||
CoreTap.instance.alias_reverse_table
|
CoreTap.instance.alias_reverse_table
|
||||||
end
|
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)
|
def self.[](name)
|
||||||
Formulary.factory(name)
|
Formulary.factory(name)
|
||||||
end
|
end
|
||||||
|
@ -87,6 +87,8 @@ module Homebrew
|
|||||||
.search(string_or_regex)
|
.search(string_or_regex)
|
||||||
.sort
|
.sort
|
||||||
|
|
||||||
|
results += Formula.fuzzy_search(string_or_regex)
|
||||||
|
|
||||||
results.map do |name|
|
results.map do |name|
|
||||||
formula, canonical_full_name = begin
|
formula, canonical_full_name = begin
|
||||||
f = Formulary.factory(name)
|
f = Formulary.factory(name)
|
||||||
|
@ -127,6 +127,7 @@ class Tap
|
|||||||
@style_exceptions = nil
|
@style_exceptions = nil
|
||||||
@pypi_formula_mappings = nil
|
@pypi_formula_mappings = nil
|
||||||
@config = nil
|
@config = nil
|
||||||
|
@spell_checker = nil
|
||||||
remove_instance_variable(:@private) if instance_variable_defined?(:@private)
|
remove_instance_variable(:@private) if instance_variable_defined?(:@private)
|
||||||
end
|
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/highline-2.0.3/lib"
|
||||||
$:.unshift "#{path}/../#{ruby_engine}/#{ruby_version}/gems/commander-4.6.0/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/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/diff-lcs-1.4.4/lib"
|
||||||
$:.unshift "#{path}/../#{ruby_engine}/#{ruby_version}/gems/docile-1.4.0/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"
|
$:.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