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