diff --git a/README.md b/README.md index db02bec..585b5be 100644 --- a/README.md +++ b/README.md @@ -50,23 +50,31 @@ Usage ``` usage: fzf [options] - Options - -m, --multi Enable multi-select + Search -x, --extended Extended-search mode -e, --extended-exact Extended-search mode (exact match) - -q, --query=STR Initial query - -f, --filter=STR Filter mode. Do not start interactive finder. + -i Case-insensitive match (default: smart-case match) + +i Case-sensitive match -n, --nth=[-]N[,..] Comma-separated list of field indexes for limiting search scope (positive or negative integers) -d, --delimiter=STR Field delimiter regex for --nth (default: AWK-style) + + Search result -s, --sort=MAX Maximum number of matched items to sort (default: 1000) +s, --no-sort Do not sort the result. Keep the sequence unchanged. - -i Case-insensitive match (default: smart-case match) - +i Case-sensitive match + + Interface + -m, --multi Enable multi-select with tab/shift-tab + --no-mouse Disable mouse +c, --no-color Disable colors +2, --no-256 Disable 256-color --black Use black background - --no-mouse Disable mouse + + Scripting + -q, --query=STR Start the finder with the given query + -1, --select-1 (with --query) Automatically select the only match + -0, --exit-0 (with --query) Exit when there's no match + -f, --filter=STR Filter mode. Do not start interactive finder. Environment variables FZF_DEFAULT_COMMAND Default command to use when input is tty @@ -137,10 +145,12 @@ Useful examples --------------- ```sh -# vimf - Open selected file in Vim -vimf() { +# fe [FUZZY PATTERN] - Open the selected file with the default editor +# - Bypass fuzzy finder if there's only one match (--select-1) +# - Exit if there's no match (--exit-0) +fe() { local file - file=$(fzf --query="$1") && vim "$file" + file=$(fzf --query="$1" --select-1 --exit-0) && ${EDITOR:-vim} "$file" } # fd - cd to selected directory @@ -193,29 +203,6 @@ ftags() { ) && $EDITOR $(cut -f3 <<< "$line") -c "set nocst" \ -c "silent tag $(cut -f2 <<< "$line")" } - -# fq1 [QUERY] -# - Immediately select the file when there's only one match. -# If not, start the fuzzy finder as usual. -fq1() { - local lines - lines=$(fzf --filter="$1" --no-sort) - if [ -z "$lines" ]; then - return 1 - elif [ $(wc -l <<< "$lines") -eq 1 ]; then - echo "$lines" - else - echo "$lines" | fzf --query="$1" - fi -} - -# fe [QUERY] -# - Open the selected file with the default editor -# (Bypass fuzzy finder when there's only one match) -fe() { - local file - file=$(fq1 "$1") && ${EDITOR:-vim} "$file" -} ``` Key bindings for command line diff --git a/fzf b/fzf index b1313c5..9a19b4f 100755 --- a/fzf +++ b/fzf @@ -51,7 +51,7 @@ end class FZF C = Curses attr_reader :rxflag, :sort, :nth, :color, :black, :ansi256, - :mouse, :multi, :query, :filter, :extended + :mouse, :multi, :query, :select1, :exit0, :filter, :extended class AtomicVar def initialize value @@ -83,6 +83,8 @@ class FZF @multi = false @mouse = true @extended = nil + @select1 = false + @exit0 = false @filter = nil @nth = nil @delim = nil @@ -113,6 +115,10 @@ class FZF when '--mouse' then @mouse = true when '--no-mouse' then @mouse = false when '+s', '--no-sort' then @sort = nil + when '-1', '--select-1' then @select1 = true + when '+1', '--no-select-1' then @select1 = false + when '-0', '--exit-0' then @exit0 = true + when '+0', '--no-exit-0' then @exit0 = false when '-q', '--query' usage 1, 'query string required' unless query = argv.shift @query = AtomicVar.new query.dup @@ -184,41 +190,60 @@ class FZF def start if @filter - start_reader(false).join + start_reader.join filter_list @new else - @stdout = $stdout.clone - $stdout.reopen($stderr) + start_reader + emit(:key) { q = @query.get; [q, q.length] } unless @query.empty? + if !@query.empty? && (@select1 || @exit0) + start_search do |loaded, matches| + len = matches.length + if loaded + if @select1 && len == 1 + puts matches.first.first + exit 0 + elsif @exit0 && len == 0 + exit 1 + end + end - start_reader true - init_screen - start_renderer - start_search - start_loop + if loaded || len > 1 + start_renderer + Thread.new { start_loop } + end + end + + sleep + else + start_search + start_renderer + start_loop + end end end def filter_list list - matches = get_matcher.match(list, @filter, '', '') + matches = matcher.match(list, @filter, '', '') if @sort && matches.length <= @sort matches = sort_by_rank(matches) end matches.each { |m| puts m.first } end - def get_matcher - if @extended - ExtendedFuzzyMatcher.new @rxflag, @extended, @nth, @delim - else - FuzzyMatcher.new @rxflag, @nth, @delim - end + def matcher + @matcher ||= + if @extended + ExtendedFuzzyMatcher.new @rxflag, @extended, @nth, @delim + else + FuzzyMatcher.new @rxflag, @nth, @delim + end end def version File.open(__FILE__, 'r') do |f| f.each_line do |line| if line =~ /Version: (.*)/ - $stdout.puts "fzf " << $1 + $stdout.puts 'fzf ' << $1 exit end end @@ -229,23 +254,31 @@ class FZF $stderr.puts message if message $stderr.puts %[usage: fzf [options] - Options - -m, --multi Enable multi-select + Search -x, --extended Extended-search mode -e, --extended-exact Extended-search mode (exact match) - -q, --query=STR Initial query - -f, --filter=STR Filter mode. Do not start interactive finder. + -i Case-insensitive match (default: smart-case match) + +i Case-sensitive match -n, --nth=[-]N[,..] Comma-separated list of field indexes for limiting search scope (positive or negative integers) -d, --delimiter=STR Field delimiter regex for --nth (default: AWK-style) + + Search result -s, --sort=MAX Maximum number of matched items to sort (default: 1000) +s, --no-sort Do not sort the result. Keep the sequence unchanged. - -i Case-insensitive match (default: smart-case match) - +i Case-sensitive match + + Interface + -m, --multi Enable multi-select with tab/shift-tab + --no-mouse Disable mouse +c, --no-color Disable colors +2, --no-256 Disable 256-color --black Use black background - --no-mouse Disable mouse + + Scripting + -q, --query=STR Start the finder with the given query + -1, --select-1 (with --query) Automatically select the only match + -0, --exit-0 (with --query) Exit when there's no match + -f, --filter=STR Filter mode. Do not start interactive finder. Environment variables FZF_DEFAULT_COMMAND Default command to use when input is tty @@ -547,6 +580,9 @@ class FZF end def init_screen + @stdout = $stdout.clone + $stdout.reopen($stderr) + C.init_screen C.mousemask C::ALL_MOUSE_EVENTS if @mouse C.start_color @@ -604,7 +640,7 @@ class FZF C.refresh end - def start_reader curses + def start_reader stream = if @source.tty? if default_command = ENV['FZF_DEFAULT_COMMAND'] @@ -623,13 +659,12 @@ class FZF emit(:new) { @new << line.chomp } end emit(:loaded) { true } - @spinner.clear if curses + @spinner.clear if @spinner end end - def start_search - matcher = get_matcher - searcher = Thread.new { + def start_search &callback + Thread.new do lists = [] events = {} fcache = {} @@ -668,7 +703,7 @@ class FZF progress = 0 started_at = Time.now - if new_search && !lists.empty? + if updated = new_search && !lists.empty? q, cx = events.delete(:key) || [q, 0] empty = matcher.empty?(q) unless matches = fcache[q] @@ -699,6 +734,10 @@ class FZF @matches.set matches end#new_search + callback = nil if callback && + (updated || events[:loaded]) && + callback.call(events[:loaded], matches) + # This small delay reduces the number of partial lists sleep((delay = [20, delay + 5].min) * 0.01) unless user_input @@ -707,7 +746,7 @@ class FZF rescue Exception => e @main.raise e end - } + end end def pick @@ -751,6 +790,8 @@ class FZF end def start_renderer + init_screen + Thread.new do begin while blk = @queue.shift @@ -1030,7 +1071,6 @@ class FZF actions[127] = actions[ctrl(:h)] actions[ctrl(:q)] = actions[ctrl(:g)] = actions[ctrl(:c)] = actions[:esc] - emit(:key) { [@query.get, cursor] } unless @query.empty? while true @cursor_x.set cursor render { print_input } @@ -1093,7 +1133,7 @@ class FZF if (token = tokens[n]) && (md = token.match(pat) rescue nil) prefix_length += (tokens[0...n] || []).join.length offset = md.offset(0).map { |o| o + prefix_length } - return MatchData.new offset + return MatchData.new(offset) end end nil diff --git a/test/test_fzf.rb b/test/test_fzf.rb index e180878..94b80d8 100644 --- a/test/test_fzf.rb +++ b/test/test_fzf.rb @@ -1,6 +1,9 @@ #!/usr/bin/env ruby # encoding: utf-8 +require 'curses' +require 'timeout' +require 'stringio' require 'minitest/autorun' $LOAD_PATH.unshift File.expand_path('../..', __FILE__) ENV['FZF_EXECUTABLE'] = '0' @@ -20,6 +23,15 @@ class TestFZF < MiniTest::Unit::TestCase assert_equal true, fzf.color assert_equal nil, fzf.rxflag assert_equal true, fzf.mouse + assert_equal nil, fzf.nth + assert_equal true, fzf.color + assert_equal false, fzf.black + assert_equal true, fzf.ansi256 + assert_equal '', fzf.query.get + assert_equal false, fzf.select1 + assert_equal false, fzf.exit0 + assert_equal nil, fzf.filter + assert_equal nil, fzf.extended end def test_environment_variables @@ -30,7 +42,8 @@ class TestFZF < MiniTest::Unit::TestCase assert_equal nil, fzf.nth ENV['FZF_DEFAULT_OPTS'] = - '-x -m -s 10000 -q " hello world " +c +2 --no-mouse -f "goodbye world" --black --nth=3,-1,2' + '-x -m -s 10000 -q " hello world " +c +2 --select-1 -0 ' + + '--no-mouse -f "goodbye world" --black --nth=3,-1,2' fzf = FZF.new [] assert_equal 10000, fzf.sort assert_equal ' hello world ', @@ -43,13 +56,16 @@ class TestFZF < MiniTest::Unit::TestCase assert_equal false, fzf.ansi256 assert_equal true, fzf.black assert_equal false, fzf.mouse + assert_equal true, fzf.select1 + assert_equal true, fzf.exit0 assert_equal [3, -1, 2], fzf.nth end def test_option_parser # Long opts - fzf = FZF.new %w[--sort=2000 --no-color --multi +i --query hello - --filter=howdy --extended-exact --no-mouse --no-256 --nth=1] + fzf = FZF.new %w[--sort=2000 --no-color --multi +i --query hello --select-1 + --exit-0 --filter=howdy --extended-exact + --no-mouse --no-256 --nth=1] assert_equal 2000, fzf.sort assert_equal true, fzf.multi assert_equal false, fzf.color @@ -58,12 +74,16 @@ class TestFZF < MiniTest::Unit::TestCase assert_equal false, fzf.mouse assert_equal 0, fzf.rxflag assert_equal 'hello', fzf.query.get + assert_equal true, fzf.select1 + assert_equal true, fzf.exit0 assert_equal 'howdy', fzf.filter assert_equal :exact, fzf.extended assert_equal [1], fzf.nth - fzf = FZF.new %w[--sort=2000 --no-color --multi +i --query hello - --filter a --filter b --no-256 --black --nth -2 + # Long opts (left-to-right) + fzf = FZF.new %w[--sort=2000 --no-color --multi +i --query=hello + --filter a --filter b --no-256 --black --nth -1 --nth -2 + --select-1 --exit-0 --no-select-1 --no-exit-0 --no-sort -i --color --no-multi --256] assert_equal nil, fzf.sort assert_equal false, fzf.multi @@ -74,11 +94,13 @@ class TestFZF < MiniTest::Unit::TestCase assert_equal 1, fzf.rxflag assert_equal 'b', fzf.filter assert_equal 'hello', fzf.query.get + assert_equal false, fzf.select1 + assert_equal false, fzf.exit0 assert_equal nil, fzf.extended assert_equal [-2], fzf.nth # Short opts - fzf = FZF.new %w[-s 2000 +c -m +i -qhello -x -fhowdy +2 -n3] + fzf = FZF.new %w[-s2000 +c -m +i -qhello -x -fhowdy +2 -n3 -1 -0] assert_equal 2000, fzf.sort assert_equal true, fzf.multi assert_equal false, fzf.color @@ -88,10 +110,14 @@ class TestFZF < MiniTest::Unit::TestCase assert_equal 'howdy', fzf.filter assert_equal :fuzzy, fzf.extended assert_equal [3], fzf.nth + assert_equal true, fzf.select1 + assert_equal true, fzf.exit0 # Left-to-right fzf = FZF.new %w[-s 2000 +c -m +i -qhello -x -fgoodbye +2 -n3 -n4,5 - -s 3000 -c +m -i -q world +x -fworld -2 --black --no-black] + -s 3000 -c +m -i -q world +x -fworld -2 --black --no-black + -1 -0 +1 +0 + ] assert_equal 3000, fzf.sort assert_equal false, fzf.multi assert_equal true, fzf.color @@ -99,13 +125,11 @@ class TestFZF < MiniTest::Unit::TestCase assert_equal false, fzf.black assert_equal 1, fzf.rxflag assert_equal 'world', fzf.query.get + assert_equal false, fzf.select1 + assert_equal false, fzf.exit0 assert_equal 'world', fzf.filter assert_equal nil, fzf.extended assert_equal [4, 5], fzf.nth - - fzf = FZF.new %w[--query hello +s -s 2000 --query=world] - assert_equal 2000, fzf.sort - assert_equal 'world', fzf.query.get rescue SystemExit => e assert false, "Exited" end @@ -538,5 +562,60 @@ class TestFZF < MiniTest::Unit::TestCase matcher = FZF::FuzzyMatcher.new Regexp::IGNORECASE, [2], regex assert_equal [[list[0], [[1, 2]]], [list[1], [[8, 9]]]], matcher.match(list, 'f', '', '') end + + def stream_for str + StringIO.new(str).tap do |sio| + sio.instance_eval do + alias org_gets gets + + def gets + org_gets.tap { |e| sleep 0.5 unless e.nil? } + end + end + end + end + + def test_select_1 + stream = stream_for "Hello\nWorld" + output = StringIO.new + + begin + $stdout = output + FZF.new(%w[--query=ol --select-1], stream).start + rescue SystemExit => e + assert_equal 0, e.status + assert_equal 'World', output.string.chomp + ensure + $stdout = STDOUT + end + end + + def test_select_1_ambiguity + stream = stream_for "Hello\nWorld" + begin + Timeout::timeout(3) do + FZF.new(%w[--query=o --select-1], stream).start + end + flunk 'Should not reach here' + rescue Exception => e + Curses.close_screen + assert_instance_of Timeout::Error, e + end + end + + def test_exit_0 + stream = stream_for "Hello\nWorld" + output = StringIO.new + + begin + $stdout = output + FZF.new(%w[--query=zz --exit-0], stream).start + rescue SystemExit => e + assert_equal 1, e.status + assert_equal '', output.string + ensure + $stdout = STDOUT + end + end end