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
#=> P<span class='match'>re</span>view.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 © 2011, Gavin Kistner ([email protected])

Defined Under Namespace

Classes: MatchPart

Constant Summary collapse

VERSION =
0.5
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]

Class Method Summary collapse

Class Method Details

.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


145
146
147
148
149
150
151
152
153
154
155
# File 'lib/liqrrdmetal/liqrrdmetal.rb', line 145

def parts_by_score( search, actuals, score_threshold=1.0 )
	actuals.map{ |actual|
		[ actual, *score_with_parts(search,actual) ]
	}.select{ |actual,score,parts|
		score < score_threshold
	}.sort_by{ |actual,score,parts|
		[ score, actual ]
	}.map{ |actual,score,parts|
		parts
	}
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.



192
193
194
195
196
197
198
199
200
201
# File 'lib/liqrrdmetal/liqrrdmetal.rb', line 192

def score( search, actual )
	if search.length==0
		TRAILING[0]
	elsif search.length > actual.length
		NO_MATCH[0]
	else
		values = 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 <span class='match'>Foo</span>l in Lo<span class='match'>v</span>e"


167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/liqrrdmetal/liqrrdmetal.rb', line 167

def score_with_parts( search, actual )
	if search.length==0
		[ TRAILING[0], [MatchPart.new(actual)] ]
	elsif search.length > actual.length
		[ NO_MATCH[0], [MatchPart.new(actual)] ]
	else
		values = 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

.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.



205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'lib/liqrrdmetal/liqrrdmetal.rb', line 205

def 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(/#{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