├── .gitignore
├── test
├── pets
│ ├── cats
│ │ ├── about
│ │ └── cats
│ └── dogs
│ │ └── about
└── test_infer.rb
├── Readme.md
└── infer.rb
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 |
--------------------------------------------------------------------------------
/test/pets/cats/about:
--------------------------------------------------------------------------------
1 | Cats meow and poop
2 |
--------------------------------------------------------------------------------
/test/pets/dogs/about:
--------------------------------------------------------------------------------
1 | Dogs bark and poop
2 |
--------------------------------------------------------------------------------
/test/pets/cats/cats:
--------------------------------------------------------------------------------
1 | This should rank above its parent directory
2 |
--------------------------------------------------------------------------------
/test/test_infer.rb:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | require 'test/unit'
4 | require_relative '../infer'
5 |
6 | def capture_stdout(&block)
7 | original_stdout = $stdout
8 | $stdout = StringIO.new
9 | begin
10 | yield
11 | ensure
12 | $stdout = original_stdout
13 | end
14 | end
15 |
16 | class TestInfer < Test::Unit::TestCase
17 | def test_basic_path_ranking
18 | i = Infer.new('test')
19 | assert_equal i.rank_file('this/is/a/test').rank, 'test'.length.to_f/'this/is/a/test'.length
20 | end
21 |
22 | def test_directory_vs_file_rank
23 | i = Infer.new('~/cats -p')
24 | capture_stdout { i.run }
25 | assert_equal i.results[0].path, 'cats/cats'
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/Readme.md:
--------------------------------------------------------------------------------
1 | Infer
2 | ====================
3 | Infer (i) is a command line utility for rapidly opening files based on keyword ranks. If the closest match is higher than a degree of certainty (default 10%) the file will be opened in your configured editor (default vim).
4 |
5 | ```
6 | $ i -h
7 | Usage: i [options] keywords...
8 |
9 | Options:
10 | -m, --max [num] Limit number of results
11 | -t, --technique [mdfind|grep] Search technique to use
12 | -s, --[no-]showonly Show results and never open the inference
13 | -a, --all Do not truncate the results
14 | --[no-]prompt Prompt for result selection
15 | -p, --plain Plain filename output; no indices, ranks, prompting, or unnecessary info.
16 | -z, --null Separate results with a null character instead of newline.
17 | -g, --global Global filesystem search
18 | -c, --command [COMMAND] Execute command on inference
19 | -v, --[no-]verbose Verbose output
20 | -[0-9], --index Force open result n
21 | -h, --help Show this message
22 | ```
23 |
24 | Installation
25 | ====================
26 | ```
27 | curl -L http://github.com/em/infer/raw/master/infer.rb -o /usr/local/bin/i && chmod +x !#:4
28 | ```
29 |
30 | Example
31 | ====================
32 |
33 | Infer with a search for "http" run against the node.js source code:
34 |
35 | i http -sa
36 | 0. ██████████▏lib/http.js
37 | 1. █████████▎ lib/https.js
38 | 2. ███████▍ deps/http_parser/http_parser.c
39 | 3. ██████▌ deps/http_parser/
40 | 4. █████▎ doc/api/api/http.html
41 | 5. █████▏ doc/api/api/https.html
42 | 6. ████▉ deps/http_parser/test.c
43 | 7. ████▋ benchmark/http_simple.js
44 | 8. ████▌ deps/http_parser/Makefile
45 | 9. ████▎ deps/http_parser/README.md
46 | 10. ████ deps/http_parser/LICENSE-MIT
47 | 11. ███▉ test/simple/test-http-wget.js
48 | 12. ███▊ benchmark/http_simple_bench.sh
49 | 13. ███▋ benchmark/static_http_server.js
50 | 14. ███▌ test/simple/test-http-chunked.js
51 | 15. ███▍ test/disabled/test-http-agent2.js
52 | 16. ███▎ test/simple/test-http-exceptions.js
53 | 17. ███▏ test/simple/test-http-client-race.js
54 | 18. ███ test/simple/test-http-abort-client.js
55 | 19. ███ test/simple/test-http-buffer-sanity.js
56 | 20. ██▉ test/disabled/test-http-head-request.js
57 | 21. ██▉ test/pummel/test-https-large-response.js
58 | 22. ██▊ test/simple/test-http-default-encoding.js
59 | 23. ██▋ test/disabled/test-https-loop-to-google.js
60 | 24. ██▋ test/simple/test-http-client-parse-error.js
61 | 25. ██▋ test/simple/test-http-server-multiheaders.js
62 | 26. ██▌ test/pummel/test-http-client-reconnect-bug.js
63 | 27. ██▌ test/disabled/test-http-big-proxy-responses.js
64 | 28. ██▍ test/simple/test-http-allow-req-after-204-res.js
65 | 29. ██▎ test/simple/test-http-head-response-has-no-body.js
66 | 30. ██▎ test/simple/test-http-keep-alive-close-on-header.js
67 | 31. ██▏ test/simple/test-http-many-keep-alive-connections.js
68 |
69 |
70 | The options -sa are "show only" and "all results".
71 | You'll notice in these results lib/http.js is significantly higher than the proceeding pathname.
72 | Enough that simply running i http
would immediately open it your configured editor.
73 |
74 | Now, if I wanted `deps/http_parser/http_parser.c` all I have to do is make it less ambiguous:
75 | ```
76 | i http .c
77 | ```
78 | This is enough to boost http_parser.c past the other results and open it immediately.
79 |
80 | Configuration
81 | ====================
82 | Example ~/.infrc
83 |
84 | inference_index: 0.1 # Open if first result is 10% better than the next
85 |
86 | # Regexes used to classify files by name
87 | matchers:
88 | scalar_graphics: "\\.(psg|png|jpeg|jpg|gif|tiff)$"
89 | vector_graphics: "\\.(ai|eps)$"
90 |
91 | # Commands that get executed on an inference, $ holds the full file name
92 | handlers:
93 | scalar_graphics: "open -a \"Adobe Photoshop CS5\" $"
94 | vector_graphics: "open -a \"Adobe Illustrator CS5\" $"
95 | default: "vim $" # Catch-all if nothing else is matched (make this your most general-purpose editor, e.g. mate)
96 |
97 |
98 | # Pro tip
99 | ```
100 | I keywords
101 | ^ capitalize to specify a command explicitly
102 |
103 | I use this all the time to open html instead of edit it:
104 | I open index.html
105 | ```
106 |
--------------------------------------------------------------------------------
/infer.rb:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | require 'find'
4 | require 'yaml'
5 | require 'optparse'
6 | require 'optparse/time'
7 | require 'ostruct'
8 | require 'pp'
9 |
10 | class String
11 | def nibble(fixnum=1)
12 | range_end = self.length - 1
13 | slice(fixnum..range_end)
14 | end
15 | end
16 |
17 | class Infer
18 | attr :results, :options
19 |
20 | class Keyword
21 | attr :term, :case_sensitive
22 |
23 | def initialize(term,case_sensitive=false)
24 | @term = term
25 | @case_sensitive = case_sensitive
26 | end
27 |
28 | # Generate apple query expression
29 | def qe_modifiers
30 | end
31 | end
32 |
33 | class Result
34 | include Comparable
35 | attr_accessor :path, :rank
36 |
37 | def <=>(other)
38 | r = other.rank <=> rank
39 | if r == 0
40 | return other.path <=> path
41 | end
42 | r
43 | end
44 |
45 | def initialize(path, rank)
46 | @path = path
47 | @rank = rank
48 | end
49 | end
50 |
51 | def initialize(arguments)
52 | arguments = arguments.split(' ') if arguments.is_a? String
53 | @arguments = arguments
54 | @results = []
55 |
56 | @options = {
57 | inference_index: 0.01, # 10%
58 | max_results: 40,
59 | unlimited_results: false,
60 | technique: 'exhaustive',
61 | include_dirs: false,
62 | display_info: true,
63 | display_ranks: true,
64 | display_indices: true,
65 | prompt: true,
66 | ignore: "(^\\.|log/)",
67 |
68 | matchers: {
69 | graphics: "\\.(png|jpeg|jpg|gif|tiff|psd)$",
70 | },
71 |
72 | handlers: {
73 | default: "vim $",
74 | graphics: "open $",
75 | },
76 | }
77 |
78 | @content_keywords = []
79 | @path_keywords = []
80 |
81 |
82 | load_options('~/.infrc') # load from home dir
83 | load_options('./.infrc') # load from current dir
84 |
85 | parse_args
86 | end
87 |
88 | def term_lines
89 | `tput lines`.to_i
90 | end
91 |
92 | def term_cols
93 | `tput lines`.to_i
94 | end
95 |
96 | def transform_keys_to_symbols(value)
97 | return value if not value.is_a?(Hash)
98 | hash = value.inject({}){|memo,(k,v)| memo[k.to_sym] = transform_keys_to_symbols(v); memo}
99 | return hash
100 | end
101 |
102 | def load_options(path)
103 | path = File.expand_path(path)
104 | yml_options = YAML::load_file(path) rescue return
105 | yml_options = transform_keys_to_symbols(yml_options)
106 | # yml_options = yml_options.inject({}){|memo,(k,v)| memo[k.to_sym] = v; memo}
107 | @options.merge! yml_options
108 | end
109 |
110 | def invalid_arg(name)
111 | print "Options '#{name}' requires an argument.\n"
112 | exit
113 | end
114 |
115 | def search search_dir
116 |
117 | print "Searching #{search_dir}\n\n" if @options[:verbose]
118 |
119 | case @options[:technique]
120 | when 'grep'
121 | grep_search search_dir
122 | when 'mdfind'
123 | begin
124 | mdfind_search search_dir
125 | rescue Errno::ENOENT
126 | exhaustive_search search_dir
127 | end
128 | else
129 | exhaustive_search search_dir
130 | end
131 | end
132 |
133 | # Grep for content search
134 | def grep_search search_dir
135 | args = ['-r']
136 | end
137 |
138 | def cleanup_path path
139 | path.strip!
140 | end
141 |
142 | def pipe_lines args
143 | IO.popen args do |out|
144 | while path = out.readline rescue nil do
145 | yield path
146 | end
147 | end
148 | end
149 |
150 | def process_path path
151 | rank = rank_path(path)
152 | result = Result.new(path,rank)
153 | # return false if @results.include? result
154 | @results << result unless rank < 0
155 | true
156 | end
157 |
158 | def rerank_results
159 | c_kw = @path_keywords + @filter.split(' ')
160 |
161 | @results.each { |r|
162 | r.rank = rank_path r.path, c_kw
163 | }
164 |
165 | @results.sort!
166 | end
167 |
168 | def mdfind_search search_dir
169 | results = []
170 |
171 | @base_path = File.expand_path(search_dir) + '/'
172 |
173 | def abs_to_rel! path
174 | path.strip!
175 | path.slice! @base_path
176 | end
177 |
178 | unless @path_keywords.empty?
179 | # First we get all files that match any of the filenames (this includes directories)
180 | # We have to do this so we can recurse the directories, for the desired behavior
181 | # of searching on path, instead of just filename as mdfind does
182 |
183 | # Note: DisplayName appears to be cached better than FSName, and is the same for files
184 | query = "(%s)" % @path_keywords.collect{|k| "kMDItemDisplayName = '*%s*'" % k}.join(' || ')
185 |
186 | # pp query
187 | # pp search_dir
188 |
189 | pipe_lines ['mdfind', '-onlyin', search_dir, query] do |path|
190 | abs_to_rel! path
191 | # puts path
192 | r = rank_path path
193 | results << r unless r.nil?
194 |
195 | if File.directory? path
196 | exhaustive_search(path)
197 | end
198 |
199 | end
200 | end
201 |
202 | # Now we filter the result by any content matches
203 | c_results = []
204 | if @content_keywords.any?
205 | query = "(%s)" % @content_keywords.collect{|k| "kMDItemTextContent = '%s'cdw" % k }.join(' && ')
206 |
207 | # pp query
208 | # pp search_dir
209 |
210 | pipe_lines ['mdfind', '-onlyin', search_dir, query] do |path|
211 | abs_to_rel! path
212 | c_results << path
213 | end
214 |
215 | # pp results
216 | # pp c_results
217 |
218 | # We take all content results if no path criteria,
219 | # as if the default was all inclusive
220 | if @path_keywords.any?
221 | results = results.keep_if do |r|
222 | c_results.include? r[0]
223 | end
224 | else
225 | results = c_results.map {|r| Result.new(r,1) }
226 | end
227 | end
228 |
229 | results
230 | end
231 |
232 |
233 | def exhaustive_search search_dir
234 | Find.find(search_dir) do |path|
235 | path.slice! './'
236 |
237 | if @options[:ignore] && File.basename(path).match(@options[:ignore])
238 | Find.prune if File.directory?(path) # Don't recurse this dir
239 | next # Don't save result
240 | end
241 |
242 | path += '/' if File.directory?(path)
243 | # rank = rank_path(path)
244 | process_path path
245 | end
246 |
247 | end
248 |
249 | def exec_result(result)
250 | command = nil
251 |
252 | if @options[:command]
253 | command = @options[:command]
254 | command += ' $' unless command.match /\$/
255 | else
256 | @options[:matchers].each do |type, pattern|
257 | command = @options[:handlers][type] if result.path.match(pattern) && @options[:handlers][type]
258 | end
259 |
260 | command ||= @options[:handlers][:default]
261 | end
262 |
263 | command = command.gsub /\$/, '"%s"' % result.path.gsub('"','\"')
264 | puts command
265 | exec command
266 | end
267 |
268 | def rank_path(path, p_kw=@path_keywords)
269 |
270 | unless @case_sensitive
271 | path = path.downcase
272 | p_kw = p_kw.map {|kw| kw.downcase }
273 | end
274 |
275 | if p_kw.empty? && !path.empty?
276 | return 1
277 | end
278 |
279 |
280 | chars_matched = 0
281 | content_matched = 0
282 |
283 |
284 | if !@options[:include_dirs] && File.directory?(path)
285 | return -1
286 | end
287 |
288 | p_kw.each do |condition|
289 | return -1 unless path.include? condition
290 | chars_matched += condition.length * path.scan(condition).length
291 | end
292 |
293 | c_kw = @content_keywords
294 |
295 | if c_kw.any? && File.exists?(path) && !File.directory?(path)
296 | File.open(path, "r") do |infile|
297 | while (line = infile.gets)
298 | c_kw.each do |kw|
299 | if line.include? kw
300 | content_matched += kw.length * line.scan(kw).length
301 | end
302 | end
303 | end
304 | end
305 | end
306 |
307 | # pp content_matched
308 | return -1 if chars_matched == 0 && content_matched == 0
309 |
310 | # puts
311 | # puts chars_matched.to_f / path.length
312 | chars_matched.to_f / path.length
313 | end
314 |
315 | def parse_args
316 |
317 |
318 | opts = OptionParser.new do |opts|
319 | opts.banner = "Usage: i [options] keywords..."
320 |
321 | opts.separator ""
322 | opts.separator "Options:"
323 |
324 | opts.on("-m", "--max [num]", Integer, "Limit number of results") do |v|
325 | @options[:max_results] = v
326 | end
327 |
328 | opts.on("-t", "--technique [mdfind|grep]", "Search technique to use") do |v|
329 | @options[:technique] = v ? 'mdfind' : ''
330 | end
331 |
332 | opts.on("-s", "--[no-]showonly", "Show results and never open the inference") do |v|
333 | @options[:show_only] = v
334 | end
335 |
336 | opts.on("-a", "--all", "Do not truncate the results") do |v|
337 | @options[:max_results] = nil
338 | end
339 |
340 | opts.on("--[no-]prompt", "Prompt for result selection") do |v|
341 | @options[:prompt] = v
342 | end
343 |
344 | opts.on("-p", "--plain", "Plain filename output; no indices, ranks, prompting, or unnecessary info.") do |v|
345 | @options[:display_info] = false
346 | @options[:display_ranks] = false
347 | @options[:display_indices] = false
348 | @options[:prompt] = false
349 | end
350 |
351 | opts.on("-z", "--null", "Separate results with a null character instead of newline.") do |v|
352 | @options[:terminator] = "\x00"
353 | end
354 |
355 | opts.on("-g", "--global", "Global filesystem search") do |v|
356 | @options[:global_search] = v
357 | end
358 |
359 | opts.on("-c [COMMAND]", "--command", "Execute command on inference") do |v|
360 | @options[:command] = v
361 | end
362 |
363 | opts.on("-v", "--[no-]verbose", "Verbose output") do |v|
364 | @options[:verbose] = v
365 | end
366 |
367 | opts.on("-[0-9]", "--index", Integer, "Force open result n") do |v|
368 | @override_index = v
369 | end
370 |
371 | # no argument, shows at tail. this will print an options summary.
372 | # try it and see!
373 | opts.on_tail("-h", "--help", "Show this message") do
374 | puts opts
375 | exit
376 | end
377 | end
378 |
379 | @options[:command] = @arguments.shift if File.basename($0) == 'I'
380 |
381 | opts.parse!(@arguments)
382 |
383 |
384 | # inside search special notation
385 | # parse search keywords
386 |
387 | @arguments.each_with_index do |a, i|
388 | case a
389 | when /^\/.+/
390 | @content_keywords << a.nibble
391 | else
392 | @path_keywords << a
393 | end
394 |
395 | # @arguments.delete_at(i)
396 | end
397 |
398 | end
399 |
400 | def read_char
401 | system "stty raw -echo"
402 | STDIN.getc
403 | ensure
404 | system "stty -raw echo"
405 | end
406 |
407 | BACKSPACE = "\u007F"
408 |
409 | def filtering_loop
410 | @selection ||= 0
411 |
412 | @filter = ''
413 |
414 | begin
415 | c = read_char
416 | # pp c
417 | # return
418 |
419 | case c
420 | when "\u0003" # ^C
421 | puts 'quit'
422 | return
423 |
424 | when BACKSPACE
425 | next if @filter.length == 0
426 | @filter.chop!
427 | print "\33[1D\33[K"
428 |
429 | rerank_results
430 | when "\33"
431 | # pp STDIN.getc
432 | #
433 | # exit unless STDIN.stat.size > 0
434 |
435 | if STDIN.getc == '['
436 | d = STDIN.getc
437 | if d == 'A'
438 | @selection = [0, @selection-1].max
439 | print "\33[1D\33[K"
440 | end
441 | if d == 'B' && @selection+1 < @display_count
442 | @selection += 1
443 | print "\33[1D\33[K"
444 | end
445 | end
446 |
447 | when "\r"
448 | exec_result @results[@selection]
449 |
450 | when /[0-9]/
451 | @selection = Integer(c)
452 |
453 | when "\n"
454 | exit
455 | else
456 | @filter += c
457 | print c
458 | rerank_results
459 | # print "\\33[1C"
460 | end
461 |
462 | print_results(@filter)
463 |
464 | end until c == "\n" || c == "\r"
465 |
466 | exit
467 |
468 | end
469 |
470 |
471 | def print_results(filter=nil)
472 |
473 | width = `tput cols`.to_i
474 | height = `tput lines`.to_i
475 |
476 | flen = filter ? filter.length : 0
477 |
478 | results = @results
479 |
480 | if filter
481 | print "\33[1B\33[%dD" % (19 + flen)
482 |
483 | c_kw = @path_keywords + filter.split(' ')
484 | else
485 | print "Filter: \n"
486 | # print "\\33[7mFilter 100 files: \\n\\n"
487 | end
488 |
489 |
490 | # Erase to end of screen
491 | print "\33[J\n"
492 |
493 |
494 | if @options[:max_results]
495 | @display_count = [@options[:max_results], results.length, height-5].min
496 | else
497 | @display_count = results.length
498 | end
499 |
500 | selection = @selection || 0
501 | outputted = 0
502 | first_result = nil
503 | results.each_with_index do |result, i|
504 |
505 | # next unless !filter || result.path.include?(filter)
506 |
507 | next if result.rank < 0
508 |
509 | break if outputted >= @display_count
510 |
511 | selected = (selection == outputted)
512 |
513 | first_result ||= result
514 |
515 | outputted += 1
516 |
517 | if !selected
518 | # print "\\33[37m"
519 | end
520 |
521 | if @options[:display_indices]
522 | print selected ? "\u25B6" : ' '
523 | print " #{i} ".rjust((@display_count-1).to_s.length+2)
524 | end
525 |
526 | if @options[:display_ranks]
527 | rank_ratio = result.rank / first_result.rank * 5
528 | rank_remainder = rank_ratio - Integer(rank_ratio)
529 | partial_blocks = ["\u258F","\u258E","\u258D","\u258C","\u258B","\u258A","\u2589","\u2588"]
530 | remainder_block = partial_blocks[rank_remainder * partial_blocks.length]
531 |
532 | print ("\u2588"*(rank_ratio) + remainder_block).ljust(6)
533 | end
534 |
535 | line = "#{result.path}"
536 |
537 | # This is the best way to do a normal slice(-n) apparently
538 | # Ruby is fucking stupid.
539 | print line.split(//).last(width-12).join('')
540 |
541 | if selected
542 | # print " <- launch with "
543 | else
544 | print "\33[0m"
545 | end
546 |
547 | print "\n"
548 |
549 | end
550 |
551 |
552 | # print "\\33[J"
553 |
554 | # count = results.reduce(0) {|r,c| c += 1; break if r.rank.nil? }
555 | count = results.find_index {|r| r.rank.nil?} || results.length
556 |
557 | if count > @display_count
558 | puts "\n%d more hidden.\n" % (count - @display_count)
559 | outputted += 2
560 | end
561 |
562 |
563 | # Move cursor up to filter
564 | print "\33[%dA\33[%dC" % [outputted+2,8 + flen]
565 |
566 |
567 |
568 |
569 |
570 | if @options[:prompt]
571 | # print "\\nPick one of the results to launch (0-%d): " % (display_count-1)
572 |
573 | # puts "\\33[%dA" % 5
574 | # puts "\\33[J"
575 | #
576 |
577 | # filtering_loop
578 |
579 | # sel = Integer(read_char) rescue nil
580 | # exec_result @results[sel] unless sel.nil?
581 | end
582 |
583 | end
584 |
585 | def run
586 | # use first option as search directory if it is a dir and outside of the cwd
587 | # search_dir = (@arguments[0] if @arguments[0] && @arguments[0].match(/^[\\~\\.\\/]/) && File.directory?(@arguments[0])) || './'
588 | search_dir = './'
589 |
590 | search_dir = '/' if @options[:global_search]
591 |
592 |
593 | search(search_dir)
594 |
595 | @results.sort!
596 |
597 |
598 | if @results.empty?
599 | print "Didn't find anything.\n"
600 | exit
601 | end
602 |
603 | unless @options[:show_only] || !@options[:display_info]
604 | if @results.count == 1 || @results[0].rank - @results[1].rank > @options[:inference_index]
605 | exec_result @results[0]
606 | exit
607 | end
608 |
609 | # print "\\Too vague. Try refining the search.\\n"
610 | end
611 |
612 | print "\n" if @options[:display_info]
613 |
614 | print_results
615 | filtering_loop
616 | end
617 |
618 | #
619 | # return a structure describing the options.
620 | #
621 | def self.parse(args)
622 | # the options specified on the command line will be collected in *options*.
623 | # we set default values here.
624 |
625 | end # parse()
626 |
627 | end # class Infer
628 |
629 | # options = Infer.parse(@arguments)
630 | # pp options
631 |
632 | if __FILE__ == $0
633 | trap("SIGINT") { puts " ya"; exit!; }
634 |
635 | begin
636 | i = Infer.new(ARGV)
637 | i.run
638 | rescue OptionParser::InvalidOption => e
639 | puts e.message
640 | end
641 | end
642 |
643 |
644 | [0,1,2].map {|i| i + 1 }
645 |
--------------------------------------------------------------------------------