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.
Files changed (5) hide show
  1. checksums.yaml +7 -0
  2. data/lib/fzf/tasks.rb +195 -0
  3. data/lib/fzf/version.rb +4 -0
  4. data/lib/fzf.rb +361 -0
  5. 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
@@ -0,0 +1,4 @@
1
+ # Gem version constant
2
+ module FzfRuby
3
+ VERSION = "0.1.0"
4
+ end
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: []