From 171803d5d34feb1b4244ca81b9db0a7bc2171c85 Mon Sep 17 00:00:00 2001 From: Kevin Deisz Date: Tue, 29 Oct 2019 10:08:37 -0400 Subject: [PATCH] Promote did_you_mean to default gem At the moment, there are some problems with regard to bundler + did_you_mean because of did_you_mean being a bundled gem. Since the vendored version of thor inside bundler and ruby itself explicitly requires did_you_mean, it can become difficult to load it when using Bundler.setup. See this issue: https://github.com/yuki24/did_you_mean/issues/117#issuecomment-482733159 for more details. --- doc/maintainers.rdoc | 5 +- doc/standard_library.rdoc | 2 +- gems/bundled_gems | 1 - lib/did_you_mean.rb | 110 +++++++++++ lib/did_you_mean/core_ext/name_error.rb | 25 +++ lib/did_you_mean/did_you_mean.gemspec | 23 +++ lib/did_you_mean/experimental.rb | 2 + .../initializer_name_correction.rb | 20 ++ .../experimental/ivar_name_correction.rb | 76 ++++++++ .../formatters/plain_formatter.rb | 33 ++++ .../formatters/verbose_formatter.rb | 49 +++++ lib/did_you_mean/jaro_winkler.rb | 87 +++++++++ lib/did_you_mean/levenshtein.rb | 57 ++++++ lib/did_you_mean/spell_checker.rb | 46 +++++ .../spell_checkers/key_error_checker.rb | 20 ++ .../spell_checkers/method_name_checker.rb | 56 ++++++ .../spell_checkers/name_error_checkers.rb | 20 ++ .../name_error_checkers/class_name_checker.rb | 50 +++++ .../variable_name_checker.rb | 82 +++++++++ .../spell_checkers/null_checker.rb | 6 + lib/did_you_mean/tree_spell_checker.rb | 137 ++++++++++++++ lib/did_you_mean/verbose.rb | 4 + lib/did_you_mean/version.rb | 3 + .../core_ext/test_name_error_extension.rb | 48 +++++ .../edit_distance/test_jaro_winkler.rb | 36 ++++ test/did_you_mean/fixtures/book.rb | 4 + test/did_you_mean/fixtures/mini_dir.yml | 15 ++ test/did_you_mean/fixtures/rspec_dir.yml | 112 ++++++++++++ test/did_you_mean/helper.rb | 29 +++ .../spell_checking/test_class_name_check.rb | 79 ++++++++ .../spell_checking/test_key_name_check.rb | 54 ++++++ .../spell_checking/test_method_name_check.rb | 140 ++++++++++++++ .../test_uncorrectable_name_check.rb | 15 ++ .../test_variable_name_check.rb | 140 ++++++++++++++ test/did_you_mean/test_spell_checker.rb | 77 ++++++++ test/did_you_mean/test_verbose_formatter.rb | 22 +++ test/did_you_mean/tree_spell/change_word.rb | 61 ++++++ test/did_you_mean/tree_spell/human_typo.rb | 89 +++++++++ .../tree_spell/test_change_word.rb | 38 ++++ .../tree_spell/test_human_typo.rb | 24 +++ test/did_you_mean/tree_spell_checker_test.rb | 173 ++++++++++++++++++ tool/sync_default_gems.rb | 8 + 42 files changed, 2074 insertions(+), 4 deletions(-) create mode 100644 lib/did_you_mean.rb create mode 100644 lib/did_you_mean/core_ext/name_error.rb create mode 100644 lib/did_you_mean/did_you_mean.gemspec create mode 100644 lib/did_you_mean/experimental.rb create mode 100644 lib/did_you_mean/experimental/initializer_name_correction.rb create mode 100644 lib/did_you_mean/experimental/ivar_name_correction.rb create mode 100644 lib/did_you_mean/formatters/plain_formatter.rb create mode 100644 lib/did_you_mean/formatters/verbose_formatter.rb create mode 100644 lib/did_you_mean/jaro_winkler.rb create mode 100644 lib/did_you_mean/levenshtein.rb create mode 100644 lib/did_you_mean/spell_checker.rb create mode 100644 lib/did_you_mean/spell_checkers/key_error_checker.rb create mode 100644 lib/did_you_mean/spell_checkers/method_name_checker.rb create mode 100644 lib/did_you_mean/spell_checkers/name_error_checkers.rb create mode 100644 lib/did_you_mean/spell_checkers/name_error_checkers/class_name_checker.rb create mode 100644 lib/did_you_mean/spell_checkers/name_error_checkers/variable_name_checker.rb create mode 100644 lib/did_you_mean/spell_checkers/null_checker.rb create mode 100644 lib/did_you_mean/tree_spell_checker.rb create mode 100644 lib/did_you_mean/verbose.rb create mode 100644 lib/did_you_mean/version.rb create mode 100644 test/did_you_mean/core_ext/test_name_error_extension.rb create mode 100644 test/did_you_mean/edit_distance/test_jaro_winkler.rb create mode 100644 test/did_you_mean/fixtures/book.rb create mode 100644 test/did_you_mean/fixtures/mini_dir.yml create mode 100644 test/did_you_mean/fixtures/rspec_dir.yml create mode 100644 test/did_you_mean/helper.rb create mode 100644 test/did_you_mean/spell_checking/test_class_name_check.rb create mode 100644 test/did_you_mean/spell_checking/test_key_name_check.rb create mode 100644 test/did_you_mean/spell_checking/test_method_name_check.rb create mode 100644 test/did_you_mean/spell_checking/test_uncorrectable_name_check.rb create mode 100644 test/did_you_mean/spell_checking/test_variable_name_check.rb create mode 100644 test/did_you_mean/test_spell_checker.rb create mode 100644 test/did_you_mean/test_verbose_formatter.rb create mode 100644 test/did_you_mean/tree_spell/change_word.rb create mode 100644 test/did_you_mean/tree_spell/human_typo.rb create mode 100644 test/did_you_mean/tree_spell/test_change_word.rb create mode 100644 test/did_you_mean/tree_spell/test_human_typo.rb create mode 100644 test/did_you_mean/tree_spell_checker_test.rb diff --git a/doc/maintainers.rdoc b/doc/maintainers.rdoc index 1fb29fb1d3..98de9f2549 100644 --- a/doc/maintainers.rdoc +++ b/doc/maintainers.rdoc @@ -162,6 +162,9 @@ Zachary Scott (zzak) _unmaintained_ https://github.com/ruby/delegate https://rubygems.org/gems/delegate +[lib/did_you_mean.rb] + Yuki Nishijima (yuki24) + https://github.com/ruby/did_you_mean [lib/fileutils.rb] _unmaintained_ https://github.com/ruby/fileutils @@ -342,8 +345,6 @@ Zachary Scott (zzak) == Bundled gems upstream repositories -[did_you_mean] - https://github.com/yuki24/did_you_mean [minitest] https://github.com/seattlerb/minitest [net-telnet] diff --git a/doc/standard_library.rdoc b/doc/standard_library.rdoc index 1c8f309628..f71cedebf7 100644 --- a/doc/standard_library.rdoc +++ b/doc/standard_library.rdoc @@ -62,6 +62,7 @@ Bundler:: Manage your Ruby application's gem dependencies CGI:: Support for the Common Gateway Interface protocol CSV:: Provides an interface to read and write CSV files and data Delegator:: Provides three abilities to delegate method calls to an object +DidYouMean:: "Did you mean?" experience in Ruby FileUtils:: Several file utility methods for copying, moving, removing, etc Forwardable:: Provides delegation of specified methods to a designated object GetoptLong:: Parse command line options similar to the GNU C getopt_long() @@ -112,7 +113,6 @@ Zlib:: Ruby interface for the zlib compression/decompression library == Libraries -DidYouMean:: "Did you mean?" experience in Ruby MiniTest:: A test suite with TDD, BDD, mocking and benchmarking Net::Telnet:: Telnet client library for Ruby PowerAssert:: Power Assert for Ruby. diff --git a/gems/bundled_gems b/gems/bundled_gems index f6c6815e9b..b2c6e2d5cb 100644 --- a/gems/bundled_gems +++ b/gems/bundled_gems @@ -1,4 +1,3 @@ -did_you_mean 1.3.1 https://github.com/yuki24/did_you_mean minitest 5.13.0 https://github.com/seattlerb/minitest net-telnet 0.2.0 https://github.com/ruby/net-telnet power_assert 1.1.5 https://github.com/k-tsj/power_assert diff --git a/lib/did_you_mean.rb b/lib/did_you_mean.rb new file mode 100644 index 0000000000..b8f92579ca --- /dev/null +++ b/lib/did_you_mean.rb @@ -0,0 +1,110 @@ +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/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 `
': 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 + + # Returns the currenctly 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 diff --git a/lib/did_you_mean/core_ext/name_error.rb b/lib/did_you_mean/core_ext/name_error.rb new file mode 100644 index 0000000000..77dcd520c0 --- /dev/null +++ b/lib/did_you_mean/core_ext/name_error.rb @@ -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 diff --git a/lib/did_you_mean/did_you_mean.gemspec b/lib/did_you_mean/did_you_mean.gemspec new file mode 100644 index 0000000000..8a74db8cc4 --- /dev/null +++ b/lib/did_you_mean/did_you_mean.gemspec @@ -0,0 +1,23 @@ +# coding: utf-8 +lib = File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'did_you_mean/version' + +Gem::Specification.new do |spec| + spec.name = "did_you_mean" + spec.version = DidYouMean::VERSION + spec.authors = ["Yuki Nishijima"] + spec.email = ["mail@yukinishijima.net"] + spec.summary = '"Did you mean?" experience in Ruby' + spec.description = 'The gem that has been saving people from typos since 2014.' + spec.homepage = "https://github.com/ruby/did_you_mean" + spec.license = "MIT" + + spec.files = `git ls-files`.split($/).reject{|path| path.start_with?('evaluation/') } + spec.test_files = spec.files.grep(%r{^(test)/}) + spec.require_paths = ["lib"] + + spec.required_ruby_version = '>= 2.5.0' + + spec.add_development_dependency "rake" +end diff --git a/lib/did_you_mean/experimental.rb b/lib/did_you_mean/experimental.rb new file mode 100644 index 0000000000..f8e37e4532 --- /dev/null +++ b/lib/did_you_mean/experimental.rb @@ -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." diff --git a/lib/did_you_mean/experimental/initializer_name_correction.rb b/lib/did_you_mean/experimental/initializer_name_correction.rb new file mode 100644 index 0000000000..b59c98e774 --- /dev/null +++ b/lib/did_you_mean/experimental/initializer_name_correction.rb @@ -0,0 +1,20 @@ +# frozen-string-literal: true + +require_relative '../levenshtein' + +module DidYouMean + module Experimental + module InitializerNameCorrection + def method_added(name) + super + + distance = Levenshtein.distance(name.to_s, 'initialize') + if distance != 0 && distance <= 2 + warn "warning: #{name} might be misspelled, perhaps you meant initialize?" + end + end + end + + ::Class.prepend(InitializerNameCorrection) + end +end diff --git a/lib/did_you_mean/experimental/ivar_name_correction.rb b/lib/did_you_mean/experimental/ivar_name_correction.rb new file mode 100644 index 0000000000..322e422c6b --- /dev/null +++ b/lib/did_you_mean/experimental/ivar_name_correction.rb @@ -0,0 +1,76 @@ +# frozen-string-literal: true + +require_relative '../../did_you_mean' + +module DidYouMean + module Experimental #:nodoc: + class IvarNameCheckerBuilder #:nodoc: + attr_reader :original_checker + + def initialize(original_checker) #:nodoc: + @original_checker = original_checker + end + + def new(no_method_error) #:nodoc: + IvarNameChecker.new(no_method_error, original_checker: @original_checker) + end + end + + class IvarNameChecker #:nodoc: + REPLS = { + "(irb)" => -> { Readline::HISTORY.to_a.last } + } + + TRACE = TracePoint.trace(:raise) do |tp| + e = tp.raised_exception + + if SPELL_CHECKERS.include?(e.class.to_s) && !e.instance_variable_defined?(:@frame_binding) + e.instance_variable_set(:@frame_binding, tp.binding) + end + end + + attr_reader :original_checker + + def initialize(no_method_error, original_checker: ) + @original_checker = original_checker.new(no_method_error) + + @location = no_method_error.backtrace_locations.first + @ivar_names = no_method_error.frame_binding.receiver.instance_variables + + no_method_error.remove_instance_variable(:@frame_binding) + end + + def corrections + original_checker.corrections + ivar_name_corrections + end + + def ivar_name_corrections + @ivar_name_corrections ||= SpellChecker.new(dictionary: @ivar_names).correct(receiver_name.to_s) + end + + private + + def receiver_name + return unless @original_checker.receiver.nil? + + abs_path = @location.absolute_path + lineno = @location.lineno + + /@(\w+)*\.#{@original_checker.method_name}/ =~ line(abs_path, lineno).to_s && $1 + end + + def line(abs_path, lineno) + if REPLS[abs_path] + REPLS[abs_path].call + elsif File.exist?(abs_path) + File.open(abs_path) do |file| + file.detect { file.lineno == lineno } + end + end + end + end + end + + NameError.send(:attr, :frame_binding) + SPELL_CHECKERS['NoMethodError'] = Experimental::IvarNameCheckerBuilder.new(SPELL_CHECKERS['NoMethodError']) +end diff --git a/lib/did_you_mean/formatters/plain_formatter.rb b/lib/did_you_mean/formatters/plain_formatter.rb new file mode 100644 index 0000000000..e2d995f587 --- /dev/null +++ b/lib/did_you_mean/formatters/plain_formatter.rb @@ -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 diff --git a/lib/did_you_mean/formatters/verbose_formatter.rb b/lib/did_you_mean/formatters/verbose_formatter.rb new file mode 100644 index 0000000000..b8fe214d57 --- /dev/null +++ b/lib/did_you_mean/formatters/verbose_formatter.rb @@ -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 diff --git a/lib/did_you_mean/jaro_winkler.rb b/lib/did_you_mean/jaro_winkler.rb new file mode 100644 index 0000000000..56db130af4 --- /dev/null +++ b/lib/did_you_mean/jaro_winkler.rb @@ -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 diff --git a/lib/did_you_mean/levenshtein.rb b/lib/did_you_mean/levenshtein.rb new file mode 100644 index 0000000000..098053470f --- /dev/null +++ b/lib/did_you_mean/levenshtein.rb @@ -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 diff --git a/lib/did_you_mean/spell_checker.rb b/lib/did_you_mean/spell_checker.rb new file mode 100644 index 0000000000..e5106abba2 --- /dev/null +++ b/lib/did_you_mean/spell_checker.rb @@ -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 diff --git a/lib/did_you_mean/spell_checkers/key_error_checker.rb b/lib/did_you_mean/spell_checkers/key_error_checker.rb new file mode 100644 index 0000000000..be4bea7789 --- /dev/null +++ b/lib/did_you_mean/spell_checkers/key_error_checker.rb @@ -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 diff --git a/lib/did_you_mean/spell_checkers/method_name_checker.rb b/lib/did_you_mean/spell_checkers/method_name_checker.rb new file mode 100644 index 0000000000..3ca8a37e08 --- /dev/null +++ b/lib/did_you_mean/spell_checkers/method_name_checker.rb @@ -0,0 +1,56 @@ +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 ||= SpellChecker.new(dictionary: RB_RESERVED_WORDS + method_names).correct(method_name) - NAMES_TO_EXCLUDE[@receiver.class] + end + + def method_names + method_names = receiver.methods + receiver.singleton_methods + method_names += receiver.private_methods if @private_call + method_names.uniq! + method_names + end + end +end diff --git a/lib/did_you_mean/spell_checkers/name_error_checkers.rb b/lib/did_you_mean/spell_checkers/name_error_checkers.rb new file mode 100644 index 0000000000..6e2aaa4cb1 --- /dev/null +++ b/lib/did_you_mean/spell_checkers/name_error_checkers.rb @@ -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 diff --git a/lib/did_you_mean/spell_checkers/name_error_checkers/class_name_checker.rb b/lib/did_you_mean/spell_checkers/name_error_checkers/class_name_checker.rb new file mode 100644 index 0000000000..3bd048b27c --- /dev/null +++ b/lib/did_you_mean/spell_checkers/name_error_checkers/class_name_checker.rb @@ -0,0 +1,50 @@ +# frozen-string-literal: true + +require 'delegate' +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 < SimpleDelegator + attr :namespace + + def initialize(name, namespace = '') + super(name) + @namespace = namespace + end + + def full_name + self.class.new("#{namespace}#{__getobj__}") + end + end + + private_constant :ClassName + end +end diff --git a/lib/did_you_mean/spell_checkers/name_error_checkers/variable_name_checker.rb b/lib/did_you_mean/spell_checkers/name_error_checkers/variable_name_checker.rb new file mode 100644 index 0000000000..3e51b4fa3a --- /dev/null +++ b/lib/did_you_mean/spell_checkers/name_error_checkers/variable_name_checker.rb @@ -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 diff --git a/lib/did_you_mean/spell_checkers/null_checker.rb b/lib/did_you_mean/spell_checkers/null_checker.rb new file mode 100644 index 0000000000..1306f69d4a --- /dev/null +++ b/lib/did_you_mean/spell_checkers/null_checker.rb @@ -0,0 +1,6 @@ +module DidYouMean + class NullChecker + def initialize(*); end + def corrections; [] end + end +end diff --git a/lib/did_you_mean/tree_spell_checker.rb b/lib/did_you_mean/tree_spell_checker.rb new file mode 100644 index 0000000000..6a5b485413 --- /dev/null +++ b/lib/did_you_mean/tree_spell_checker.rb @@ -0,0 +1,137 @@ +module DidYouMean + # spell checker for a dictionary that has a tree + # structure, see doc/tree_spell_checker_api.md + class TreeSpellChecker + attr_reader :dictionary, :dimensions, :separator, :augment + + def initialize(dictionary:, separator: '/', augment: nil) + @dictionary = dictionary + @separator = separator + @augment = augment + @dimensions = parse_dimensions + end + + def correct(input) + plausibles = plausible_dimensions input + return no_idea(input) if plausibles.empty? + suggestions = find_suggestions input, plausibles + return no_idea(input) if suggestions.empty? + suggestions + end + + private + + def parse_dimensions + ParseDimensions.new(dictionary, separator).call + end + + def find_suggestions(input, plausibles) + states = plausibles[0].product(*plausibles[1..-1]) + paths = possible_paths states + leaf = input.split(separator).last + ideas = find_ideas(paths, leaf) + ideas.compact.flatten + end + + def no_idea(input) + return [] unless augment + ::DidYouMean::SpellChecker.new(dictionary: dictionary).correct(input) + end + + def find_ideas(paths, leaf) + paths.map do |path| + names = find_leaves(path) + ideas = CorrectElement.new.call names, leaf + ideas_to_paths ideas, leaf, names, path + end + end + + def ideas_to_paths(ideas, leaf, names, path) + return nil if ideas.empty? + return [path + separator + leaf] if names.include? leaf + ideas.map { |str| path + separator + str } + end + + def find_leaves(path) + dictionary.map do |str| + next unless str.include? "#{path}#{separator}" + str.gsub("#{path}#{separator}", '') + end.compact + end + + def possible_paths(states) + states.map do |state| + state.join separator + end + end + + def plausible_dimensions(input) + elements = input.split(separator)[0..-2] + elements.each_with_index.map do |element, i| + next if dimensions[i].nil? + CorrectElement.new.call dimensions[i], element + end.compact + end + end + + # parses the elements in each dimension + class ParseDimensions + def initialize(dictionary, separator) + @dictionary = dictionary + @separator = separator + end + + def call + leafless = remove_leaves + dimensions = find_elements leafless + dimensions.map do |elements| + elements.to_set.to_a + end + end + + private + + def remove_leaves + dictionary.map do |a| + elements = a.split(separator) + elements[0..-2] + end.to_set.to_a + end + + def find_elements(leafless) + max_elements = leafless.map(&:size).max + dimensions = Array.new(max_elements) { [] } + (0...max_elements).each do |i| + leafless.each do |elements| + dimensions[i] << elements[i] unless elements[i].nil? + end + end + dimensions + end + + attr_reader :dictionary, :separator + end + + # identifies the elements close to element + class CorrectElement + def initialize + end + + def call(names, element) + return names if names.size == 1 + str = normalize element + return [str] if names.include? str + checker = ::DidYouMean::SpellChecker.new(dictionary: names) + checker.correct(str) + end + + private + + def normalize(leaf) + str = leaf.dup + str.downcase! + return str unless str.include? '@' + str.tr!('@', ' ') + end + end +end diff --git a/lib/did_you_mean/verbose.rb b/lib/did_you_mean/verbose.rb new file mode 100644 index 0000000000..4e86f167ea --- /dev/null +++ b/lib/did_you_mean/verbose.rb @@ -0,0 +1,4 @@ +require_relative '../did_you_mean' +require_relative 'formatters/verbose_formatter' + +DidYouMean.formatter = DidYouMean::VerboseFormatter.new diff --git a/lib/did_you_mean/version.rb b/lib/did_you_mean/version.rb new file mode 100644 index 0000000000..cdfeea8026 --- /dev/null +++ b/lib/did_you_mean/version.rb @@ -0,0 +1,3 @@ +module DidYouMean + VERSION = "1.3.1" +end diff --git a/test/did_you_mean/core_ext/test_name_error_extension.rb b/test/did_you_mean/core_ext/test_name_error_extension.rb new file mode 100644 index 0000000000..dc571a8f4c --- /dev/null +++ b/test/did_you_mean/core_ext/test_name_error_extension.rb @@ -0,0 +1,48 @@ +require_relative '../helper' + +class NameErrorExtensionTest < Test::Unit::TestCase + SPELL_CHECKERS = DidYouMean::SPELL_CHECKERS + + class TestSpellChecker + def initialize(*); end + def corrections; ["does_exist"]; end + end + + def setup + @org, SPELL_CHECKERS['NameError'] = SPELL_CHECKERS['NameError'], TestSpellChecker + + @error = assert_raise(NameError){ doesnt_exist } + end + + def teardown + SPELL_CHECKERS['NameError'] = @org + end + + def test_message + assert_match(/Did you mean\? does_exist/, @error.to_s) + assert_match(/Did you mean\? does_exist/, @error.message) + end + + def test_to_s_does_not_make_disruptive_changes_to_error_message + error = assert_raise(NameError) do + raise NameError, "uninitialized constant Object" + end + + error.to_s + assert_equal 1, error.to_s.scan("Did you mean?").count + end + + def test_correctable_error_objects_are_dumpable + error = + begin + Dir.chdir(__dir__) { File.open('test_name_error_extension.rb').sizee } + rescue NoMethodError => e + e + end + + error.to_s + + assert_equal "undefined method `sizee' for #", + Marshal.load(Marshal.dump(error)).original_message + end +end diff --git a/test/did_you_mean/edit_distance/test_jaro_winkler.rb b/test/did_you_mean/edit_distance/test_jaro_winkler.rb new file mode 100644 index 0000000000..04043aaaaa --- /dev/null +++ b/test/did_you_mean/edit_distance/test_jaro_winkler.rb @@ -0,0 +1,36 @@ +require_relative '../helper' + +# These tests were originally written by Jian Weihang (簡煒航) as part of his work +# on the jaro_winkler gem. The original code could be found here: +# https://github.com/tonytonyjan/jaro_winkler/blob/9bd12421/spec/jaro_winkler_spec.rb +# +# Copyright (c) 2014 Jian Weihang + +class JaroWinklerTest < Test::Unit::TestCase + def test_jaro_winkler_distance + assert_distance 0.9667, 'henka', 'henkan' + assert_distance 1.0, 'al', 'al' + assert_distance 0.9611, 'martha', 'marhta' + assert_distance 0.8324, 'jones', 'johnson' + assert_distance 0.9167, 'abcvwxyz', 'zabcvwxy' + assert_distance 0.9583, 'abcvwxyz', 'cabvwxyz' + assert_distance 0.84, 'dwayne', 'duane' + assert_distance 0.8133, 'dixon', 'dicksonx' + assert_distance 0.0, 'fvie', 'ten' + assert_distance 0.9067, 'does_exist', 'doesnt_exist' + assert_distance 1.0, 'x', 'x' + end + + def test_jarowinkler_distance_with_utf8_strings + assert_distance 0.9818, '變形金剛4:絕跡重生', '變形金剛4: 絕跡重生' + assert_distance 0.8222, '連勝文', '連勝丼' + assert_distance 0.8222, '馬英九', '馬英丸' + assert_distance 0.6667, '良い', 'いい' + end + + private + + def assert_distance(score, str1, str2) + assert_equal score, DidYouMean::JaroWinkler.distance(str1, str2).round(4) + end +end diff --git a/test/did_you_mean/fixtures/book.rb b/test/did_you_mean/fixtures/book.rb new file mode 100644 index 0000000000..e6644a6263 --- /dev/null +++ b/test/did_you_mean/fixtures/book.rb @@ -0,0 +1,4 @@ +class Book + class Cover + end +end diff --git a/test/did_you_mean/fixtures/mini_dir.yml b/test/did_you_mean/fixtures/mini_dir.yml new file mode 100644 index 0000000000..12fd96083d --- /dev/null +++ b/test/did_you_mean/fixtures/mini_dir.yml @@ -0,0 +1,15 @@ +--- +- test/core_ext/name_error_extension_test.rb +- test/edit_distance/jaro_winkler_test.rb +- test/fixtures/book.rb +- test/spell_checker_test.rb +- test/spell_checking/class_name_check_test.rb +- test/spell_checking/key_name_check_test.rb +- test/spell_checking/method_name_check_test.rb +- test/spell_checking/uncorrectable_name_check_test.rb +- test/spell_checking/variable_name_check_test.rb +- test/test_helper.rb +- test/tree_spell_checker_test.rb +- test/tree_spell_explore_test.rb +- test/tree_spell_human_typo_test.rb +- test/verbose_formatter_test.rb diff --git a/test/did_you_mean/fixtures/rspec_dir.yml b/test/did_you_mean/fixtures/rspec_dir.yml new file mode 100644 index 0000000000..66d8a5f6a0 --- /dev/null +++ b/test/did_you_mean/fixtures/rspec_dir.yml @@ -0,0 +1,112 @@ +--- +- spec/spec_helper.rb +- spec/integration/suite_hooks_errors_spec.rb +- spec/integration/filtering_spec.rb +- spec/integration/spec_file_load_errors_spec.rb +- spec/integration/failed_line_detection_spec.rb +- spec/integration/persistence_failures_spec.rb +- spec/integration/bisect_runners_spec.rb +- spec/integration/order_spec.rb +- spec/integration/fail_if_no_examples_spec.rb +- spec/integration/bisect_spec.rb +- spec/integration/output_stream_spec.rb +- spec/support/sandboxing.rb +- spec/support/spec_files.rb +- spec/support/fake_libs/json.rb +- spec/support/fake_libs/open3.rb +- spec/support/fake_libs/drb/acl.rb +- spec/support/fake_libs/drb/drb.rb +- spec/support/fake_libs/mocha/api.rb +- spec/support/fake_libs/test/unit/assertions.rb +- spec/support/fake_libs/flexmock/rspec.rb +- spec/support/fake_libs/rake/tasklib.rb +- spec/support/fake_libs/coderay.rb +- spec/support/fake_libs/rr.rb +- spec/support/fake_libs/rake.rb +- spec/support/fake_libs/erb.rb +- spec/support/fake_libs/rspec/mocks.rb +- spec/support/fake_libs/rspec/expectations.rb +- spec/support/fake_libs/minitest/assertions.rb +- spec/support/fake_libs/minitest.rb +- spec/support/matchers.rb +- spec/support/runner_support.rb +- spec/support/isolated_home_directory.rb +- spec/support/config_options_helper.rb +- spec/support/mathn_integration_support.rb +- spec/support/helper_methods.rb +- spec/support/formatter_support.rb +- spec/support/fake_bisect_runner.rb +- spec/support/shared_example_groups.rb +- spec/support/aruba_support.rb +- spec/rspec/core/runner_spec.rb +- spec/rspec/core/did_you_mean_spec.rb +- spec/rspec/core/drb_spec.rb +- spec/rspec/core/metadata_spec.rb +- spec/rspec/core/example_group_spec.rb +- spec/rspec/core/configuration/only_failures_support_spec.rb +- spec/rspec/core/rake_task_spec.rb +- spec/rspec/core/memoized_helpers_spec.rb +- spec/rspec/core/ordering_spec.rb +- spec/rspec/core/option_parser_spec.rb +- spec/rspec/core/example_execution_result_spec.rb +- spec/rspec/core/suite_hooks_spec.rb +- spec/rspec/core/set_spec.rb +- spec/rspec/core/configuration_spec.rb +- spec/rspec/core/rspec_matchers_spec.rb +- spec/rspec/core/hooks_filtering_spec.rb +- spec/rspec/core/bisect/shell_command_spec.rb +- spec/rspec/core/bisect/server_spec.rb +- spec/rspec/core/bisect/example_minimizer_spec.rb +- spec/rspec/core/bisect/shell_runner_spec.rb +- spec/rspec/core/bisect/utilities_spec.rb +- spec/rspec/core/bisect/coordinator_spec.rb +- spec/rspec/core/resources/a_foo.rb +- spec/rspec/core/resources/formatter_specs.rb +- spec/rspec/core/resources/inconsistently_ordered_specs.rb +- spec/rspec/core/resources/a_bar.rb +- spec/rspec/core/resources/utf8_encoded.rb +- spec/rspec/core/resources/a_spec.rb +- spec/rspec/core/resources/acceptance/bar.rb +- spec/rspec/core/resources/acceptance/foo_spec.rb +- spec/rspec/core/resources/custom_example_group_runner.rb +- spec/rspec/core/failed_example_notification_spec.rb +- spec/rspec/core/hooks_spec.rb +- spec/rspec/core/formatters/profile_formatter_spec.rb +- spec/rspec/core/formatters/deprecation_formatter_spec.rb +- spec/rspec/core/formatters/syntax_highlighter_spec.rb +- spec/rspec/core/formatters/base_text_formatter_spec.rb +- spec/rspec/core/formatters/snippet_extractor_spec.rb +- spec/rspec/core/formatters/progress_formatter_spec.rb +- spec/rspec/core/formatters/html_snippet_extractor_spec.rb +- spec/rspec/core/formatters/helpers_spec.rb +- spec/rspec/core/formatters/html_formatter_spec.rb +- spec/rspec/core/formatters/json_formatter_spec.rb +- spec/rspec/core/formatters/documentation_formatter_spec.rb +- spec/rspec/core/formatters/exception_presenter_spec.rb +- spec/rspec/core/formatters/console_codes_spec.rb +- spec/rspec/core/formatters/fallback_message_formatter_spec.rb +- spec/rspec/core/invocations_spec.rb +- spec/rspec/core/configuration_options_spec.rb +- spec/rspec/core/pending_spec.rb +- spec/rspec/core/profiler_spec.rb +- spec/rspec/core/project_initializer_spec.rb +- spec/rspec/core/aggregate_failures_spec.rb +- spec/rspec/core/dsl_spec.rb +- spec/rspec/core/ruby_project_spec.rb +- spec/rspec/core/formatters_spec.rb +- spec/rspec/core/metadata_filter_spec.rb +- spec/rspec/core/example_group_constants_spec.rb +- spec/rspec/core/world_spec.rb +- spec/rspec/core/shared_context_spec.rb +- spec/rspec/core/pending_example_spec.rb +- spec/rspec/core/filter_manager_spec.rb +- spec/rspec/core/shared_example_group_spec.rb +- spec/rspec/core/example_status_persister_spec.rb +- spec/rspec/core/backtrace_formatter_spec.rb +- spec/rspec/core/output_wrapper_spec.rb +- spec/rspec/core/example_spec.rb +- spec/rspec/core/reporter_spec.rb +- spec/rspec/core/filterable_item_repository_spec.rb +- spec/rspec/core/notifications_spec.rb +- spec/rspec/core/warnings_spec.rb +- spec/rspec/core_spec.rb diff --git a/test/did_you_mean/helper.rb b/test/did_you_mean/helper.rb new file mode 100644 index 0000000000..d8aa41c3d1 --- /dev/null +++ b/test/did_you_mean/helper.rb @@ -0,0 +1,29 @@ +require 'test/unit' + +module DidYouMean + module TestHelper + class << self + attr_reader :root + end + + if File.file?(File.expand_path('../lib/did_you_mean.rb', __dir__)) + # In this case we're being run from inside the gem, so we just want to + # require the root of the library + + @root = File.expand_path('../lib/did_you_mean', __dir__) + require_relative @root + else + # In this case we're being run from inside ruby core, and we want to + # include the experimental features in the test suite + + @root = File.expand_path('../../lib/did_you_mean', __dir__) + require_relative @root + # We are excluding experimental features for now. + # require_relative File.join(@root, 'experimental') + end + + def assert_correction(expected, array) + assert_equal Array(expected), array, "Expected #{array.inspect} to only include #{expected.inspect}" + end + end +end diff --git a/test/did_you_mean/spell_checking/test_class_name_check.rb b/test/did_you_mean/spell_checking/test_class_name_check.rb new file mode 100644 index 0000000000..388dbe89a7 --- /dev/null +++ b/test/did_you_mean/spell_checking/test_class_name_check.rb @@ -0,0 +1,79 @@ +require_relative '../helper' + +module ACRONYM +end + +class Project + def self.bo0k + Bo0k + end +end + +class Book + class TableOfContents; end + + def tableof_contents + TableofContents + end + + class Page + def tableof_contents + TableofContents + end + + def self.tableof_contents + TableofContents + end + end +end + +class ClassNameCheckTest < Test::Unit::TestCase + include DidYouMean::TestHelper + + def test_corrections + error = assert_raise(NameError) { ::Bo0k } + assert_correction "Book", error.corrections + end + + def test_corrections_include_case_specific_class_name + error = assert_raise(NameError) { ::Acronym } + assert_correction "ACRONYM", error.corrections + end + + def test_corrections_include_top_level_class_name + error = assert_raise(NameError) { Project.bo0k } + assert_correction "Book", error.corrections + end + + def test_names_in_corrections_have_namespaces + error = assert_raise(NameError) { ::Book::TableofContents } + assert_correction "Book::TableOfContents", error.corrections + end + + def test_corrections_candidates_for_names_in_upper_level_scopes + error = assert_raise(NameError) { Book::Page.tableof_contents } + assert_correction "Book::TableOfContents", error.corrections + end + + def test_corrections_should_work_from_within_instance_method + error = assert_raise(NameError) { ::Book.new.tableof_contents } + assert_correction "Book::TableOfContents", error.corrections + end + + def test_corrections_should_work_from_within_instance_method_on_nested_class + error = assert_raise(NameError) { ::Book::Page.new.tableof_contents } + assert_correction "Book::TableOfContents", error.corrections + end + + def test_does_not_suggest_user_input + error = assert_raise(NameError) { ::Book::Cover } + + # This is a weird require, but in a multi-threaded condition, a constant may + # be loaded between when a NameError occurred and when the spell checker + # attemps to find a possible suggestion. The manual require here simulates + # a race condition a single test. + require_relative '../fixtures/book' + + assert_empty error.corrections + end +end diff --git a/test/did_you_mean/spell_checking/test_key_name_check.rb b/test/did_you_mean/spell_checking/test_key_name_check.rb new file mode 100644 index 0000000000..ea05ff69e4 --- /dev/null +++ b/test/did_you_mean/spell_checking/test_key_name_check.rb @@ -0,0 +1,54 @@ +require_relative '../helper' + +class KeyNameCheckTest < Test::Unit::TestCase + include DidYouMean::TestHelper + + def test_corrects_hash_key_name_with_fetch + hash = { "foo" => 1, bar: 2 } + + error = assert_raise(KeyError) { hash.fetch(:bax) } + assert_correction ":bar", error.corrections + assert_match "Did you mean? :bar", error.to_s + + error = assert_raise(KeyError) { hash.fetch("fooo") } + assert_correction %("foo"), error.corrections + assert_match %(Did you mean? "foo"), error.to_s + end + + def test_corrects_hash_key_name_with_fetch_values + hash = { "foo" => 1, bar: 2 } + + error = assert_raise(KeyError) { hash.fetch_values("foo", :bar, :bax) } + assert_correction ":bar", error.corrections + assert_match "Did you mean? :bar", error.to_s + + error = assert_raise(KeyError) { hash.fetch_values("foo", :bar, "fooo") } + assert_correction %("foo"), error.corrections + assert_match %(Did you mean? "foo"), error.to_s + end + + def test_correct_symbolized_hash_keys_with_string_value + hash = { foo_1: 1, bar_2: 2 } + + error = assert_raise(KeyError) { hash.fetch('foo_1') } + assert_correction %(:foo_1), error.corrections + assert_match %(Did you mean? :foo_1), error.to_s + end + + def test_corrects_sprintf_key_name + error = assert_raise(KeyError) { sprintf("%d", {fooo: 1}) } + assert_correction ":fooo", error.corrections + assert_match "Did you mean? :fooo", error.to_s + end + + def test_corrects_env_key_name + ENV["FOO"] = "1" + ENV["BAR"] = "2" + error = assert_raise(KeyError) { ENV.fetch("BAX") } + assert_correction %("BAR"), error.corrections + assert_match %(Did you mean? "BAR"), error.to_s + ensure + ENV.delete("FOO") + ENV.delete("BAR") + end +end diff --git a/test/did_you_mean/spell_checking/test_method_name_check.rb b/test/did_you_mean/spell_checking/test_method_name_check.rb new file mode 100644 index 0000000000..f3a6b1c7c7 --- /dev/null +++ b/test/did_you_mean/spell_checking/test_method_name_check.rb @@ -0,0 +1,140 @@ +require_relative '../helper' + +class MethodNameCheckTest < Test::Unit::TestCase + include DidYouMean::TestHelper + + class User + def friends; end + def first_name; end + def descendants; end + def call_incorrect_private_method + raiae NoMethodError + end + + def raise_no_method_error + self.firstname + rescue NoMethodError => e + raise e, e.message, e.backtrace + end + + protected + def the_protected_method; end + + private + def friend; end + def the_private_method; end + + class << self + def load; end + end + end + + module UserModule + def from_module; end + end + + def setup + @user = User.new.extend(UserModule) + end + + def test_corrections_include_instance_method + error = assert_raise(NoMethodError){ @user.flrst_name } + + assert_correction :first_name, error.corrections + assert_match "Did you mean? first_name", error.to_s + end + + def test_corrections_include_private_method + error = assert_raise(NoMethodError){ @user.friend } + + assert_correction :friends, error.corrections + assert_match "Did you mean? friends", error.to_s + end + + def test_corrections_include_method_from_module + error = assert_raise(NoMethodError){ @user.fr0m_module } + + assert_correction :from_module, error.corrections + assert_match "Did you mean? from_module", error.to_s + end + + def test_corrections_include_class_method + error = assert_raise(NoMethodError){ User.l0ad } + + assert_correction :load, error.corrections + assert_match "Did you mean? load", error.to_s + end + + def test_private_methods_should_not_be_suggested + error = assert_raise(NoMethodError){ User.new.the_protected_method } + refute_includes error.corrections, :the_protected_method + + error = assert_raise(NoMethodError){ User.new.the_private_method } + refute_includes error.corrections, :the_private_method + end + + def test_corrections_when_private_method_is_called_with_args + error = assert_raise(NoMethodError){ @user.call_incorrect_private_method } + + assert_correction :raise, error.corrections + assert_match "Did you mean? raise", error.to_s + end + + def test_exclude_methods_on_nil + error = assert_raise(NoMethodError){ nil.map } + assert_empty error.corrections + end + + def test_does_not_exclude_custom_methods_on_nil + def nil.empty? + end + + error = assert_raise(NoMethodError){ nil.empty } + assert_correction :empty?, error.corrections + ensure + NilClass.class_eval { undef empty? } + end + + def test_does_not_append_suggestions_twice + error = assert_raise NoMethodError do + begin + @user.firstname + rescue NoMethodError => e + raise e, e.message, e.backtrace + end + end + + assert_equal 1, error.to_s.scan(/Did you mean/).count + end + + def test_does_not_append_suggestions_three_times + error = assert_raise NoMethodError do + begin + @user.raise_no_method_error + rescue NoMethodError => e + raise e, e.message, e.backtrace + end + end + + assert_equal 1, error.to_s.scan(/Did you mean/).count + end + + def test_suggests_corrections_on_nested_error + error = assert_raise NoMethodError do + begin + @user.firstname + rescue NoMethodError + @user.firstname + end + end + + assert_equal 1, error.to_s.scan(/Did you mean/).count + end + + def test_suggests_yield + error = assert_raise(NoMethodError) { yeild(1) } + + assert_correction :yield, error.corrections + assert_match "Did you mean? yield", error.to_s + end +end diff --git a/test/did_you_mean/spell_checking/test_uncorrectable_name_check.rb b/test/did_you_mean/spell_checking/test_uncorrectable_name_check.rb new file mode 100644 index 0000000000..5d934e5f70 --- /dev/null +++ b/test/did_you_mean/spell_checking/test_uncorrectable_name_check.rb @@ -0,0 +1,15 @@ +require_relative '../helper' + +class UncorrectableNameCheckTest < Test::Unit::TestCase + class FirstNameError < NameError; end + + def setup + @error = assert_raise(FirstNameError) do + raise FirstNameError, "Other name error" + end + end + + def test_message + assert_equal "Other name error", @error.message + end +end diff --git a/test/did_you_mean/spell_checking/test_variable_name_check.rb b/test/did_you_mean/spell_checking/test_variable_name_check.rb new file mode 100644 index 0000000000..193e2b7520 --- /dev/null +++ b/test/did_you_mean/spell_checking/test_variable_name_check.rb @@ -0,0 +1,140 @@ +require_relative '../helper' + +class VariableNameCheckTest < Test::Unit::TestCase + include DidYouMean::TestHelper + + class User + def initialize + @email_address = 'email_address@address.net' + @first_name = nil + @last_name = nil + end + + def first_name; end + def to_s + "#{@first_name} #{@last_name} <#{email_address}>" + end + + private + + def cia_codename; "Alexa" end + end + + module UserModule + def from_module; end + end + + def setup + @user = User.new.extend(UserModule) + end + + def test_corrections_include_instance_method + error = assert_raise(NameError) do + @user.instance_eval { flrst_name } + end + + @user.instance_eval do + remove_instance_variable :@first_name + remove_instance_variable :@last_name + end + + assert_correction :first_name, error.corrections + assert_match "Did you mean? first_name", error.to_s + end + + def test_corrections_include_method_from_module + error = assert_raise(NameError) do + @user.instance_eval { fr0m_module } + end + + assert_correction :from_module, error.corrections + assert_match "Did you mean? from_module", error.to_s + end + + def test_corrections_include_local_variable_name + if RUBY_ENGINE != "jruby" + person = person = nil + error = (eprson rescue $!) # Do not use @assert_raise here as it changes a scope. + + assert_correction :person, error.corrections + assert_match "Did you mean? person", error.to_s + end + end + + def test_corrections_include_ruby_predefined_objects + some_var = some_var = nil + + false_error = assert_raise(NameError) do + some_var = fals + end + + true_error = assert_raise(NameError) do + some_var = treu + end + + nil_error = assert_raise(NameError) do + some_var = nul + end + + file_error = assert_raise(NameError) do + __FIEL__ + end + + assert_correction :false, false_error.corrections + assert_match "Did you mean? false", false_error.to_s + + assert_correction :true, true_error.corrections + assert_match "Did you mean? true", true_error.to_s + + assert_correction :nil, nil_error.corrections + assert_match "Did you mean? nil", nil_error.to_s + + assert_correction :__FILE__, file_error.corrections + assert_match "Did you mean? __FILE__", file_error.to_s + end + + def test_suggests_yield + error = assert_raise(NameError) { yeild } + + assert_correction :yield, error.corrections + assert_match "Did you mean? yield", error.to_s + end + + def test_corrections_include_instance_variable_name + error = assert_raise(NameError){ @user.to_s } + + assert_correction :@email_address, error.corrections + assert_match "Did you mean? @email_address", error.to_s + end + + def test_corrections_include_private_method + error = assert_raise(NameError) do + @user.instance_eval { cia_code_name } + end + + assert_correction :cia_codename, error.corrections + assert_match "Did you mean? cia_codename", error.to_s + end + + @@does_exist = true + + def test_corrections_include_class_variable_name + error = assert_raise(NameError){ @@doesnt_exist } + + assert_correction :@@does_exist, error.corrections + assert_match "Did you mean? @@does_exist", error.to_s + end + + def test_struct_name_error + value = Struct.new(:does_exist).new + error = assert_raise(NameError){ value[:doesnt_exist] } + + assert_correction [:does_exist, :does_exist=], error.corrections + assert_match "Did you mean? does_exist", error.to_s + end + + def test_exclude_typical_incorrect_suggestions + error = assert_raise(NameError){ foo } + assert_empty error.corrections + end +end diff --git a/test/did_you_mean/test_spell_checker.rb b/test/did_you_mean/test_spell_checker.rb new file mode 100644 index 0000000000..98460b4d94 --- /dev/null +++ b/test/did_you_mean/test_spell_checker.rb @@ -0,0 +1,77 @@ +require_relative './helper' + +class SpellCheckerTest < Test::Unit::TestCase + def test_spell_checker_corrects_mistypes + assert_spell 'foo', input: 'doo', dictionary: ['foo', 'fork'] + assert_spell 'email', input: 'meail', dictionary: ['email', 'fail', 'eval'] + assert_spell 'fail', input: 'fial', dictionary: ['email', 'fail', 'eval'] + assert_spell 'fail', input: 'afil', dictionary: ['email', 'fail', 'eval'] + assert_spell 'eval', input: 'eavl', dictionary: ['email', 'fail', 'eval'] + assert_spell 'eval', input: 'veal', dictionary: ['email', 'fail', 'eval'] + assert_spell 'sub!', input: 'suv!', dictionary: ['sub', 'gsub', 'sub!'] + assert_spell 'sub', input: 'suv', dictionary: ['sub', 'gsub', 'sub!'] + + assert_spell %w(gsub! gsub), input: 'gsuv!', dictionary: %w(sub gsub gsub!) + assert_spell %w(sub! sub gsub!), input: 'ssub!', dictionary: %w(sub sub! gsub gsub!) + + group_methods = %w(groups group_url groups_url group_path) + assert_spell 'groups', input: 'group', dictionary: group_methods + + group_classes = %w( + GroupMembership + GroupMembershipPolicy + GroupMembershipDecorator + GroupMembershipSerializer + GroupHelper + Group + GroupMailer + NullGroupMembership + ) + + assert_spell 'GroupMembership', dictionary: group_classes, input: 'GroupMemberhip' + assert_spell 'GroupMembershipDecorator', dictionary: group_classes, input: 'GroupMemberhipDecorator' + + names = %w(first_name_change first_name_changed? first_name_will_change!) + assert_spell names, input: 'first_name_change!', dictionary: names + + assert_empty DidYouMean::SpellChecker.new(dictionary: ['proc']).correct('product_path') + assert_empty DidYouMean::SpellChecker.new(dictionary: ['fork']).correct('fooo') + end + + def test_spell_checker_corrects_misspells + assert_spell 'descendants', input: 'dependents', dictionary: ['descendants'] + assert_spell 'drag_to', input: 'drag', dictionary: ['drag_to'] + assert_spell 'set_result_count', input: 'set_result', dictionary: ['set_result_count'] + end + + def test_spell_checker_sorts_results_by_simiarity + expected = %w( + name12345 + name1234 + name123 + ) + + actual = DidYouMean::SpellChecker.new(dictionary: %w( + name12 + name123 + name1234 + name12345 + name123456 + )).correct('name123456') + + assert_equal expected, actual + end + + def test_spell_checker_excludes_input_from_dictionary + assert_empty DidYouMean::SpellChecker.new(dictionary: ['input']).correct('input') + assert_empty DidYouMean::SpellChecker.new(dictionary: [:input]).correct('input') + assert_empty DidYouMean::SpellChecker.new(dictionary: ['input']).correct(:input) + end + + private + + def assert_spell(expected, input: , dictionary: ) + corrections = DidYouMean::SpellChecker.new(dictionary: dictionary).correct(input) + assert_equal Array(expected), corrections, "Expected to suggest #{expected}, but got #{corrections.inspect}" + end +end diff --git a/test/did_you_mean/test_verbose_formatter.rb b/test/did_you_mean/test_verbose_formatter.rb new file mode 100644 index 0000000000..43ce83287e --- /dev/null +++ b/test/did_you_mean/test_verbose_formatter.rb @@ -0,0 +1,22 @@ +require_relative './helper' + +class VerboseFormatterTest < Test::Unit::TestCase + def setup + require_relative File.join(DidYouMean::TestHelper.root, 'verbose') + end + + def teardown + DidYouMean.formatter = DidYouMean::PlainFormatter.new + end + + def test_message + @error = assert_raise(NoMethodError){ 1.zeor? } + + assert_equal <<~MESSAGE.chomp, @error.message + undefined method `zeor?' for 1:Integer + + Did you mean? zero? + + MESSAGE + end +end diff --git a/test/did_you_mean/tree_spell/change_word.rb b/test/did_you_mean/tree_spell/change_word.rb new file mode 100644 index 0000000000..d34b1f38e6 --- /dev/null +++ b/test/did_you_mean/tree_spell/change_word.rb @@ -0,0 +1,61 @@ +module TreeSpell + # Changes a word with one of four actions: + # insertion, substitution, deletion and transposition. + class ChangeWord + # initialize with input string + def initialize(input) + @input = input + @len = input.length + end + + # insert char after index of i_place + def insertion(i_place, char) + @word = input.dup + return char + word if i_place == 0 + return word + char if i_place == len - 1 + word.insert(i_place + 1, char) + end + + # substitute char at index of i_place + def substitution(i_place, char) + @word = input.dup + word[i_place] = char + word + end + + # delete character at index of i_place + def deletion(i_place) + @word = input.dup + word.slice!(i_place) + word + end + + # transpose char at i_place with char at i_place + direction + # if i_place + direction is out of bounds just swap in other direction + def transposition(i_place, direction) + @word = input.dup + w = word.dup + return swap_first_two(w) if i_place + direction < 0 + return swap_last_two(w) if i_place + direction >= len + swap_two(w, i_place, direction) + w + end + + private + + attr_accessor :word, :input, :len + + def swap_first_two(w) + w[1] + w[0] + word[2..-1] + end + + def swap_last_two(w) + w[0...(len - 2)] + word[len - 1] + word[len - 2] + end + + def swap_two(w, i_place, direction) + w[i_place] = word[i_place + direction] + w[i_place + direction] = word[i_place] + end + end +end diff --git a/test/did_you_mean/tree_spell/human_typo.rb b/test/did_you_mean/tree_spell/human_typo.rb new file mode 100644 index 0000000000..302d4d6902 --- /dev/null +++ b/test/did_you_mean/tree_spell/human_typo.rb @@ -0,0 +1,89 @@ +# module for classes needed to test TreeSpellChecker +module TreeSpell + require_relative 'change_word' + # Simulate an error prone human typist + # see doc/human_typo_api.md for the api description + class HumanTypo + def initialize(input, lambda: 0.05) + @input = input + check_input + @len = input.length + @lambda = lambda + end + + def call + @word = input.dup + i_place = initialize_i_place + loop do + action = action_type + @word = make_change action, i_place + @len = word.length + i_place += exponential + break if i_place >= len + end + word + end + + private + + attr_accessor :input, :word, :len, :lambda + + def initialize_i_place + i_place = nil + loop do + i_place = exponential + break if i_place < len + end + i_place + end + + def exponential + (rand / (lambda / 2)).to_i + end + + def rand_char + popular_chars = alphabetic_characters + special_characters + n = popular_chars.length + popular_chars[rand(n)] + end + + def alphabetic_characters + ('a'..'z').to_a.join + ('A'..'Z').to_a.join + end + + def special_characters + '?<>,.!`+=-_":;@#$%^&*()' + end + + def toss + return +1 if rand >= 0.5 + -1 + end + + def action_type + [:insert, :transpose, :delete, :substitute][rand(4)] + end + + def make_change(action, i_place) + cw = ChangeWord.new(word) + case action + when :delete + cw.deletion(i_place) + when :insert + cw.insertion(i_place, rand_char) + when :substitute + cw.substitution(i_place, rand_char) + when :transpose + cw.transposition(i_place, toss) + end + end + + def check_input + fail check_input_message if input.nil? || input.length < 5 + end + + def check_input_message + "input length must be greater than 5 characters: #{input}" + end + end +end diff --git a/test/did_you_mean/tree_spell/test_change_word.rb b/test/did_you_mean/tree_spell/test_change_word.rb new file mode 100644 index 0000000000..613e11b869 --- /dev/null +++ b/test/did_you_mean/tree_spell/test_change_word.rb @@ -0,0 +1,38 @@ +require_relative '../helper' +require_relative 'change_word' + +class ChangeWordTest < Test::Unit::TestCase + def setup + @input = 'spec/services/anything_spec' + @cw = TreeSpell::ChangeWord.new(@input) + @len = @input.length + end + + def test_deleletion + assert_match @cw.deletion(5), 'spec/ervices/anything_spec' + assert_match @cw.deletion(@len - 1), 'spec/services/anything_spe' + assert_match @cw.deletion(0), 'pec/services/anything_spec' + end + + def test_substitution + assert_match @cw.substitution(5, '$'), 'spec/$ervices/anything_spec' + assert_match @cw.substitution(@len - 1, '$'), 'spec/services/anything_spe$' + assert_match @cw.substitution(0, '$'), '$pec/services/anything_spec' + end + + def test_insertion + assert_match @cw.insertion(7, 'X'), 'spec/serXvices/anything_spec' + assert_match @cw.insertion(0, 'X'), 'Xspec/services/anything_spec' + assert_match @cw.insertion(@len - 1, 'X'), 'spec/services/anything_specX' + end + + def test_transposition + n = @input.length + assert_match @cw.transposition(0, -1), 'psec/services/anything_spec' + assert_match @cw.transposition(n - 1, +1), 'spec/services/anything_spce' + assert_match @cw.transposition(4, +1), 'specs/ervices/anything_spec' + assert_match @cw.transposition(4, -1), 'spe/cservices/anything_spec' + assert_match @cw.transposition(21, -1), 'spec/services/anythign_spec' + assert_match @cw.transposition(21, +1), 'spec/services/anythin_gspec' + end +end diff --git a/test/did_you_mean/tree_spell/test_human_typo.rb b/test/did_you_mean/tree_spell/test_human_typo.rb new file mode 100644 index 0000000000..7ede9e393e --- /dev/null +++ b/test/did_you_mean/tree_spell/test_human_typo.rb @@ -0,0 +1,24 @@ +require_relative '../helper' +require_relative 'human_typo' + +class HumanTypoTest < Test::Unit::TestCase + def setup + @input = 'spec/services/anything_spec' + @sh = TreeSpell::HumanTypo.new(@input, lambda: 0.05) + @len = @input.length + end + + def test_changes + # srand seed ensures all four actions are called + srand 247_696_449 + sh = TreeSpell::HumanTypo.new(@input, lambda: 0.20) + word_error = sh.call + assert_equal word_error, 'spec/suervcieq/anythin_gpec' + end + + def test_check_input + assert_raise(RuntimeError, "input length must be greater than 5 characters: tiny") do + TreeSpell::HumanTypo.new('tiny') + end + end +end diff --git a/test/did_you_mean/tree_spell_checker_test.rb b/test/did_you_mean/tree_spell_checker_test.rb new file mode 100644 index 0000000000..b61a491e20 --- /dev/null +++ b/test/did_you_mean/tree_spell_checker_test.rb @@ -0,0 +1,173 @@ +require 'set' +require 'yaml' + +require_relative './helper' + +class TreeSpellCheckerTest < Test::Unit::TestCase + MINI_DIRECTORIES = YAML.load_file(File.expand_path('fixtures/mini_dir.yml', __dir__)) + RSPEC_DIRECTORIES = YAML.load_file(File.expand_path('fixtures/rspec_dir.yml', __dir__)) + + def setup + @dictionary = + %w( + spec/models/concerns/vixen_spec.rb + spec/models/concerns/abcd_spec.rb + spec/models/concerns/vixenus_spec.rb + spec/models/concerns/efgh_spec.rb + spec/modals/confirms/abcd_spec.rb + spec/modals/confirms/efgh_spec.rb + spec/models/gafafa_spec.rb + spec/models/gfsga_spec.rb + spec/controllers/vixen_controller_spec.rb + ) + @test_str = 'spek/modeks/confirns/viken_spec.rb' + @tsp = DidYouMean::TreeSpellChecker.new(dictionary: @dictionary) + end + + def test_corrupt_root + word = 'test/verbose_formatter_test.rb' + word_error = 'btets/cverbose_formatter_etst.rb suggestions' + tsp = DidYouMean::TreeSpellChecker.new(dictionary: MINI_DIRECTORIES) + s = tsp.correct(word_error).first + assert_match s, word + end + + def test_leafless_state + tsp = DidYouMean::TreeSpellChecker.new(dictionary: @dictionary.push('spec/features')) + word = 'spec/modals/confirms/efgh_spec.rb' + word_error = 'spec/modals/confirXX/efgh_spec.rb' + s = tsp.correct(word_error).first + assert_equal s, word + s = tsp.correct('spec/featuresXX') + assert_equal 'spec/features', s.first + end + + def test_rake_dictionary + dict = %w(parallel:prepare parallel:create parallel:rake parallel:migrate) + word_error = 'parallel:preprare' + tsp = DidYouMean::TreeSpellChecker.new(dictionary: dict, separator: ':') + s = tsp.correct(word_error).first + assert_match s, 'parallel:prepare' + end + + def test_special_words_mini + tsp = DidYouMean::TreeSpellChecker.new(dictionary: MINI_DIRECTORIES) + special_words_mini.each do |word, word_error| + s = tsp.correct(word_error).first + assert_match s, word + end + end + + def test_special_words_rspec + tsp = DidYouMean::TreeSpellChecker.new(dictionary: RSPEC_DIRECTORIES) + special_words_rspec.each do |word, word_error| + s = tsp.correct(word_error) + assert_match s.first, word + end + end + + def special_words_rspec + [ + ['spec/rspec/core/formatters/exception_presenter_spec.rb','spec/rspec/core/formatters/eception_presenter_spec.rb'], + ['spec/rspec/core/ordering_spec.rb', 'spec/spec/core/odrering_spec.rb'], + ['spec/rspec/core/metadata_spec.rb', 'spec/rspec/core/metadata_spe.crb'], + ['spec/support/mathn_integration_support.rb', 'spec/support/mathn_itegrtion_support.rb'] + ] + end + + def special_words_mini + [ + ['test/fixtures/book.rb', 'test/fixture/book.rb'], + ['test/fixtures/book.rb', 'test/fixture/book.rb'], + ['test/edit_distance/jaro_winkler_test.rb', 'test/edit_distace/jaro_winkler_test.rb'], + ['test/edit_distance/jaro_winkler_test.rb', 'teste/dit_distane/jaro_winkler_test.rb'], + ['test/fixtures/book.rb', 'test/fixturWes/book.rb'], + ['test/test_helper.rb', 'tes!t/test_helper.rb'], + ['test/fixtures/book.rb', 'test/hfixtures/book.rb'], + ['test/edit_distance/jaro_winkler_test.rb', 'test/eidt_distance/jaro_winkler_test.@rb'], + ['test/spell_checker_test.rb', 'test/spell_checke@r_test.rb'], + ['test/tree_spell_human_typo_test.rb', 'testt/ree_spell_human_typo_test.rb'], + ['test/spell_checking/variable_name_check_test.rb', 'test/spell_checking/vriabl_ename_check_test.rb'], + ['test/spell_checking/key_name_check_test.rb', 'tesit/spell_checking/key_name_choeck_test.rb'], + ['test/edit_distance/jaro_winkler_test.rb', 'test/edit_distance/jaro_winkler_tuest.rb'] + ] + end + + def test_file_in_root + word = 'test/spell_checker_test.rb' + word_error = 'test/spell_checker_test.r' + suggestions = DidYouMean::TreeSpellChecker.new(dictionary: MINI_DIRECTORIES).correct word_error + assert_equal word, suggestions.first + end + + def test_no_plausible_states + word_error = 'testspell_checker_test.rb' + suggestions = DidYouMean::TreeSpellChecker.new(dictionary: MINI_DIRECTORIES).correct word_error + assert_equal [], suggestions + end + + def test_no_plausible_states_with_augmentation + word_error = 'testspell_checker_test.rb' + suggestions = DidYouMean::TreeSpellChecker.new(dictionary: MINI_DIRECTORIES).correct word_error + assert_equal [], suggestions + suggestions = DidYouMean::TreeSpellChecker.new(dictionary: MINI_DIRECTORIES, augment: true).correct word_error + assert_equal 'test/spell_checker_test.rb', suggestions.first + end + + def test_no_idea_with_augmentation + word_error = 'test/spell_checking/key_name.rb' + suggestions = DidYouMean::TreeSpellChecker.new(dictionary: MINI_DIRECTORIES).correct word_error + assert_equal [], suggestions + suggestions = DidYouMean::TreeSpellChecker.new(dictionary: MINI_DIRECTORIES, augment: true).correct word_error + assert_equal 'test/spell_checking/key_name_check_test.rb', suggestions.first + end + + def test_works_out_suggestions + exp = ['spec/models/concerns/vixen_spec.rb', + 'spec/models/concerns/vixenus_spec.rb'] + suggestions = @tsp.correct(@test_str) + assert_equal suggestions.to_set, exp.to_set + end + + def test_works_when_input_is_correct + correct_input = 'spec/models/concerns/vixenus_spec.rb' + suggestions = @tsp.correct correct_input + assert_equal suggestions.first, correct_input + end + + def test_find_out_leaves_in_a_path + path = 'spec/modals/confirms' + names = @tsp.send(:find_leaves, path) + assert_equal names.to_set, %w(abcd_spec.rb efgh_spec.rb).to_set + end + + def test_works_out_nodes + exp_paths = ['spec/models/concerns', + 'spec/models/confirms', + 'spec/modals/concerns', + 'spec/modals/confirms', + 'spec/controllers/concerns', + 'spec/controllers/confirms'].to_set + states = @tsp.send(:parse_dimensions) + nodes = states[0].product(*states[1..-1]) + paths = @tsp.send(:possible_paths, nodes) + assert_equal paths.to_set, exp_paths.to_set + end + + def test_works_out_state_space + suggestions = @tsp.send(:plausible_dimensions, @test_str) + assert_equal suggestions, [["spec"], ["models", "modals"], ["confirms", "concerns"]] + end + + def test_parses_dictionary + states = @tsp.send(:parse_dimensions) + assert_equal states, [["spec"], ["models", "modals", "controllers"], ["concerns", "confirms"]] + end + + def test_parses_elementary_dictionary + dictionary = ['spec/models/user_spec.rb', 'spec/services/account_spec.rb'] + tsp = DidYouMean::TreeSpellChecker.new(dictionary: dictionary) + states = tsp.send(:parse_dimensions) + assert_equal states, [['spec'], ['models', 'services']] + end +end diff --git a/tool/sync_default_gems.rb b/tool/sync_default_gems.rb index 810f1e9a92..1c07073e2d 100644 --- a/tool/sync_default_gems.rb +++ b/tool/sync_default_gems.rb @@ -48,6 +48,7 @@ # * https://github.com/ruby/yaml # * https://github.com/ruby/uri # * https://github.com/ruby/openssl +# * https://github.com/ruby/did_you_mean # require 'fileutils' @@ -102,6 +103,7 @@ $repositories = { yaml: "ruby/yaml", uri: "ruby/uri", openssl: "ruby/openssl", + did_you_mean: "ruby/did_you_mean" } def sync_default_gems(gem) @@ -262,6 +264,12 @@ def sync_default_gems(gem) when "readlineext" sync_lib "readline-ext" mv "lib/readline-ext.gemspec", "ext/readline" + when "did_you_mean" + rm_rf(%w[lib/did_you_mean* test/did_you_mean]) + cp_r(Dir.glob("#{upstream}/lib/did_you_mean*"), "lib") + cp_r("#{upstream}/did_you_mean.gemspec", "lib/did_you_mean") + cp_r("#{upstream}/test", "test/did_you_mean") + rm_rf(%w[test/did_you_mean/tree_spell/test_explore.rb]) else sync_lib gem end