fzf-ruby 0.1.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.
- checksums.yaml +7 -0
- data/lib/fzf/tasks.rb +195 -0
- data/lib/fzf/version.rb +4 -0
- data/lib/fzf.rb +361 -0
- metadata +54 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1d8aeb61620f712a78d80f9efe0928314cd9eb96fcb8e47c15bf4911f7d31393
|
|
4
|
+
data.tar.gz: 68d72dcb58df89ac5d8f415af888d36e008dfad7c68fe50e2034f912b0fc5859
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c18947f2ec169459782fdee57c3d0c5d6174407da594eaabef6babf6e8b53c75414c67d2583c6a4a7319f3c97f1af7f482b0cd6f179d3bae4110e551b2388e7d
|
|
7
|
+
data.tar.gz: 04ea977125b483d2e55007bfceb15af4cc5660f2a5f9f90cc522df00c81030b86897763d764c6f4e4e8ae8f0073154c3866d1175492496a534e467cee56be679
|
data/lib/fzf/tasks.rb
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Rake tasks for building fzf from source.
|
|
2
|
+
#
|
|
3
|
+
# In your consuming project's Rakefile:
|
|
4
|
+
# require "fzf/tasks"
|
|
5
|
+
#
|
|
6
|
+
# Then:
|
|
7
|
+
# rake fzf:install # clone + build fzf into ./bin/fzf (installs Go if needed)
|
|
8
|
+
# rake fzf:version # print installed fzf version
|
|
9
|
+
# rake fzf:clean # remove ./bin/fzf
|
|
10
|
+
|
|
11
|
+
require "rake"
|
|
12
|
+
require "tmpdir"
|
|
13
|
+
require "fileutils"
|
|
14
|
+
require "open-uri"
|
|
15
|
+
require "rbconfig"
|
|
16
|
+
|
|
17
|
+
module FzfRuby
|
|
18
|
+
module Tasks
|
|
19
|
+
extend Rake::DSL
|
|
20
|
+
|
|
21
|
+
# Source repository for fzf
|
|
22
|
+
FZF_REPO = "https://github.com/junegunn/fzf.git"
|
|
23
|
+
|
|
24
|
+
# Go's recommended install location (per https://go.dev/doc/install)
|
|
25
|
+
GO_HOME = "/usr/local/go"
|
|
26
|
+
|
|
27
|
+
# Go download base URL
|
|
28
|
+
GO_DL_BASE = "https://go.dev/dl"
|
|
29
|
+
|
|
30
|
+
# Map Ruby's RbConfig values to Go's platform naming
|
|
31
|
+
ARCH_MAP = {
|
|
32
|
+
"x86_64" => "amd64",
|
|
33
|
+
"aarch64" => "arm64",
|
|
34
|
+
"arm64" => "arm64",
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
OS_MAP = {
|
|
38
|
+
"linux" => "linux",
|
|
39
|
+
"darwin" => "darwin",
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# Detect the host OS in Go's naming convention
|
|
43
|
+
def self.detect_os
|
|
44
|
+
host = RbConfig::CONFIG["host_os"]
|
|
45
|
+
OS_MAP.each { |prefix, name| return name if host.start_with?(prefix) }
|
|
46
|
+
abort "Unsupported OS for Go auto-install: #{host}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Detect the host architecture in Go's naming convention
|
|
50
|
+
def self.detect_arch
|
|
51
|
+
cpu = RbConfig::CONFIG["host_cpu"]
|
|
52
|
+
ARCH_MAP.fetch(cpu) { abort "Unsupported arch for Go auto-install: #{cpu}" }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Fetch the latest stable Go version string (e.g. "go1.26.5")
|
|
56
|
+
def self.fetch_go_version
|
|
57
|
+
# go.dev/VERSION?m=text returns the version on the first line
|
|
58
|
+
URI.open("https://go.dev/VERSION?m=text") { |f| f.readline.strip }
|
|
59
|
+
rescue => e
|
|
60
|
+
abort "Failed to fetch latest Go version: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Return the path to a working `go` binary, installing if necessary.
|
|
64
|
+
def self.ensure_go
|
|
65
|
+
# Check for system Go first
|
|
66
|
+
system_go = `which go 2>/dev/null`.strip
|
|
67
|
+
unless system_go.empty?
|
|
68
|
+
puts "Using system Go: #{`go version`.strip}"
|
|
69
|
+
return system_go
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Check for previously-installed Go in GO_HOME
|
|
73
|
+
local_go = File.join(GO_HOME, "bin", "go")
|
|
74
|
+
if File.executable?(local_go)
|
|
75
|
+
puts "Using Go from #{GO_HOME}: #{`#{local_go} version`.strip}"
|
|
76
|
+
return local_go
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Auto-install Go to GO_HOME
|
|
80
|
+
install_go
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Download and install Go to /usr/local/go (per Go's official docs).
|
|
84
|
+
# Requires sudo since /usr/local is root-owned.
|
|
85
|
+
def self.install_go
|
|
86
|
+
version = fetch_go_version
|
|
87
|
+
os = detect_os
|
|
88
|
+
arch = detect_arch
|
|
89
|
+
tarball = "#{version}.#{os}-#{arch}.tar.gz"
|
|
90
|
+
url = "#{GO_DL_BASE}/#{tarball}"
|
|
91
|
+
|
|
92
|
+
puts "Go not found — installing #{version} to #{GO_HOME}..."
|
|
93
|
+
puts "Downloading #{url}..."
|
|
94
|
+
|
|
95
|
+
# Download the tarball
|
|
96
|
+
dl_path = File.join(Dir.tmpdir, tarball)
|
|
97
|
+
unless system("curl", "-fSL", "--progress-bar", url, "-o", dl_path)
|
|
98
|
+
abort "Failed to download Go from #{url}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Remove any stale partial install, then extract.
|
|
102
|
+
# The tarball contains a `go/` directory; extracting to /usr/local
|
|
103
|
+
# places it at /usr/local/go — exactly where Go docs recommend.
|
|
104
|
+
parent = File.dirname(GO_HOME)
|
|
105
|
+
puts "Installing to #{GO_HOME} (requires sudo)..."
|
|
106
|
+
unless system("sudo", "rm", "-rf", GO_HOME)
|
|
107
|
+
abort "Failed to remove old Go installation (sudo required)"
|
|
108
|
+
end
|
|
109
|
+
unless system("sudo", "tar", "-xzf", dl_path, "-C", parent)
|
|
110
|
+
abort "Failed to extract Go tarball (sudo required)"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Clean up downloaded tarball
|
|
114
|
+
FileUtils.rm_f(dl_path)
|
|
115
|
+
|
|
116
|
+
go_bin = File.join(GO_HOME, "bin", "go")
|
|
117
|
+
unless File.executable?(go_bin)
|
|
118
|
+
abort "Go installation succeeded but binary not found at #{go_bin}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
puts "Installed #{`#{go_bin} version`.strip} to #{GO_HOME}"
|
|
122
|
+
go_bin
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
namespace :fzf do
|
|
126
|
+
desc "Build fzf from latest source and install to bin/ (auto-installs Go if needed)"
|
|
127
|
+
task :install do
|
|
128
|
+
bin_dir = File.join(Dir.pwd, "bin")
|
|
129
|
+
dest = File.join(bin_dir, "fzf")
|
|
130
|
+
|
|
131
|
+
# Ensure Go is available (install if missing)
|
|
132
|
+
go_bin = FzfRuby::Tasks.ensure_go
|
|
133
|
+
|
|
134
|
+
# Prepend Go's bin dir to PATH so `make` can find it
|
|
135
|
+
go_bin_dir = File.dirname(go_bin)
|
|
136
|
+
env = { "PATH" => "#{go_bin_dir}:#{ENV['PATH']}" }
|
|
137
|
+
|
|
138
|
+
FileUtils.mkdir_p(bin_dir)
|
|
139
|
+
|
|
140
|
+
Dir.mktmpdir("fzf-build") do |tmpdir|
|
|
141
|
+
puts "Cloning fzf from #{FZF_REPO}..."
|
|
142
|
+
unless system("git", "clone", "--depth", "1", FZF_REPO, tmpdir)
|
|
143
|
+
abort "Failed to clone fzf repository"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
puts "Building fzf..."
|
|
147
|
+
# `make install` compiles and places the binary in <source>/bin/fzf
|
|
148
|
+
unless system(env, "make", "install", chdir: tmpdir)
|
|
149
|
+
abort "Failed to build fzf"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
built = File.join(tmpdir, "bin", "fzf")
|
|
153
|
+
unless File.exist?(built)
|
|
154
|
+
abort "Build succeeded but binary not found at #{built}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
FileUtils.cp(built, dest)
|
|
158
|
+
FileUtils.chmod(0o755, dest)
|
|
159
|
+
puts "Installed fzf to #{dest}"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
desc "Print installed fzf version from bin/"
|
|
164
|
+
task :version do
|
|
165
|
+
fzf = File.join(Dir.pwd, "bin", "fzf")
|
|
166
|
+
if File.executable?(fzf)
|
|
167
|
+
system(fzf, "--version")
|
|
168
|
+
else
|
|
169
|
+
puts "fzf not found in bin/ — run `rake fzf:install`"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
desc "Remove fzf binary from bin/"
|
|
174
|
+
task :clean do
|
|
175
|
+
fzf = File.join(Dir.pwd, "bin", "fzf")
|
|
176
|
+
if File.exist?(fzf)
|
|
177
|
+
FileUtils.rm(fzf)
|
|
178
|
+
puts "Removed #{fzf}"
|
|
179
|
+
else
|
|
180
|
+
puts "Nothing to clean"
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
desc "Remove Go installation from #{GO_HOME} (requires sudo)"
|
|
185
|
+
task :clean_go do
|
|
186
|
+
if Dir.exist?(GO_HOME)
|
|
187
|
+
system("sudo", "rm", "-rf", GO_HOME) || abort("Failed to remove #{GO_HOME}")
|
|
188
|
+
puts "Removed Go from #{GO_HOME}"
|
|
189
|
+
else
|
|
190
|
+
puts "No Go installation at #{GO_HOME}"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
data/lib/fzf/version.rb
ADDED
data/lib/fzf.rb
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# fzf.rb — Ruby wrapper for the fzf fuzzy finder.
|
|
2
|
+
#
|
|
3
|
+
# Provides a single `fzf` method that pipes items into fzf and returns
|
|
4
|
+
# the user's selection. Works as a module method or a top-level function.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# require "fzf"
|
|
8
|
+
#
|
|
9
|
+
# # Basic selection
|
|
10
|
+
# choice = fzf("apple", "banana", "cherry")
|
|
11
|
+
#
|
|
12
|
+
# # Multi-select with appearance options
|
|
13
|
+
# choices = fzf(*items, multi: true, prompt: "Pick> ", header: "Fruits",
|
|
14
|
+
# border: :rounded, layout: :reverse, height: "40%")
|
|
15
|
+
#
|
|
16
|
+
# # Preview pane and field extraction
|
|
17
|
+
# fzf(*entries, preview: "echo {}", preview_window: "right:50%:wrap",
|
|
18
|
+
# delimiter: ":", nth: "1,2", with_nth: "1")
|
|
19
|
+
#
|
|
20
|
+
# # Custom keybinds (string or hash form)
|
|
21
|
+
# fzf(*items, bind: { "ctrl-a" => "select-all", "ctrl-d" => "deselect-all" })
|
|
22
|
+
# fzf(*items, bind: "ctrl-a:select-all,ctrl-d:deselect-all")
|
|
23
|
+
#
|
|
24
|
+
# # Bordered sections, labels and styling
|
|
25
|
+
# fzf(*items, style: :full, list_border: :rounded, list_label: " Files ",
|
|
26
|
+
# input_border: true, header_border: :sharp, footer: "enter to open")
|
|
27
|
+
#
|
|
28
|
+
# # Search tuning
|
|
29
|
+
# fzf(*items, scheme: :path, tiebreak: "begin,length", ignore_case: true)
|
|
30
|
+
|
|
31
|
+
require_relative "fzf/version"
|
|
32
|
+
|
|
33
|
+
module FzfRuby
|
|
34
|
+
# Raised when fzf binary is not found in PATH
|
|
35
|
+
class FzfNotFoundError < StandardError; end
|
|
36
|
+
|
|
37
|
+
# Resolve the fzf binary path.
|
|
38
|
+
# Priority: FZF_PATH env var → project bin/fzf → system PATH
|
|
39
|
+
def self.fzf_binary
|
|
40
|
+
# Explicit override via environment variable
|
|
41
|
+
from_env = ENV["FZF_PATH"]
|
|
42
|
+
return from_env if from_env && File.executable?(from_env)
|
|
43
|
+
|
|
44
|
+
# Local project binary (installed via `rake fzf:install`)
|
|
45
|
+
local = File.join(project_root, "bin", "fzf")
|
|
46
|
+
return local if File.executable?(local)
|
|
47
|
+
|
|
48
|
+
# Fall back to system PATH
|
|
49
|
+
"fzf"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Find the project root — the directory containing the Gemfile.
|
|
53
|
+
# Falls back to the current working directory.
|
|
54
|
+
def self.project_root
|
|
55
|
+
if defined?(Bundler)
|
|
56
|
+
Bundler.root.to_s
|
|
57
|
+
else
|
|
58
|
+
Dir.pwd
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Maps Ruby keyword options to fzf CLI flags.
|
|
63
|
+
# Organized by category, mirroring the sections of `fzf --help` (v0.73).
|
|
64
|
+
#
|
|
65
|
+
# Every flag fzf accepts should have exactly one entry here — this hash is
|
|
66
|
+
# the single source of truth for the supported option surface. Adding a flag
|
|
67
|
+
# means adding a line here, plus listing it in BOOLEAN_OPTIONS or
|
|
68
|
+
# OPTIONAL_VALUE_OPTIONS if it is not a plain required-value flag.
|
|
69
|
+
OPTION_FLAGS = {
|
|
70
|
+
# --- Selection ---
|
|
71
|
+
multi: "--multi",
|
|
72
|
+
accept_nth: "--accept-nth", # fields to print on accept
|
|
73
|
+
id_nth: "--id-nth", # item identity fields for reloads
|
|
74
|
+
|
|
75
|
+
# --- Search behavior ---
|
|
76
|
+
query: "--query", # prefill the query string
|
|
77
|
+
filter: "--filter", # non-interactive filter mode
|
|
78
|
+
delimiter: "--delimiter",
|
|
79
|
+
nth: "--nth", # fields to match against
|
|
80
|
+
with_nth: "--with-nth", # fields to display
|
|
81
|
+
scheme: "--scheme", # default | path | history
|
|
82
|
+
tiebreak: "--tiebreak", # length | chunk | begin | end | index
|
|
83
|
+
tail: "--tail", # max items to keep in memory
|
|
84
|
+
exact: "--exact",
|
|
85
|
+
literal: "--literal", # do not normalize latin letters
|
|
86
|
+
disabled: "--disabled", # do not perform search
|
|
87
|
+
ignore_case: "--ignore-case",
|
|
88
|
+
no_ignore_case: "--no-ignore-case", # force case-sensitive
|
|
89
|
+
smart_case: "--smart-case", # fzf's default
|
|
90
|
+
no_extended: "--no-extended", # disable extended-search syntax
|
|
91
|
+
no_sort: "--no-sort",
|
|
92
|
+
tac: "--tac", # reverse input order
|
|
93
|
+
track: "--track", # track selection across updates
|
|
94
|
+
sync: "--sync", # for multi-staged filtering
|
|
95
|
+
cycle: "--cycle",
|
|
96
|
+
select_1: "--select-1", # auto-select if only one match
|
|
97
|
+
exit_0: "--exit-0", # exit immediately if no match
|
|
98
|
+
|
|
99
|
+
# --- Prompt / input line ---
|
|
100
|
+
prompt: "--prompt",
|
|
101
|
+
pointer: "--pointer",
|
|
102
|
+
marker: "--marker",
|
|
103
|
+
marker_multi_line: "--marker-multi-line",
|
|
104
|
+
ghost: "--ghost", # placeholder shown on empty input
|
|
105
|
+
no_input: "--no-input", # hide the input section
|
|
106
|
+
input_border: "--input-border",
|
|
107
|
+
input_label: "--input-label",
|
|
108
|
+
input_label_pos: "--input-label-pos",
|
|
109
|
+
|
|
110
|
+
# --- Info line ---
|
|
111
|
+
info: "--info", # default | inline | hidden | inline-right
|
|
112
|
+
info_command: "--info-command",
|
|
113
|
+
separator: "--separator",
|
|
114
|
+
no_separator: "--no-separator",
|
|
115
|
+
|
|
116
|
+
# --- Header ---
|
|
117
|
+
header: "--header",
|
|
118
|
+
header_lines: "--header-lines", # treat first N input lines as header
|
|
119
|
+
header_first: "--header-first", # print header above the prompt
|
|
120
|
+
header_border: "--header-border",
|
|
121
|
+
header_lines_border: "--header-lines-border",
|
|
122
|
+
header_label: "--header-label",
|
|
123
|
+
header_label_pos: "--header-label-pos",
|
|
124
|
+
|
|
125
|
+
# --- Footer ---
|
|
126
|
+
footer: "--footer",
|
|
127
|
+
footer_border: "--footer-border",
|
|
128
|
+
footer_label: "--footer-label",
|
|
129
|
+
footer_label_pos: "--footer-label-pos",
|
|
130
|
+
|
|
131
|
+
# --- Preview ---
|
|
132
|
+
preview: "--preview",
|
|
133
|
+
preview_window: "--preview-window",
|
|
134
|
+
preview_border: "--preview-border",
|
|
135
|
+
preview_label: "--preview-label",
|
|
136
|
+
preview_label_pos: "--preview-label-pos",
|
|
137
|
+
preview_wrap_sign: "--preview-wrap-sign",
|
|
138
|
+
|
|
139
|
+
# --- Layout / appearance ---
|
|
140
|
+
height: "--height",
|
|
141
|
+
min_height: "--min-height", # floor when height is a percentage
|
|
142
|
+
layout: "--layout", # default | reverse | reverse-list
|
|
143
|
+
style: "--style", # default | minimal | full[:BORDER]
|
|
144
|
+
border: "--border", # rounded | sharp | bold | double | none | ...
|
|
145
|
+
border_label: "--border-label",
|
|
146
|
+
border_label_pos: "--border-label-pos",
|
|
147
|
+
list_border: "--list-border",
|
|
148
|
+
list_label: "--list-label",
|
|
149
|
+
list_label_pos: "--list-label-pos",
|
|
150
|
+
margin: "--margin", # TRBL | TB,RL | T,RL,B | T,R,B,L
|
|
151
|
+
padding: "--padding", # same forms as margin
|
|
152
|
+
color: "--color",
|
|
153
|
+
no_color: "--no-color",
|
|
154
|
+
no_bold: "--no-bold",
|
|
155
|
+
ansi: "--ansi", # process ANSI color codes in input
|
|
156
|
+
|
|
157
|
+
# --- List rendering ---
|
|
158
|
+
highlight_line: "--highlight-line",
|
|
159
|
+
wrap: "--wrap", # char | word
|
|
160
|
+
wrap_sign: "--wrap-sign",
|
|
161
|
+
no_multi_line: "--no-multi-line", # only meaningful with --read0
|
|
162
|
+
gap: "--gap", # blank lines between items
|
|
163
|
+
gap_line: "--gap-line", # string drawn in each gap
|
|
164
|
+
keep_right: "--keep-right",
|
|
165
|
+
no_hscroll: "--no-hscroll",
|
|
166
|
+
hscroll_off: "--hscroll-off",
|
|
167
|
+
scroll_off: "--scroll-off",
|
|
168
|
+
ellipsis: "--ellipsis",
|
|
169
|
+
tabstop: "--tabstop",
|
|
170
|
+
scrollbar: "--scrollbar",
|
|
171
|
+
no_scrollbar: "--no-scrollbar",
|
|
172
|
+
raw: "--raw", # show non-matching items
|
|
173
|
+
gutter: "--gutter",
|
|
174
|
+
gutter_raw: "--gutter-raw",
|
|
175
|
+
freeze_left: "--freeze-left", # fields frozen on the left
|
|
176
|
+
freeze_right: "--freeze-right",
|
|
177
|
+
|
|
178
|
+
# --- History ---
|
|
179
|
+
history: "--history", # file path, not shell history
|
|
180
|
+
history_size: "--history-size",
|
|
181
|
+
|
|
182
|
+
# --- Keybinds / navigation (bind gets special handling in build_command) ---
|
|
183
|
+
bind: "--bind",
|
|
184
|
+
jump_labels: "--jump-labels",
|
|
185
|
+
filepath_word: "--filepath-word", # word movements respect path separators
|
|
186
|
+
|
|
187
|
+
# --- Process / integration ---
|
|
188
|
+
with_shell: "--with-shell", # shell used for child processes
|
|
189
|
+
tmux: "--tmux", # alias of --popup
|
|
190
|
+
popup: "--popup", # requires tmux 3.3+ / Zellij 0.44+
|
|
191
|
+
listen: "--listen", # HTTP server for remote actions
|
|
192
|
+
}.freeze
|
|
193
|
+
|
|
194
|
+
# Bare flags that take no value — emitted only when the option is truthy.
|
|
195
|
+
BOOLEAN_OPTIONS = %i[
|
|
196
|
+
multi exact literal disabled ignore_case no_ignore_case smart_case
|
|
197
|
+
no_extended no_sort tac track sync cycle select_1 exit_0 ansi
|
|
198
|
+
no_input no_separator header_first no_color no_bold highlight_line
|
|
199
|
+
no_multi_line keep_right no_hscroll no_scrollbar raw filepath_word
|
|
200
|
+
].freeze
|
|
201
|
+
|
|
202
|
+
# Flags whose value is optional (`--flag` or `--flag=VALUE` in fzf's help).
|
|
203
|
+
# Passing `true` emits the bare flag; any other value emits `--flag=VALUE`.
|
|
204
|
+
OPTIONAL_VALUE_OPTIONS = %i[
|
|
205
|
+
border preview_border list_border input_border header_border
|
|
206
|
+
header_lines_border footer_border wrap gap gap_line scrollbar
|
|
207
|
+
tmux popup listen
|
|
208
|
+
].freeze
|
|
209
|
+
|
|
210
|
+
# Options that put fzf in a mode where it can emit more than one line:
|
|
211
|
+
# multi-select accepts several items, and filter mode prints every match.
|
|
212
|
+
# Any of these makes fzf() return an Array instead of a single String.
|
|
213
|
+
LIST_RESULT_OPTIONS = %i[multi filter].freeze
|
|
214
|
+
|
|
215
|
+
module_function
|
|
216
|
+
|
|
217
|
+
# Invoke fzf with the given menu items and options.
|
|
218
|
+
#
|
|
219
|
+
# @param menu_items [Array<#to_s>] items to display in fzf (also accepts a single Array)
|
|
220
|
+
# @param options [Hash] any option from OPTION_FLAGS — see that constant for
|
|
221
|
+
# the full list; unknown keys are ignored. Values follow three shapes:
|
|
222
|
+
# - keys in BOOLEAN_OPTIONS take true/false (multi:, exact:, cycle:, ...)
|
|
223
|
+
# - keys in OPTIONAL_VALUE_OPTIONS take true (fzf's default) or a value
|
|
224
|
+
# (border: true, border: :rounded, wrap: :word, gap: 1, tmux: "80%")
|
|
225
|
+
# - everything else takes a value (prompt:, header:, preview:, height:, ...)
|
|
226
|
+
# Symbol values are hyphenated for fzf (layout: :reverse_list → reverse-list);
|
|
227
|
+
# String values are passed through untouched.
|
|
228
|
+
# Notable options:
|
|
229
|
+
# multi: [Boolean] enable multi-select
|
|
230
|
+
# prompt: [String] custom prompt string
|
|
231
|
+
# header: [String] header text above the list
|
|
232
|
+
# preview: [String] shell command for preview pane ({} = current item)
|
|
233
|
+
# preview_window: [String] preview pane layout (e.g. "right:50%:wrap")
|
|
234
|
+
# height: [String] window height (e.g. "40%", "20")
|
|
235
|
+
# layout: [Symbol|String] :reverse, :reverse_list, or :default
|
|
236
|
+
# border: [Boolean|Symbol|String] true, :rounded, :sharp, :none, etc.
|
|
237
|
+
# bind: [String|Hash] keybinds — hash form: { "ctrl-a" => "select-all" }
|
|
238
|
+
# query: [String] prefill the search query
|
|
239
|
+
# exact: [Boolean] exact (non-fuzzy) matching
|
|
240
|
+
# cycle: [Boolean] wrap around the list
|
|
241
|
+
# select_1: [Boolean] auto-select if only one match
|
|
242
|
+
# exit_0: [Boolean] exit immediately when no match
|
|
243
|
+
# @return [String] the selected item (single-select mode)
|
|
244
|
+
# @return [Array<String>] selected items (multi:) or all matches (filter:)
|
|
245
|
+
# @return [nil] if the user cancelled (Ctrl-C / Esc), or nothing matched
|
|
246
|
+
def fzf(*menu_items, **options)
|
|
247
|
+
# Flatten in case caller passes an array instead of splatted args
|
|
248
|
+
items = menu_items.flatten.map(&:to_s)
|
|
249
|
+
|
|
250
|
+
cmd = build_command(options)
|
|
251
|
+
result = run_fzf(cmd, items)
|
|
252
|
+
|
|
253
|
+
# Return nil on cancel, an array for the modes that can yield several
|
|
254
|
+
# lines, a single string otherwise
|
|
255
|
+
return nil if result.nil?
|
|
256
|
+
list_result?(options) ? result : result.first
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Does this option set put fzf in a mode that can return more than one line?
|
|
260
|
+
#
|
|
261
|
+
# @param options [Hash] keyword options from the caller
|
|
262
|
+
# @return [Boolean]
|
|
263
|
+
def list_result?(options)
|
|
264
|
+
LIST_RESULT_OPTIONS.any? { |key| options[key] }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Build the fzf command as an array (avoids shell interpolation).
|
|
268
|
+
#
|
|
269
|
+
# Values are always attached with `=` (`--prompt=Pick> `) rather than as a
|
|
270
|
+
# separate argument. fzf accepts both forms, but the `=` form is unambiguous
|
|
271
|
+
# for values that begin with a dash (e.g. pointer: "->") and is required for
|
|
272
|
+
# distinguishing a bare optional-value flag from one carrying a value.
|
|
273
|
+
#
|
|
274
|
+
# @param options [Hash] keyword options from the caller
|
|
275
|
+
# @return [Array<String>] command suitable for IO.popen
|
|
276
|
+
def build_command(options)
|
|
277
|
+
cmd = [FzfRuby.fzf_binary]
|
|
278
|
+
|
|
279
|
+
options.each do |key, value|
|
|
280
|
+
flag = OPTION_FLAGS[key]
|
|
281
|
+
next unless flag # ignore unknown options silently
|
|
282
|
+
|
|
283
|
+
if BOOLEAN_OPTIONS.include?(key)
|
|
284
|
+
# Boolean flags are added only when truthy
|
|
285
|
+
cmd << flag if value
|
|
286
|
+
elsif value.nil? || value == false
|
|
287
|
+
# Any option explicitly disabled is simply omitted
|
|
288
|
+
next
|
|
289
|
+
elsif OPTIONAL_VALUE_OPTIONS.include?(key)
|
|
290
|
+
# `true` means "use fzf's default" — emit the flag with no value
|
|
291
|
+
cmd << (value == true ? flag : "#{flag}=#{serialize_value(value)}")
|
|
292
|
+
elsif key == :bind
|
|
293
|
+
# Keybinds accept a string or a hash of { key_combo => action }
|
|
294
|
+
cmd << "#{flag}=#{serialize_bind(value)}"
|
|
295
|
+
else
|
|
296
|
+
cmd << "#{flag}=#{serialize_value(value)}"
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
cmd
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Serialize an option value for the command line.
|
|
304
|
+
#
|
|
305
|
+
# Symbols are treated as Ruby-flavoured spellings of fzf's hyphenated
|
|
306
|
+
# enum values (layout: :reverse_list → "reverse-list"). Every other type is
|
|
307
|
+
# passed through verbatim — strings are user data (labels, paths, preview
|
|
308
|
+
# commands) and must never be rewritten.
|
|
309
|
+
#
|
|
310
|
+
# @param value [Object] the raw option value
|
|
311
|
+
# @return [String] the value as fzf expects it
|
|
312
|
+
def serialize_value(value)
|
|
313
|
+
value.is_a?(Symbol) ? value.to_s.tr("_", "-") : value.to_s
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Serialize bind option to fzf's comma-separated key:action format.
|
|
317
|
+
#
|
|
318
|
+
# @param value [String, Hash] either a raw bind string or a hash mapping
|
|
319
|
+
# key combos to actions (e.g. { "ctrl-a" => "select-all" })
|
|
320
|
+
# @return [String] fzf-compatible bind string
|
|
321
|
+
def serialize_bind(value)
|
|
322
|
+
case value
|
|
323
|
+
when Hash
|
|
324
|
+
# Convert { "ctrl-a" => "select-all", "ctrl-d" => "deselect-all" }
|
|
325
|
+
# into "ctrl-a:select-all,ctrl-d:deselect-all"
|
|
326
|
+
value.map { |k, v| "#{k}:#{v}" }.join(",")
|
|
327
|
+
else
|
|
328
|
+
value.to_s
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Pipe items into fzf via stdin and capture the selected lines.
|
|
333
|
+
#
|
|
334
|
+
# fzf reads candidates from stdin and opens /dev/tty for interactive
|
|
335
|
+
# user input, so IO.popen works without additional TTY wiring.
|
|
336
|
+
#
|
|
337
|
+
# @param cmd [Array<String>] the fzf command array
|
|
338
|
+
# @param items [Array<String>] candidate lines
|
|
339
|
+
# @return [Array<String>, nil] selected lines, or nil on cancel/error
|
|
340
|
+
def run_fzf(cmd, items)
|
|
341
|
+
input = items.join("\n")
|
|
342
|
+
|
|
343
|
+
output = IO.popen(cmd, "r+") do |io|
|
|
344
|
+
io.write(input)
|
|
345
|
+
io.close_write
|
|
346
|
+
io.read
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# fzf exit codes: 0 = ok, 1 = no match, 2 = error, 130 = cancelled
|
|
350
|
+
return nil unless $?.success?
|
|
351
|
+
|
|
352
|
+
selections = output.split("\n").reject(&:empty?)
|
|
353
|
+
selections.empty? ? nil : selections
|
|
354
|
+
rescue Errno::ENOENT
|
|
355
|
+
raise FzfNotFoundError,
|
|
356
|
+
"fzf is not installed or not found in PATH. Install it: https://github.com/junegunn/fzf#installation"
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Make fzf() available as a top-level method for convenience
|
|
361
|
+
include FzfRuby
|
metadata
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: fzf-ruby
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- asmrtfm
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Pipes items into fzf and returns the user's selection. Supports multi-select,
|
|
13
|
+
preview panes, keybinds, layout, field extraction, and all major fzf options as
|
|
14
|
+
Ruby keywords.
|
|
15
|
+
email:
|
|
16
|
+
- asmrtfm@gmail.com
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- lib/fzf.rb
|
|
22
|
+
- lib/fzf/tasks.rb
|
|
23
|
+
- lib/fzf/version.rb
|
|
24
|
+
homepage: https://github.com/asmrtfm/fzf-ruby
|
|
25
|
+
licenses:
|
|
26
|
+
- MIT
|
|
27
|
+
metadata: {}
|
|
28
|
+
post_install_message: |
|
|
29
|
+
fzf-ruby requires the `fzf` binary.
|
|
30
|
+
|
|
31
|
+
Build from source (requires Go >= 1.23):
|
|
32
|
+
1. Add to your Rakefile: require "fzf/tasks"
|
|
33
|
+
2. Run: rake fzf:install
|
|
34
|
+
|
|
35
|
+
This clones and compiles fzf into your project's bin/ directory.
|
|
36
|
+
Alternatively, install fzf system-wide: https://github.com/junegunn/fzf#installation
|
|
37
|
+
rdoc_options: []
|
|
38
|
+
require_paths:
|
|
39
|
+
- lib
|
|
40
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
41
|
+
requirements:
|
|
42
|
+
- - ">="
|
|
43
|
+
- !ruby/object:Gem::Version
|
|
44
|
+
version: '3.0'
|
|
45
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '0'
|
|
50
|
+
requirements: []
|
|
51
|
+
rubygems_version: 4.0.10
|
|
52
|
+
specification_version: 4
|
|
53
|
+
summary: Ruby wrapper for fzf — fuzzy find anything with `fzf(*items, **options)`
|
|
54
|
+
test_files: []
|