Fuzzy CLI file search through configured directories, ranked results displayed as menu
#!/usr/bin/env ruby | |
# encoding: utf-8 | |
# == Synopsis | |
# Proof of concept using Fuzzy File Finder to locate a script to edit | |
# Searches a set of predefined locations for a fuzzy string | |
# e.g. "mwp" matches both "myweatherprogram" and "mowthelawnplease" | |
# ................on "(m)y(w)eather(p)rogram" and "(m)o(w)thelawn(p)lease" | |
# | |
# Results are ranked and a menu is displayed with the most likely | |
# match at the top. Editor to be launched and directories to search | |
# specified in CONFIG below. | |
# | |
# == Examples | |
# Search through configured directories for "mwp" | |
# editscript mwp | |
# | |
# == Usage | |
# editscript [options] "search string" | |
# | |
# For help use: editscript -h | |
# | |
# == Options | |
# -s, --show Show results without executing | |
# -n, --no-menu No menu interaction. Executes the highest ranked result | |
# or, with '-s', returns a plain list of results | |
# -a, --show-all Show all results, otherwise limited to top 10 | |
# --scores Show match scoring with results | |
# -d, --debug Verbose debugging | |
# -h, --help help | |
# | |
# == Author | |
# Brett Terpstra | |
# | |
# == Copyright | |
# Public Domain | |
# | |
# This script includes Fuzzy File Finder by Jamis Buck, no external dependencies | |
# Fuzzy File Finder is available as a gem as well if you want to play further | |
# `gem install –source=gems.github.com jamis-fuzzy_file_finder` | |
## CONFIG | |
# | |
# The CLI tool you want to launch with the file | |
editor = 'subl' | |
# A list of directories to search in (recursive) | |
search_paths = ['~/scripts','/usr/bin/local','~/bin','~/.bash_it'] | |
################################################################## | |
## ADVANCED (OPTIONAL) CONFIG | |
## You do not need to edit the $options below, but | |
## you can modify default behavior if you really want to. | |
## All of the following can be specified via command line arguments | |
## Use `editscript -h` to see the available $options | |
$options = { | |
:show => false, # Show results without executing | |
:menu => true, # Don't display a menu, execute/show the highest ranked result | |
:showall => false, # Show all results, otherwise limited to top 10 | |
:showscores => false, # Show match scoring with results | |
:debug => false, # Verbose debugging | |
} | |
## End CONFIG ##################################################### | |
require 'optparse' | |
require 'readline' | |
#-- | |
# Fuzzy File Finder <https://github.com/jamis/fuzzy_file_finder> | |
# ================================================================== | |
# Author: Jamis Buck (jamis@jamisbuck.org) | |
# Date: 2008-10-09 | |
# | |
# This file is in the public domain. Usage, modification, and | |
# redistribution of this file are unrestricted. | |
# ================================================================== | |
#++ | |
class FuzzyFileFinder | |
module Version | |
MAJOR = 1 | |
MINOR = 0 | |
TINY = 4 | |
STRING = [MAJOR, MINOR, TINY].join(".") | |
end | |
class TooManyEntries < RuntimeError; end | |
class CharacterRun < Struct.new(:string, :inside) #:nodoc: | |
def to_s | |
if inside | |
"(#{string})" | |
else | |
string | |
end | |
end | |
end | |
class FileSystemEntry #:nodoc: | |
attr_reader :parent | |
attr_reader :name | |
def initialize(parent, name) | |
@parent = parent | |
@name = name | |
end | |
def path | |
File.join(parent.name, name) | |
end | |
end | |
class Directory #:nodoc: | |
attr_reader :name | |
def initialize(name, is_root=false) | |
@name = name | |
@is_root = is_root | |
end | |
def root? | |
is_root | |
end | |
end | |
attr_reader :roots | |
attr_reader :files | |
attr_reader :ceiling | |
attr_reader :shared_prefix | |
attr_reader :ignores | |
def initialize(directories=['.'], ceiling=10_000, ignores=nil) | |
directories = Array(directories) | |
directories << "." if directories.empty? | |
root_dirnames = directories.map { |d| File.expand_path(d) }.select { |d| File.directory?(d) }.uniq | |
@roots = root_dirnames.map { |d| Directory.new(d, true) } | |
@shared_prefix = determine_shared_prefix | |
@shared_prefix_re = Regexp.new("^#{Regexp.escape(shared_prefix)}" + (shared_prefix.empty? ? "" : "/")) | |
@files = [] | |
@ceiling = ceiling | |
@ignores = Array(ignores) | |
rescan! | |
end | |
def rescan! | |
@files.clear | |
roots.each { |root| follow_tree(root) } | |
end | |
def search(pattern, &block) | |
pattern.gsub!(" ", "") | |
path_parts = pattern.split("/") | |
path_parts.push "" if pattern[-1,1] == "/" | |
file_name_part = path_parts.pop || "" | |
if path_parts.any? | |
path_regex_raw = "^(.*?)" + path_parts.map { |part| make_pattern(part) }.join("(.*?/.*?)") + "(.*?)$" | |
path_regex = Regexp.new(path_regex_raw, Regexp::IGNORECASE) | |
end | |
file_regex_raw = "^(.*?)" << make_pattern(file_name_part) << "(.*)$" | |
file_regex = Regexp.new(file_regex_raw, Regexp::IGNORECASE) | |
path_matches = {} | |
files.each do |file| | |
path_match = match_path(file.parent, path_matches, path_regex, path_parts.length) | |
next if path_match[:missed] | |
match_file(file, file_regex, path_match, &block) | |
end | |
end | |
def find(pattern, max=nil) | |
results = [] | |
search(pattern) do |match| | |
results << match | |
break if max && results.length >= max | |
end | |
return results | |
end | |
def inspect #:nodoc: | |
"#<%s:0x%x roots=%s, files=%d>" % [self.class.name, object_id, roots.map { |r| r.name.inspect }.join(", "), files.length] | |
end | |
private | |
def follow_tree(directory) | |
Dir.entries(directory.name).each do |entry| | |
next if entry[0,1] == "." | |
next if ignore?(directory.name) # Ignore whole directory hierarchies | |
raise TooManyEntries if files.length > ceiling | |
full = File.join(directory.name, entry) | |
if File.directory?(full) | |
follow_tree(Directory.new(full)) | |
elsif !ignore?(full.sub(@shared_prefix_re, "")) | |
files.push(FileSystemEntry.new(directory, entry)) | |
end | |
end | |
end | |
def ignore?(name) | |
ignores.any? { |pattern| File.fnmatch(pattern, name) } | |
end | |
def make_pattern(pattern) | |
pattern = pattern.split(//) | |
pattern << "" if pattern.empty? | |
pattern.inject("") do |regex, character| | |
regex << "([^/]*?)" if regex.length > 0 | |
regex << "(" << Regexp.escape(character) << ")" | |
end | |
end | |
def build_match_result(match, inside_segments) | |
runs = [] | |
inside_chars = total_chars = 0 | |
match.captures.each_with_index do |capture, index| | |
if capture.length > 0 | |
inside = index % 2 != 0 | |
total_chars += capture.gsub(%r(/), "").length # ignore '/' delimiters | |
inside_chars += capture.length if inside | |
if runs.last && runs.last.inside == inside | |
runs.last.string << capture | |
else | |
runs << CharacterRun.new(capture, inside) | |
end | |
end | |
end | |
inside_runs = runs.select { |r| r.inside } | |
run_ratio = inside_runs.length.zero? ? 1 : inside_segments / inside_runs.length.to_f | |
char_ratio = total_chars.zero? ? 1 : inside_chars.to_f / total_chars | |
score = run_ratio * char_ratio | |
return { :score => score, :result => runs.join } | |
end | |
def match_path(path, path_matches, path_regex, path_segments) | |
return path_matches[path] if path_matches.key?(path) | |
name_with_slash = path.name + "/" # add a trailing slash for matching the prefix | |
matchable_name = name_with_slash.sub(@shared_prefix_re, "") | |
matchable_name.chop! # kill the trailing slash | |
if path_regex | |
match = matchable_name.match(path_regex) | |
path_matches[path] = | |
match && build_match_result(match, path_segments) || | |
{ :score => 1, :result => matchable_name, :missed => true } | |
else | |
path_matches[path] = { :score => 1, :result => matchable_name } | |
end | |
end | |
def match_file(file, file_regex, path_match, &block) | |
if file_match = file.name.match(file_regex) | |
match_result = build_match_result(file_match, 1) | |
full_match_result = path_match[:result].empty? ? match_result[:result] : File.join(path_match[:result], match_result[:result]) | |
shortened_path = path_match[:result].gsub(/[^\/]+/) { |m| m.index("(") ? m : m[0,1] } | |
abbr = shortened_path.empty? ? match_result[:result] : File.join(shortened_path, match_result[:result]) | |
result = { :path => file.path, | |
:abbr => abbr, | |
:directory => file.parent.name, | |
:name => file.name, | |
:highlighted_directory => path_match[:result], | |
:highlighted_name => match_result[:result], | |
:highlighted_path => full_match_result, | |
:score => path_match[:score] * match_result[:score] } | |
yield result | |
end | |
end | |
def determine_shared_prefix | |
return roots.first.name if roots.length == 1 | |
split_roots = roots.map { |root| root.name.split(%r{/}) } | |
segments = split_roots.map { |root| root.length }.max | |
master = split_roots.pop | |
segments.times do |segment| | |
if !split_roots.all? { |root| root[segment] == master[segment] } | |
return master[0,segment].join("/") | |
end | |
end | |
return roots.first.name | |
end | |
end | |
################################### editscript | |
opt_parser = OptionParser.new do |opt| | |
opt.banner = "Usage: #{File.basename(__FILE__)} [$options] 'search terms'" | |
opt.separator "" | |
opt.separator "Options:" | |
opt.on("-s","--show","Show results without executing") do |environment| | |
$options[:show] = true | |
end | |
opt.on("-n","--no-menu","No menu interaction. Executes the highest ranked result or, with '-s', returns a plain list of results") do |menu| | |
$options[:menu] = false | |
end | |
opt.on("-a","--show-all","Show all results, otherwise limited to top 10") do | |
$options[:showall] = true | |
end | |
opt.on("--scores","Show match scoring with results") do | |
$options[:showscores] = true | |
end | |
opt.on("-d","--debug","Verbose debugging") do | |
$options[:debug] = true | |
end | |
opt.on("-h","--help","help") do | |
puts opt_parser | |
exit | |
end | |
end | |
opt_parser.parse! | |
if ARGV.empty? | |
puts "No search term given. Use '#{File.basename(__FILE__)} -h' for help." | |
exit | |
else | |
search_terms = ARGV.join(' ') | |
end | |
puts "Searching #{search_terms}" if $options[:debug] | |
finder = FuzzyFileFinder.new(search_paths) | |
res = finder.find(search_terms).delete_if { |file| | |
%x{file "#{file[:path]}"}.chomp !~ /text/ | |
} | |
if res.length == 0 | |
puts "No matching files" | |
exit | |
elsif res.length > 1 | |
res = res.sort {|a,b| | |
a[:score] <=> b[:score] | |
}.reverse | |
end | |
res.each do |match| | |
printf("[%09.4f]",match[:score]) if $options[:showscores] | |
puts match[:path] | |
end if $options[:show] | |
def results_menu(res) | |
counter = 1 | |
puts | |
res.each do |match| | |
display = $options[:debug] ? match[:highlighted_path] : match[:path] | |
if $options[:showscores] | |
printf("%2d ) [%09.4f] %s\n",counter, match[:score], display) | |
else | |
printf("%2d ) %s\n", counter, display) | |
end | |
counter += 1 | |
end | |
puts | |
end | |
unless $options[:show] | |
unless $options[:menu] # Just execute the top result | |
%x{#{editor} #{res[0][:path]}} | |
else # Show a menu of results | |
res = res[0..9] unless $options[:showall] # limit to top 10 results | |
stty_save = `stty -g`.chomp | |
trap('INT') { system('stty', stty_save); exit } | |
results_menu(res) | |
begin | |
printf("Type 'q' or hit return to cancel",res.length) | |
while line = Readline.readline(": ", true) | |
if line =~ /^[a-z]/i || line == '' | |
system('stty', stty_save) # Restore | |
exit | |
end | |
if line.to_i > 0 && line.to_i <= res.length | |
puts res[line.to_i - 1][:path] if $options[:debug] | |
%x{#{editor} #{res[line.to_i - 1][:path]}} | |
break | |
else | |
puts "Out of range" | |
results_menu(res) | |
end | |
end | |
rescue Interrupt => e | |
system('stty', stty_save) | |
exit | |
end | |
end | |
end |
# This is where the script began, a bash function... | |
# Use Sublime Text 2 to edit a script in my path | |
function subs() { | |
local script editr execute | |
editr=/usr/local/bin/subl | |
script=false | |
if [[ $1 == "-s" ]]; then | |
execute=false | |
shift | |
else | |
execute=true | |
fi | |
if [[ `which $1` ]]; then # try an exact match | |
script=`which $1` | |
[[ $execute == false ]] && echo "Rule 1" | |
elif [[ $(which $1.{rb,sh,py,bash}) ]]; then # try common extensions | |
script=$(which $1.{rb,sh,py,bash}) | |
[[ $execute == false ]] && echo "Rule 2" | |
elif [[ $(find ~/scripts -type f -name "$1*"|head -n1|tr -d "\n") ]]; then # find matches in scripts folder, prefer items that start with argument | |
script=$(find ~/scripts -type f -name "$1*"|head -n1|tr -d "\n") | |
echo "Rule 3" | |
[[ $execute == false ]] && [[ $script == false ]] && echo "No match" | |
elif [[ $(find ~/scripts -type f -name "*$1*"|head -n1|tr -d "\n") ]]; then # find any matches in scripts folder | |
script=$(find ~/scripts -type f -name "*$1*"|head -n1|tr -d "\n") | |
echo "Rule 4" | |
[[ $execute == false ]] && [[ $script == false ]] && echo "No match" | |
elif [[ $(find {/usr/local/bin,~/bin}|head -n1|tr -d "\n") ]]; then # grep /usr/local/bin and ~/bin | |
script=$(find {/usr/local/bin,~/bin}|head -n1|tr -d "\n") | |
echo "Rule 5" | |
[[ $execute == false ]] && [[ $script == false ]] && echo "No match" | |
else | |
script=false | |
[[ $execute == false ]] && echo "No match" | |
fi | |
[[ $(file $script) =~ "text" ]] || script=false | |
[[ $execute == true && $script != false ]] && $editr $script || echo $script | |
# $editr $script | |
} |
This comment has been minimized.
This comment has been minimized.
a) I found that environment variables were unavailable in some contexts for me. I didn't look into why.
b) My $EDITOR is set to vim so I don't have to launch an external editor when adding tag messages to git commits, but I want to edit scripts in Sublime. I use an $ALTEDITOR environment variable locally, but that wouldn't be much more convenient than editing the config for most people.
|
This comment has been minimized.
This comment has been minimized.
Awesome! I was just about to start creating exactly this using Ruby, thanks :) Having editor = 'vim' I got: Using exec instead of %x solved the problem |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This comment has been minimized.
Why not this?