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

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