Skip to content

Commit f7f5103

Browse files
tobiclaude
andcommitted
perf: optimize fuzzy matching with String#index and lookup table
- Use String#index for character search instead of manual iteration - Pre-compute sqrt values in SQRT_TABLE for proximity bonus - Add TryEntry Data wrapper to avoid Hash#merge per result - Reduces search time by ~50% for 2000 directories Benchmarks (2000 directories): - load_all_tries: ~27ms (filesystem I/O) - get_tries (empty): ~22ms - get_tries (query): ~110ms (was ~228ms) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent d78642d commit f7f5103

2 files changed

Lines changed: 45 additions & 30 deletions

File tree

lib/fuzzy.rb

Lines changed: 25 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -79,52 +79,48 @@ def each(&block)
7979

8080
private
8181

82+
# Pre-computed sqrt values for proximity bonus (gap 0-15)
83+
SQRT_TABLE = (0..16).map { |n| 2.0 / Math.sqrt(n + 1) }.freeze
84+
8285
def calculate_match(entry)
8386
positions = []
8487
score = entry.base_score
8588

8689
# Empty query = match all with base score only
87-
if @query.empty?
88-
return [score, positions]
89-
end
90+
return [score, positions] if @query.empty?
9091

91-
text_lower = entry.text_lower
92-
text_len = text_lower.length
93-
query_len = @query_chars.length
92+
text = entry.text_lower
93+
query_chars = @query_chars
94+
query_len = query_chars.length
9495

9596
last_pos = -1
96-
query_idx = 0
97+
pos = 0
9798

98-
i = 0
99-
while i < text_len
100-
break if query_idx >= query_len
99+
query_chars.each do |qc|
100+
# Find next occurrence of query char starting from pos
101+
found = text.index(qc, pos)
102+
return nil unless found # No match
101103

102-
if text_lower[i] == @query_chars[query_idx]
103-
positions << i
104-
105-
# Base match point
106-
score += 1.0
104+
positions << found
107105

108-
# Word boundary bonus (start of string or after non-alphanumeric)
109-
is_boundary = (i == 0) || text_lower[i - 1].match?(/[^a-z0-9]/)
110-
score += 1.0 if is_boundary
106+
# Base match point
107+
score += 1.0
111108

112-
# Proximity bonus (consecutive chars score higher)
113-
if last_pos >= 0
114-
gap = i - last_pos - 1
115-
score += 2.0 / Math.sqrt(gap + 1)
116-
end
109+
# Word boundary bonus (start of string or after non-alphanumeric)
110+
if found == 0 || text[found - 1].match?(/[^a-z0-9]/)
111+
score += 1.0
112+
end
117113

118-
last_pos = i
119-
query_idx += 1
114+
# Proximity bonus (consecutive chars score higher)
115+
if last_pos >= 0
116+
gap = found - last_pos - 1
117+
score += gap < 16 ? SQRT_TABLE[gap] : (2.0 / Math.sqrt(gap + 1))
120118
end
121119

122-
i += 1
120+
last_pos = found
121+
pos = found + 1
123122
end
124123

125-
# Not all query chars matched = no match
126-
return nil if query_idx < query_len
127-
128124
# Density bonus: prefer shorter spans
129125
score *= (query_len.to_f / (last_pos + 1)) if last_pos >= 0
130126

try.rb

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,32 @@ def load_all_tries
125125
end
126126
end
127127

128+
# Result wrapper to avoid Hash#merge allocation per entry
129+
TryEntry = Data.define(:data, :score, :highlight_positions) do
130+
def [](key)
131+
case key
132+
when :score then score
133+
when :highlight_positions then highlight_positions
134+
else data[key]
135+
end
136+
end
137+
138+
def method_missing(name, *)
139+
data[name]
140+
end
141+
142+
def respond_to_missing?(name, include_private = false)
143+
data.key?(name) || super
144+
end
145+
end
146+
128147
def get_tries
129148
load_all_tries
130149
@fuzzy ||= Fuzzy.new(@all_tries)
131150

132151
results = []
133152
@fuzzy.match(@input_buffer).each do |entry, positions, score|
134-
results << entry.merge(score: score, highlight_positions: positions)
153+
results << TryEntry.new(entry, score, positions)
135154
end
136155
results
137156
end

0 commit comments

Comments
 (0)