jamis-fuzzy_file_finder 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/LICENSE ADDED
@@ -0,0 +1,4 @@
1
+ All code, documentation, and other data distributed with this project
2
+ is released in the PUBLIC DOMAIN, by the author, Jamis Buck. Anyone,
3
+ anywhere, is allowed to use, modify, and/or redistribute any of this
4
+ without restriction.
data/Manifest ADDED
@@ -0,0 +1,4 @@
1
+ lib/fuzzy_file_finder.rb
2
+ LICENSE
3
+ Manifest
4
+ README.rdoc
data/README.rdoc ADDED
@@ -0,0 +1,36 @@
1
+ = FuzzyFileFinder
2
+
3
+ FuzzyFileFinder is a (somewhat improved) implementation of TextMate's "cmd-T" functionality. It allows you to search for a file by specifying a pattern of characters that appear in that file's name. Unlike TextMate, FuzzyFileFinder also lets you match against the file's directory, so you can more easily scope your search.
4
+
5
+ == FEATURES:
6
+
7
+ * Quickly search directory trees for files
8
+ * Avoids accidentally scanning huge directories by implementing a ceiling (default 10,000 entries)
9
+ * Simple highlighting of matches to discover how a pattern matched
10
+
11
+ == SYNOPSIS:
12
+
13
+ In a nutshell:
14
+
15
+ require 'fuzzy_file_finder'
16
+
17
+ finder = FuzzyFileFinder.new
18
+
19
+ finder.search "app/blogcon" do |match|
20
+ puts "[%5d] %s" % [match[:score] * 10000, match[:highlighted_path]]
21
+ end
22
+
23
+ matches = finder.find("app/blogcon").sort_by { |m| [-m[:score], m[:path] }
24
+ matches.each do |match|
25
+ puts "[%5d] %s" % [match[:score] * 10000, match[:highlighted_path]]
26
+ end
27
+
28
+ See FuzzyFileFinder for more documentation, and links to further information.
29
+
30
+ == INSTALL:
31
+
32
+ * gem install --source=http://gems.github.com jamis-fuzzy_file_finder
33
+
34
+ == LICENSE:
35
+
36
+ All code, documentation, and related materials in this project are released into the PUBLIC DOMAIN. Usage, modification, and distribution are allowed without restriction.
@@ -0,0 +1,33 @@
1
+
2
+ # Gem::Specification for Fuzzy_file_finder-1.0.0
3
+ # Originally generated by Echoe
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = %q{fuzzy_file_finder}
7
+ s.version = "1.0.0"
8
+
9
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
10
+ s.authors = ["Jamis Buck"]
11
+ s.date = %q{2008-10-09}
12
+ s.description = %q{an implementation of TextMate's cmd-T search functionality}
13
+ s.email = %q{jamis@jamisbuck.org}
14
+ s.extra_rdoc_files = ["lib/fuzzy_file_finder.rb", "README.rdoc"]
15
+ s.files = ["lib/fuzzy_file_finder.rb", "LICENSE", "Manifest", "README.rdoc", "fuzzy_file_finder.gemspec"]
16
+ s.has_rdoc = true
17
+ s.homepage = %q{}
18
+ s.rdoc_options = ["--line-numbers", "--inline-source", "--title", "Fuzzy_file_finder", "--main", "README.rdoc"]
19
+ s.require_paths = ["lib"]
20
+ s.rubyforge_project = %q{fuzzy_file_finder}
21
+ s.rubygems_version = %q{1.2.0}
22
+ s.summary = %q{an implementation of TextMate's cmd-T search functionality}
23
+
24
+ if s.respond_to? :specification_version then
25
+ current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION
26
+ s.specification_version = 2
27
+
28
+ if current_version >= 3 then
29
+ else
30
+ end
31
+ else
32
+ end
33
+ end
@@ -0,0 +1,312 @@
1
+ #--
2
+ # ==================================================================
3
+ # Author: Jamis Buck (jamis@jamisbuck.org)
4
+ # Date: 2008-10-09
5
+ #
6
+ # This file is in the public domain. Usage, modification, and
7
+ # redistribution of this file are unrestricted.
8
+ # ==================================================================
9
+ #++
10
+
11
+ # The "fuzzy" file finder provides a way for searching a directory
12
+ # tree with only a partial name. This is similar to the "cmd-T"
13
+ # feature in TextMate (http://macromates.com).
14
+ #
15
+ # Usage:
16
+ #
17
+ # finder = FuzzyFileFinder.new
18
+ # finder.search("app/blogcon") do |match|
19
+ # puts match[:highlighted_path]
20
+ # end
21
+ #
22
+ # In the above example, all files matching "app/blogcon" will be
23
+ # yielded to the block. The given pattern is reduced to a regular
24
+ # expression internally, so that any file that contains those
25
+ # characters in that order (even if there are other characters
26
+ # in between) will match.
27
+ #
28
+ # In other words, "app/blogcon" would match any of the following
29
+ # (parenthesized strings indicate how the match was made):
30
+ #
31
+ # * (app)/controllers/(blog)_(con)troller.rb
32
+ # * lib/c(ap)_(p)ool/(bl)ue_(o)r_(g)reen_(co)loratio(n)
33
+ # * test/(app)/(blog)_(con)troller_test.rb
34
+ #
35
+ # And so forth.
36
+ class FuzzyFileFinder
37
+ module Version
38
+ MAJOR = 1
39
+ MINOR = 0
40
+ TINY = 0
41
+ STRING = [MAJOR, MINOR, TINY].join(".")
42
+ end
43
+
44
+ # This is the exception that is raised if you try to scan a
45
+ # directory tree with too many entries. By default, a ceiling of
46
+ # 10,000 entries is enforced, but you can change that number via
47
+ # the +ceiling+ parameter to FuzzyFileFinder.new.
48
+ class TooManyEntries < RuntimeError; end
49
+
50
+ # Used internally to represent a run of characters within a
51
+ # match. This is used to build the highlighted version of
52
+ # a file name.
53
+ class CharacterRun < Struct.new(:string, :inside) #:nodoc:
54
+ def to_s
55
+ if inside
56
+ "(#{string})"
57
+ else
58
+ string
59
+ end
60
+ end
61
+ end
62
+
63
+ # Used internally to represent a file within the directory tree.
64
+ class FileSystemEntry #:nodoc:
65
+ attr_reader :name
66
+
67
+ def initialize(name)
68
+ @name = name
69
+ end
70
+
71
+ def directory?
72
+ false
73
+ end
74
+ end
75
+
76
+ # Used internally to represent a subdirectory within the directory
77
+ # tree.
78
+ class Directory < FileSystemEntry
79
+ attr_reader :children
80
+
81
+ def initialize(name)
82
+ @children = []
83
+ super
84
+ end
85
+
86
+ def directory?
87
+ true
88
+ end
89
+ end
90
+
91
+ # The root of the directory tree to search.
92
+ attr_reader :root
93
+
94
+ # The maximum number of files and directories (combined).
95
+ attr_reader :ceiling
96
+
97
+ # The number of directories beneath +root+
98
+ attr_reader :directory_count
99
+
100
+ # The number of files beneath +root+
101
+ attr_reader :file_count
102
+
103
+ # Initializes a new FuzzyFileFinder. This will scan the
104
+ # given +directory+, using +ceiling+ as the maximum number
105
+ # of entries to scan. If there are more than +ceiling+ entries
106
+ # a TooManyEntries exception will be raised.
107
+ def initialize(directory=".", ceiling=10_000)
108
+ @root = Directory.new(directory)
109
+ @ceiling = ceiling
110
+ rescan!
111
+ end
112
+
113
+ # Rescans the subtree. If the directory contents every change,
114
+ # you'll need to call this to force the finder to be aware of
115
+ # the changes.
116
+ def rescan!
117
+ root.children.clear
118
+ @file_count = 0
119
+ @directory_count = 0
120
+ follow_tree(root.name, root)
121
+ end
122
+
123
+ # Takes the given +pattern+ (which must be a string) and searches
124
+ # all files beneath +root+, yielding each match.
125
+ #
126
+ # +pattern+ is interpreted thus:
127
+ #
128
+ # * "foo" : look for any file with the characters 'f', 'o', and 'o'
129
+ # in its basename (discounting directory names). The characters
130
+ # must be in that order.
131
+ # * "foo/bar" : look for any file with the characters 'b', 'a',
132
+ # and 'r' in its basename (discounting directory names). Also,
133
+ # any successful match must also have at least one directory
134
+ # element matching the characters 'f', 'o', and 'o' (in that
135
+ # order.
136
+ # * "foo/bar/baz" : same as "foo/bar", but matching two
137
+ # directory elements in addition to a file name of "baz".
138
+ #
139
+ # Each yielded match will be a hash containing the following keys:
140
+ #
141
+ # * :path refers to the full path to the file
142
+ # * :directory refers to the directory of the file
143
+ # * :name refers to the name of the file (without directory)
144
+ # * :highlighted_directory refers to the directory of the file with
145
+ # matches highlighted in parentheses.
146
+ # * :highlighted_name refers to the name of the file with matches
147
+ # highlighted in parentheses
148
+ # * :highlighted_path refers to the full path of the file with
149
+ # matches highlighted in parentheses
150
+ # * :abbr refers to an abbreviated form of :highlighted_path, where
151
+ # path segments without matches are compressed to just their first
152
+ # character.
153
+ # * :score refers to a value between 0 and 1 indicating how closely
154
+ # the file matches the given pattern. A score of 1 means the
155
+ # pattern matches the file exactly.
156
+ def search(pattern, &block)
157
+ pattern.strip!
158
+ path_parts = pattern.split("/")
159
+ path_parts.push "" if pattern[-1,1] == "/"
160
+
161
+ file_name_part = path_parts.pop || ""
162
+
163
+ if path_parts.any?
164
+ path_regex_raw = "^(.*?)" + path_parts.map { |part| make_pattern(part) }.join("(.*?/.*?)") + "(.*?)$"
165
+ path_regex = Regexp.new(path_regex_raw, Regexp::IGNORECASE)
166
+ end
167
+
168
+ file_regex_raw = "^(.*?)" << make_pattern(file_name_part) << "(.*)$"
169
+ file_regex = Regexp.new(file_regex_raw, Regexp::IGNORECASE)
170
+
171
+ do_search(path_regex, path_parts.length, file_regex, root, &block)
172
+ end
173
+
174
+ # Takes the given +pattern+ (which must be a string, formatted as
175
+ # described in #search), and returns up to +max+ matches in an
176
+ # Array. If +max+ is nil, all matches will be returned.
177
+ def find(pattern, max=nil)
178
+ results = []
179
+ search(pattern) do |match|
180
+ results << match
181
+ break if max && results.length >= max
182
+ end
183
+ return results
184
+ end
185
+
186
+ # Displays the finder object in a sane, non-explosive manner.
187
+ def inspect #:nodoc:
188
+ "#<%s:0x%x root=%s, files=%d, directories=%d>" % [self.class.name, object_id, root.name.inspect, file_count, directory_count]
189
+ end
190
+
191
+ private
192
+
193
+ # Processes the given +path+ into the given +directory+ object,
194
+ # recursively following subdirectories in a depth-first manner.
195
+ def follow_tree(path, directory)
196
+ Dir.entries(path).each do |entry|
197
+ next if entry[0,1] == "."
198
+ raise TooManyEntries if file_count + directory_count > ceiling
199
+
200
+ full = path == "." ? entry : File.join(path, entry)
201
+ if File.directory?(full)
202
+ @directory_count += 1
203
+ subdir = Directory.new(full)
204
+ directory.children << subdir
205
+ follow_tree(full, subdir)
206
+ else
207
+ @file_count += 1
208
+ directory.children << FileSystemEntry.new(entry)
209
+ end
210
+ end
211
+ end
212
+
213
+ # Takes the given pattern string "foo" and converts it to a new
214
+ # string "(f)([^/]*?)(o)([^/]*?)(o)" that can be used to create
215
+ # a regular expression.
216
+ def make_pattern(pattern)
217
+ pattern = pattern.split(//)
218
+ pattern << "" if pattern.empty?
219
+
220
+ pattern.inject("") do |regex, character|
221
+ regex << "([^/]*?)" if regex.length > 0
222
+ regex << "(" << Regexp.escape(character) << ")"
223
+ end
224
+ end
225
+
226
+ # Given a MatchData object +match+ and a number of "inside"
227
+ # segments to support, compute both the match score and the
228
+ # highlighted match string. The "inside segments" refers to how
229
+ # many patterns were matched in this one match. For a file name,
230
+ # this will always be one. For directories, it will be one for
231
+ # each directory segment in the original pattern.
232
+ def build_match_result(match, inside_segments)
233
+ runs = []
234
+ inside_chars = total_chars = 0
235
+ match.captures.each_with_index do |capture, index|
236
+ if capture.length > 0
237
+ # odd-numbered captures are matches inside the pattern.
238
+ # even-numbered captures are matches between the pattern's elements.
239
+ inside = index % 2 != 0
240
+
241
+ total_chars += capture.gsub(%r(/), "").length # ignore '/' delimiters
242
+ inside_chars += capture.length if inside
243
+
244
+ if runs.last && runs.last.inside == inside
245
+ runs.last.string << capture
246
+ else
247
+ runs << CharacterRun.new(capture, inside)
248
+ end
249
+ end
250
+ end
251
+
252
+ # Determine the score of this match.
253
+ # 1. fewer "inside runs" (runs corresponding to the original pattern)
254
+ # is better.
255
+ # 2. better coverage of the actual path name is better
256
+
257
+ inside_runs = runs.select { |r| r.inside }
258
+ run_ratio = inside_runs.length.zero? ? 1 : inside_segments / inside_runs.length.to_f
259
+
260
+ char_ratio = total_chars.zero? ? 1 : inside_chars.to_f / total_chars
261
+
262
+ score = run_ratio * char_ratio
263
+
264
+ return { :score => score, :result => runs.join }
265
+ end
266
+
267
+ # Do the actual search, recursively. +path_regex+ is either nil,
268
+ # or a regular expression to match against directory names. The
269
+ # +path_segments+ parameter is an integer indicating how many
270
+ # directory segments there were in the original pattern. The
271
+ # +file_regex+ is a regular expression to match against the file
272
+ # name, +under+ is a Directory object to search. Matches are
273
+ # yielded.
274
+ def do_search(path_regex, path_segments, file_regex, under, &block)
275
+ # If a path_regex is present, match the current directory against
276
+ # it and, if there is a match, compute the score and highlighted
277
+ # result.
278
+ path_match = path_regex && under.name.match(path_regex)
279
+
280
+ if path_match
281
+ path_match_result = build_match_result(path_match, path_segments)
282
+ path_match_score = path_match_result[:score]
283
+ path_match_result = path_match_result[:result]
284
+ else
285
+ path_match_score = 1
286
+ end
287
+
288
+ # For each child of the directory, search under subdirectories, or
289
+ # match files.
290
+ under.children.each do |entry|
291
+ full = under == root ? entry.name : File.join(under.name, entry.name)
292
+ if entry.directory?
293
+ do_search(path_regex, path_segments, file_regex, entry, &block)
294
+ elsif (path_regex.nil? || path_match) && file_match = entry.name.match(file_regex)
295
+ match_result = build_match_result(file_match, 1)
296
+ highlighted_directory = path_match_result || under.name
297
+ full_match_result = File.join(highlighted_directory, match_result[:result])
298
+ abbr = File.join(highlighted_directory.gsub(/[^\/]+/) { |m| m.index("(") ? m : m[0,1] }, match_result[:result])
299
+
300
+ result = { :path => full,
301
+ :abbr => abbr,
302
+ :directory => under.name,
303
+ :name => entry.name,
304
+ :highlighted_directory => highlighted_directory,
305
+ :highlighted_name => match_result[:result],
306
+ :highlighted_path => full_match_result,
307
+ :score => path_match_score * match_result[:score] }
308
+ yield result
309
+ end
310
+ end
311
+ end
312
+ end
metadata ADDED
@@ -0,0 +1,63 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jamis-fuzzy_file_finder
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Jamis Buck
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2008-10-09 00:00:00 -07:00
13
+ default_executable:
14
+ dependencies: []
15
+
16
+ description: an implementation of TextMate's cmd-T search functionality
17
+ email: jamis@jamisbuck.org
18
+ executables: []
19
+
20
+ extensions: []
21
+
22
+ extra_rdoc_files:
23
+ - lib/fuzzy_file_finder.rb
24
+ - README.rdoc
25
+ files:
26
+ - lib/fuzzy_file_finder.rb
27
+ - LICENSE
28
+ - Manifest
29
+ - README.rdoc
30
+ - fuzzy_file_finder.gemspec
31
+ has_rdoc: true
32
+ homepage: ""
33
+ post_install_message:
34
+ rdoc_options:
35
+ - --line-numbers
36
+ - --inline-source
37
+ - --title
38
+ - Fuzzy_file_finder
39
+ - --main
40
+ - README.rdoc
41
+ require_paths:
42
+ - lib
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: "0"
48
+ version:
49
+ required_rubygems_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: "0"
54
+ version:
55
+ requirements: []
56
+
57
+ rubyforge_project: fuzzy_file_finder
58
+ rubygems_version: 1.2.0
59
+ signing_key:
60
+ specification_version: 2
61
+ summary: an implementation of TextMate's cmd-T search functionality
62
+ test_files: []
63
+