Module: LiqrrdMetal
- Defined in:
- lib/liqrrdmetal/liqrrdmetal.rb
Overview
Derived from the LiquidMetal JavaScript library, LiqrrdMetal brings substring scoring to Ruby. Similar to Quicksilver, LiqrrdMetal gives users the ability to quickly find the most relevant items by typing in portions of the string while seeing the portions of the substring that are being matched.
To facilitate common sorting, lower scores are better; a score of 0.0 indicates a perfect match, while a score of 1.0 indicates no match.
Usage
Starting with the basics, here is how to find the score for a possible match:
score = LiqqrdMetal.score( "re", "regards.txt" ) #=> 0.082
score = LiqqrdMetal.score( "re", "preview.jpg" ) #=> 0.236
score = LiqqrdMetal.score( "re", "no" ) #=> 1.0
Want to know which letters were matched?
score,parts = LiqqrdMetal.score_with_parts( "re", "Preview.jpg" ) puts "%.02f" % score #=> 0.24
p parts #=> [#<struct LiqrrdMetal::MatchPart text="P", match=false>, #=> #<struct LiqrrdMetal::MatchPart text="re", match=true>, #=> #<struct LiqrrdMetal::MatchPart text="view.jpg", match=false>]]
puts parts.join #=> Preview.jpg
puts parts.map(&:to_html).join #=> Preview.jpg
require 'json' puts parts.to_json #=> ["t":"P","m":false,"t":"re","m":true,"t":"view.jpg","m":false]
Sort an array of possible matches by score, removing low-scoring items:
def best_matches( search, strings ) strings.map{ |s| [LiqrrdMetal.score(search,s),s] }.select{ |score,string| score < 0.3 }.sort.map{ |score,string| string } end
p best_matches( "re", various_filenames ) #=> ["resizing-text.svg", "PreviewIcon.psd" ]
Given an array of possible matches, return the matching parts sorted by score:
hits = LiqrrdMetal.parts_by_score( "re", various_filenames )
p hits.map(&:join)
#=> ["resizing-text.svg", "PreviewIcon.psd", "prime-finder.rb" ]
p hits.map{ |parts| parts.map(&:to_ascii).join }
#=> ["_re_sizing-text.svg", "P_re_viewIcon.psd", "p_r_im_e_-finder.rb" ]
You can also specify the threshold for the parts_by_score method:
good_hits = LiqrrdMetal.parts_by_score( "re", various_filenames, 0.3 )
License & Contact
LiqrrdMetal is released under the MIT License.
Copyright (c) 2011, Gavin Kistner ([email protected])
Defined Under Namespace
Modules: MatchResult Classes: MatchPart
Constant Summary collapse
- VERSION =
"0.6"- MATCH =
If you want score_with_parts to be accurate, the MATCH score must be unique
0.00- NEW_WORD =
:nodoc:
0.01- TRAILING_BUT_STARTED =
:nodoc:
[0.10]
- BUFFER =
:nodoc:
[0.15]
- TRAILING =
:nodoc:
[0.20]
- NO_MATCH =
:nodoc:
[1.00]
- RE_CACHE =
:nodoc:
{}
Class Method Summary collapse
-
.letter_scores(search, actual) ⇒ Object
Return an aray of scores for each letter in the actual text.
-
.parts_by_score(search, actuals, score_threshold = 1.0) ⇒ Object
#=> FooBar #=> Foo Bar #=> _Fo_r the L_o_ve of _B_ig C_ar_s.
-
.results_by_score(search, objects, score_threshold = 1.0) ⇒ Object
Match a single search term against an array of objects, using the supplied block to find the string to match against, receiving an array of your objects with the MatchResult module mixed in.
-
.score(search, actual) ⇒ Object
Return a score for matching the search term against the actual text.
-
.score_with_parts(search, actual) ⇒ Object
Returns an array with the score of the match, followed by an array of MatchPart instances.
-
.sorted_with_scores(search, actuals, score_threshold = 1.0) ⇒ Object
Returns an array of score/string tuples, sorted by score, below the
score_threshold.
Class Method Details
.letter_scores(search, actual) ⇒ Object
Return an aray of scores for each letter in the actual text.
Returns a single-value array of [0.0] if no match exists.
281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 |
# File 'lib/liqrrdmetal/liqrrdmetal.rb', line 281 def letter_scores( search, actual ) actual_length = actual.length scores = Array.new(actual_length) last = -1 started = false scanner = StringScanner.new actual search.chars.each do |c| return NO_MATCH unless fluff = scanner.scan_until(/#{Regexp.escape c}/i) pos = scanner.pos-1 started = true if pos == 0 if /\s/ =~ actual[pos-1] scores[pos-1] = NEW_WORD unless pos==0 scores[(last+1)..(pos-1)] = BUFFER*(fluff.length-1) elsif /[A-Z]/ =~ actual[pos] scores[(last+1)..pos] = BUFFER*fluff.length else scores[(last+1)..pos] = NO_MATCH*fluff.length end scores[pos] = MATCH last = pos end scores[ (last+1)...scores.length ] = (started ? TRAILING_BUT_STARTED : TRAILING) * (scores.length-last-1) scores end |
.parts_by_score(search, actuals, score_threshold = 1.0) ⇒ Object
#=> FooBar #=> Foo Bar #=> _Fo_r the L_o_ve of _B_ig C_ar_s
197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 |
# File 'lib/liqrrdmetal/liqrrdmetal.rb', line 197 def parts_by_score( search, actuals, score_threshold=1.0 ) re = RE_CACHE[search] ||= /#{[*search.chars].join('.*?')}/i actuals.map{ |actual| if actual=~re score,parts = score_with_parts(search,actual) if score<score_threshold [ actual, score, parts ] end end }.compact.sort_by{ |actual,score,parts| [ score, actual ] }.map{ |actual,score,parts| parts } end |
.results_by_score(search, objects, score_threshold = 1.0) ⇒ Object
Match a single search term against an array of objects, using the supplied block to find the string to match against, receiving an array of your objects with the MatchResult module mixed in.
Non-matching entries (score of 1.0) will never be included in the results, no matter the value of score_threshold
User = Struct.new :name, :email, :id users = [ User.new( "Gavin Kistner", "[email protected]", 42 ), User.new( "David Letterman", "[email protected]", 17 ), User.new( "Scott Adams", "[email protected]", 82 ) ]
scom = LiqrrdMetal.results_by_score( "s.com", users ){ |user| user.email } #=> [#<struct User name="Scott Adams", email="[email protected]", id=82>, #=> #<struct User name="David Letterman", email="[email protected]", id=17>]
p scom.map{ |user| user.liqrrd_score } #=> [0.7222222222222222, 0.7619047619047619]
p scom.map{ |user| user.liqrrd_parts.map(&:to_html).join } #=> ["scottadams@aol.com", #=> "lateshow@pipeline.com"]
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 |
# File 'lib/liqrrdmetal/liqrrdmetal.rb', line 168 def results_by_score( search, objects, score_threshold=1.0 ) re = RE_CACHE[search] ||= /#{[*search.chars].join('.*?')}/i objects.map{ |o| m = yield(o) if m=~re score,parts = score_with_parts(search,m) if score<score_threshold o.extend MatchResult o.liqrrd_match = m o.liqrrd_score, o.liqrrd_parts = score,parts o end end }.compact.sort_by{ |o| [ o.liqrrd_score, o.liqrrd_match ] } end |
.score(search, actual) ⇒ Object
Return a score for matching the search term against the actual text.
A score of 1.0 indicates no match. A score of 0.0 is a perfect match.
267 268 269 270 271 272 273 274 275 276 277 |
# File 'lib/liqrrdmetal/liqrrdmetal.rb', line 267 def score( search, actual ) re = RE_CACHE[search] ||= /#{[*search.chars].join('.*?')}/i if search.length==0 TRAILING[0] elsif (search.length > actual.length) || (search !~ re) NO_MATCH[0] else values = letter_scores( search, actual ) values.inject{ |sum,score| sum+score } / values.length end end |
.score_with_parts(search, actual) ⇒ Object
Returns an array with the score of the match, followed by an array of MatchPart instances.
score, parts = LiqrrdMetal.score_with_parts( "foov", "A Fool in Love" ) puts "%0.2f" % score #=> 0.46 p parts.map{ |p| p.match? ? "#p" : p.text }.join #=> "A _Foo_l in Lo_v_e" p parts.map(&:to_html).join #=> "A Fool in Love"
223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'lib/liqrrdmetal/liqrrdmetal.rb', line 223 def score_with_parts( search, actual ) re = RE_CACHE[search] ||= /#{[*search.chars].join('.*?')}/i if search.length==0 [ TRAILING[0], [MatchPart.new(actual)] ] elsif (search.length > actual.length) || (search !~ re) [ NO_MATCH[0], [MatchPart.new(actual)] ] else values = letter_scores( search, actual ) score = values.inject{ |sum,score| sum+score } / values.length was_matching,start = nil parts = [] values.each_with_index do |score,i| is_match = score==MATCH if is_match != was_matching parts << MatchPart.new(actual[start...i],was_matching) if start was_matching = is_match start = i end end parts << MatchPart.new(actual[start..-1],was_matching) if start [ score, parts ] end end |
.sorted_with_scores(search, actuals, score_threshold = 1.0) ⇒ Object
Returns an array of score/string tuples, sorted by score, below the score_threshold
Non-matching entries (score of 1.0) will never be included in the results, no matter the value of score_threshold
250 251 252 253 254 255 256 257 258 259 260 261 262 263 |
# File 'lib/liqrrdmetal/liqrrdmetal.rb', line 250 def sorted_with_scores( search, actuals, score_threshold=1.0 ) if search.length==0 [] else re = RE_CACHE[search] ||= /#{[*search.chars].join('.*?')}/i actuals.map{ |actual| if actual=~re values = letter_scores( search, actual ) score = values.inject{ |sum,score| sum+score } / values.length [score,actual] if score < score_threshold end }.compact.sort end end |