Moving a decision into code that gets executed earlier, with later code merely telling an object to "do whatever it is we decided earlier" is one of the techniques of confident programming. It's often a good idea, and I think it is well applied here.
Is there a better name for the filter option?
There may be a more descriptive name for the option than "filter." The trouble is that "filter" might be interpreted as "here are the things I want", instead of "here are the things I don't want."
A less ambiguous name for this option might be "except," "reject", or "exclude". You get the idea.
Similarly, you might consider renaming the instance method @filter.
Set proc in constructor
Uri is correct that it is better, if you are going to do this, to create the proc just once. He does so using lazy evaluation. You might also consider just setting it in #initialize:
def initialize(string, options = {})
@filter = filter_proc(options[:filter])
...
end
# Your filter method, renamed, and made to take an argument
def exclude_proc(filter)
if filter.is_a?(String)
...
end
Use type coercion methods
Rather than testing the option's class, consider testing whether the object responds to a coercion method that converts it to that class. So, instead of:
if filter.is_a?(String)
# the code that converts a string to a proc
you would do this:
if filter.respond_to?(:to_str)
filter = filter.to_str
# the code that converts a string to a proc
String responds to #to_str, so filters of those types will continue to work fine. But now the caller can pass in, as well as a String, any object that responds to #to_str.
By the way: We use #to_str instead of #to_s because #to_s will convert pretty much anything to a string. #to_str only converts to a string objects which wish to be treated like strings.
You can use the same idea for regular expressions, but you cannot do it the same way. The convention is that an object that responds to #to_regexp can be treated as a Regexp, but for some strange reason Regexp does not respond to #to_regexp. Instead, you have to use Regexp.try_convert:
elsif Regexp.try_convert(filters)
filters = Regexp.try_convert(filters)
Proc.new { |w| w =~ filters }
If given a Regexp, .try_convert will return it. If given an object that responds to #to_regexp, .try_convert will return the result of that method. Otherwise, it returns nil.
Consider allowing procs
It's trivial to allow the caller to pass in a callable object, such as a method:
counter = Counter.new("That was magnificent, Trevor.",
filter: method(:exclude_word?))
or a lambda:
counter = Counter.new("That was magnificent, Trevor.",
filter: ->(word) { foo(word) || bar(word) })
In the code that makes the proc:
...
elsif filter.respond_to?(:to_proc)
filter.to_proc
...
By allowing this, you give the caller the ability to make decisions that don't fit comfortably in a list of words or a regular expression.
Consider allowing arrays
Once you've made method filter take an argument, you can use a bit of recursion to allow it to take arrays of filters:
def filter_proc(filter)
if filter.respond_to?(:to_a)
filter_procs = Array(filter).map(&method(:filter_proc))
Proc.new do |w|
filter_procs.any? { |p| p.call(w) }
end
...
end
Interestingly, nil responds to #to_a with an empty array. That works out fine, because the the result of #any? on an empty array is false.
The caller may now pass in an array of words:
Counter.new("That was magnificent, Trevor.",
filter: ["That", "was"])
An array of regular expressions sill also work:
Counter.new("That was magnificent, Trevor.",
filter: [/That/, /was/])
Also, since Enumerable responds to #to_a, the possibilities are endless. This makes the API flexible enough that the caller can do anything needed, but still rational enough to understand and reason about. Best of all, it's done pretty simply.
Catch incorrect filter types
If the caller passes in a filter type that the code does not understand, it acts as though there is no filter. Consider raising an exception instead:
def filter_proc(filter)
if ...
...
else
raise ArgumentError, "Incorrect filter type"
end
end
Use require_relative for spec_helper
In the spec, consider using require_relative to load the spec_helper:
require_relative '../spec_helper'
This will allow the spec to be run no matter what the current directory is.
Consider using lambdas instead of procs
Everything you are doing here with procs can be done with lambdas, and more concisely. For example, this:
Proc.new { |w| w =~ filter }
could be expressed as:
lambda { |w| w =~ filter }
or, using the "stabby" operator:
->(w) { w =~ filter }