libtmux 0.1.0.alpha.1

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.
@@ -0,0 +1,412 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "options"
4
+
5
+ module LibTmux
6
+ class Server
7
+ def options(scope: :server)
8
+ Options.__send__(:new, self, scope: scope)
9
+ end
10
+
11
+ def hooks(scope: :session)
12
+ raise ArgumentError, "hooks use session or window scope" unless [:session, :window].include?(scope)
13
+
14
+ Hooks.__send__(:new, self, scope: scope)
15
+ end
16
+
17
+ # Formats are intentionally executable tmux formats, not literal text.
18
+ def display(format, target: nil, timeout: 5.0, cancel: nil)
19
+ return window_link(target).display(format, timeout: timeout, cancel: cancel) if target.is_a?(EntityRef) && target.kind == :window_link
20
+
21
+ args = ["display-message", "-p"]
22
+ if target
23
+ id = self.target(target, target.kind)
24
+ args.concat(["-t", id])
25
+ raise ArgumentError, "format must be a String" unless format.is_a?(String)
26
+
27
+ # display-message accepts a missing -t target; bind its returned context
28
+ # in this same command instead of accepting an empty fallback format.
29
+ format = id_format(target.kind) + format
30
+ end
31
+ result = execute_typed([*args, "--", format], timeout: timeout, cancel: cancel)
32
+ return result unless target
33
+
34
+ prefix = "#{id.bytesize}:#{id}"
35
+ unless result.stdout.start_with?(prefix)
36
+ raise TargetNotFoundError.new("display target no longer exists", phase: :command, delivery: :observed)
37
+ end
38
+ CommandResult.new(stdout: result.stdout.byteslice(prefix.bytesize..), stderr: result.stderr,
39
+ status: result.status, elapsed_seconds: result.elapsed_seconds, pid: result.pid, argv: result.argv)
40
+ end
41
+
42
+ def source_file(path, timeout: 5.0, cancel: nil)
43
+ execute_typed(["source-file", "--", path], timeout: timeout, cancel: cancel)
44
+ end
45
+
46
+ # Retiring the client cannot remove a dispatched waiter from tmux's queue.
47
+ def wait_for(channel, action: :wait, timeout: 5.0, cancel: nil)
48
+ flag = {wait: nil, signal: "-S", lock: "-L", unlock: "-U"}.fetch(action) do
49
+ raise ArgumentError, "action must be :wait, :signal, :lock or :unlock"
50
+ end
51
+ execute_typed(["wait-for", *[flag].compact, "--", channel], timeout: timeout, cancel: cancel)
52
+ end
53
+
54
+ def write_buffer(name:, data:, timeout: 5.0, cancel: nil)
55
+ execute_typed(["load-buffer", "-b", name, "--", "-"], input: data, timeout: timeout, cancel: cancel)
56
+ end
57
+
58
+ def read_buffer(name, timeout: 5.0, cancel: nil)
59
+ execute_typed(["save-buffer", "-b", name, "--", "-"], timeout: timeout, cancel: cancel)
60
+ end
61
+
62
+ def delete_buffer(name, timeout: 5.0, cancel: nil)
63
+ execute_typed(["delete-buffer", "-b", name], timeout: timeout, cancel: cancel)
64
+ end
65
+
66
+ def list_buffers(timeout: 5.0, cancel: nil)
67
+ result = execute_typed(["list-buffers", "-F", metadata_format(%w[buffer_name buffer_size])], timeout: timeout, cancel: cancel)
68
+ Internal::Metadata.decode(result.stdout, fields: 2, quoted: true).map do |name, size|
69
+ {name: name, size: decode_integer(size, "buffer size")}.freeze
70
+ end.freeze
71
+ end
72
+
73
+ def list_window_links(timeout: 5.0, cancel: nil)
74
+ acquire_links(["-a"], timeout: timeout, cancel: cancel)
75
+ end
76
+
77
+ def window_link(ref)
78
+ resolve(ref, :window_link)
79
+ end
80
+
81
+ def set_environment(name, value, hidden: false, timeout: 5.0, cancel: nil)
82
+ mutate_environment(["-g"], name, value, hidden: hidden, timeout: timeout, cancel: cancel)
83
+ end
84
+
85
+ def environment(name, hidden: false, timeout: 5.0, cancel: nil)
86
+ read_environment(["-g"], name, hidden: hidden, timeout: timeout, cancel: cancel)
87
+ end
88
+
89
+ def unset_environment(name, timeout: 5.0, cancel: nil)
90
+ execute_typed(["set-environment", "-g", "-u", "--", environment_name(name)], timeout: timeout, cancel: cancel)
91
+ end
92
+
93
+ def list_clients(timeout: 5.0, cancel: nil)
94
+ fields = %w[client_name client_pid client_created client_tty session_id client_control_mode]
95
+ result = execute_typed(["list-clients", "-F", metadata_format(fields)], timeout: timeout, cancel: cancel)
96
+ Internal::Metadata.decode(result.stdout, fields: fields.length, quoted: true).map do |name, pid, created, tty, session_id, control|
97
+ unless ["0", "1"].include?(control)
98
+ raise FieldDecodeError.new("client control mode is malformed", phase: :decode, delivery: :observed)
99
+ end
100
+ {name: name, pid: decode_integer(pid, "client PID"), created: decode_integer(created, "client creation time"),
101
+ tty: tty.empty? ? nil : tty, session_id: session_id.empty? ? nil : session_id, control: control == "1"}.freeze
102
+ end.freeze
103
+ end
104
+
105
+ private
106
+
107
+ # The format guard and nonwaiting mutation run within one tmux queue turn.
108
+ # Resolve configured aliases first; concurrent alias reconfiguration is not
109
+ # a supported coordination mechanism for these guarded operations.
110
+ def execute_link(refs, command, arguments, timeout: 5.0, cancel: nil, budget: nil)
111
+ budget ||= operation_budget(timeout, cancel)
112
+ refs.each { |ref| target(ref, :window_link) }
113
+ names = builtin_spellings("if-shell", "display-message", command, budget: budget)
114
+ marker = "libtmux-missing-#{SecureRandom.hex(16)}"
115
+ failure = tmux_command([names.fetch("display-message"), "-p", "--", marker])
116
+ body = tmux_command([names.fetch(command), *arguments])
117
+ refs.reverse_each do |ref|
118
+ guard = "\#{&&:\#{==:\#{session_id},#{ref.session_id}},\#{&&:\#{==:\#{window_index},#{ref.index}},\#{==:\#{window_id},#{ref.id}}}}"
119
+ body = tmux_command([names.fetch("if-shell"), "-F", "-t", "#{ref.session_id}:#{ref.index}", guard, body, failure])
120
+ end
121
+ # One command string is parsed by tmux; it never passes through a shell.
122
+ result = execute_typed([names.fetch("if-shell"), "-F", "1", body], **budget.options)
123
+ if result.stdout == "#{marker}\n"
124
+ raise TargetNotFoundError.new("window link was removed or replaced", phase: :command, delivery: :observed)
125
+ end
126
+ result
127
+ end
128
+
129
+ def builtin_spellings(*commands, budget:)
130
+ inventory = options(scope: :server).list(name: "command-alias", **budget.options)
131
+ unless !inventory.empty? && inventory.all? { |option| option.name == "command-alias" && option.array? }
132
+ raise UnsupportedFeatureError.new("tmux command alias inventory is unavailable", phase: :admission)
133
+ end
134
+ aliases = inventory.filter_map do |option|
135
+ option.raw.split("=", 2).first if option.present?
136
+ end
137
+ result = execute_typed(["list-commands", "-F", metadata_format(%w[command_list_name command_list_alias])], **budget.options)
138
+ catalog = Internal::Metadata.decode(result.stdout, fields: 2, quoted: true).to_h
139
+ commands.uniq.to_h do |name|
140
+ candidates = [name, catalog[name], *(1...name.length).map { |length| name[0, length] }.reverse].compact
141
+ spelling = candidates.find do |candidate|
142
+ next false if aliases.include?(candidate)
143
+
144
+ exact_alias = catalog.find { |_key, value| value == candidate }&.first
145
+ matching = exact_alias ? [exact_alias] : catalog.keys.select { |key| key.start_with?(candidate) }
146
+ matching == [name] || candidate == name
147
+ end
148
+ raise UnsupportedFeatureError.new("all builtin spellings of #{name} are aliased", phase: :admission) unless spelling
149
+
150
+ [name, spelling]
151
+ end
152
+ end
153
+
154
+ def kill_session_with_windows(ref, expected, expected_panes, timeout:, cancel:)
155
+ budget = operation_budget(timeout, cancel)
156
+ id = target(ref, :session)
157
+ unless expected.is_a?(Array) && expected.length <= 1024
158
+ raise ArgumentError, "expected_windows must be an Array of at most 1024 window references"
159
+ end
160
+ unless expected_panes.is_a?(Array) && expected_panes.length <= 4096
161
+ raise ArgumentError, "expected_panes must be an Array of at most 4096 pane references"
162
+ end
163
+ ids = expected.map { |window| target(window, :window) }.uniq
164
+ pane_ids = expected_panes.map { |pane| target(pane, :pane) }.uniq
165
+ names = builtin_spellings("if-shell", "display-message", "kill-session", budget: budget)
166
+ allowed = ids.empty? ? "0" : "\#{m/r:^(#{ids.join('|')})$,\#{window_id}}"
167
+ allowed_panes = pane_ids.empty? ? "0" : "\#{m/r:^(#{pane_ids.join('|')})$,\#{pane_id}}"
168
+ guard = "\#{&&:\#{==:\#{session_id},#{id}},\#{==:\#{W:\#{?#{allowed},,w}\#{P:\#{?#{allowed_panes},,p}}},}}"
169
+ marker = "libtmux-membership-#{SecureRandom.hex(16)}"
170
+ failure = tmux_command([names.fetch("display-message"), "-p", "--", marker])
171
+ body = tmux_command([names.fetch("kill-session"), "-t", id])
172
+ result = execute_typed([names.fetch("if-shell"), "-F", "-t", id, guard, body, failure], **budget.options)
173
+ if result.stdout == "#{marker}\n"
174
+ raise TargetNotFoundError.new("session contains windows or panes outside the expected ownership sets", phase: :command, delivery: :observed)
175
+ end
176
+ result
177
+ end
178
+
179
+ def tmux_command(arguments)
180
+ validate_argv(arguments)
181
+ arguments.map { |argument| "'#{argument.gsub("'") { %q('\\'') }}'" }.join(" ")
182
+ end
183
+
184
+ def metadata_format(fields)
185
+ Internal::Metadata.format(fields)
186
+ end
187
+
188
+ def decode_integer(value, label)
189
+ unless value.match?(/\A\d+\z/)
190
+ raise FieldDecodeError.new("#{label} is malformed", phase: :decode, delivery: :observed)
191
+ end
192
+ Integer(value, 10)
193
+ end
194
+
195
+ def require_command_flag(command, flag, budget:)
196
+ result = execute_typed(["list-commands", "-F", metadata_format(%w[command_list_name command_list_usage]), command], **budget.options)
197
+ commands = Internal::Metadata.decode(result.stdout, fields: 2, quoted: true).to_h
198
+ usage = commands.fetch(command) do
199
+ raise UnsupportedFeatureError.new("tmux does not advertise #{command}", phase: :admission)
200
+ end
201
+ unless usage.scan(/\[-([A-Za-z]+)(?:\]|\s)/).flatten.any? { |flags| flags.include?(flag) }
202
+ raise UnsupportedFeatureError.new("tmux does not advertise #{command} -#{flag}", phase: :admission)
203
+ end
204
+ end
205
+
206
+ def acquire_links(flags, timeout: 5.0, cancel: nil)
207
+ result = execute_typed(["list-windows", *flags, "-F", metadata_format(%w[session_id window_index window_id])], timeout: timeout, cancel: cancel)
208
+ Internal::Metadata.decode(result.stdout, fields: 3, quoted: true).map do |session_id, index, id|
209
+ ref = EntityRef.__send__(:new, binding_key: @pin.key, kind: :window_link, id: id,
210
+ session_id: session_id, index: decode_integer(index, "window index"))
211
+ WindowLink.__send__(:new, self, ref)
212
+ end.sort_by { |link| [link.ref.session_id[1..].to_i, link.index] }.freeze
213
+ end
214
+
215
+ def environment_name(name)
216
+ unless name.is_a?(String) && name.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
217
+ raise ArgumentError, "environment name must be a portable variable name"
218
+ end
219
+ name
220
+ end
221
+
222
+ def mutate_environment(flags, name, value, hidden:, timeout: 5.0, cancel: nil)
223
+ execute_typed(["set-environment", *flags, *(hidden ? ["-h"] : []), "--", environment_name(name), value], timeout: timeout, cancel: cancel)
224
+ end
225
+
226
+ def read_environment(flags, name, hidden:, timeout: 5.0, cancel: nil)
227
+ name = environment_name(name)
228
+ result = execute_typed(["show-environment", "-s", *flags, *(hidden ? ["-h"] : []), "--", name], timeout: timeout, cancel: cancel)
229
+ return nil if result.stdout == "unset #{name};\n"
230
+ prefix, suffix = "#{name}=\"", "\"; export #{name};\n"
231
+ unless result.stdout.start_with?(prefix) && result.stdout.end_with?(suffix)
232
+ raise ProtocolError.new("tmux returned an unexpected environment record", phase: :decode, delivery: :observed)
233
+ end
234
+ Internal::Metadata.unquote(result.stdout.byteslice(prefix.bytesize, result.stdout.bytesize - prefix.bytesize - suffix.bytesize)).freeze
235
+ end
236
+ end
237
+
238
+ class Entity
239
+ def options
240
+ Options.__send__(:new, server, ref: ref)
241
+ end
242
+
243
+ def hooks
244
+ Hooks.__send__(:new, server, ref: ref)
245
+ end
246
+
247
+ def display(format, timeout: 5.0, cancel: nil)
248
+ server.display(format, target: ref, timeout: timeout, cancel: cancel)
249
+ end
250
+ end
251
+
252
+ class Session
253
+ # Both allowlists are required for guarded deletion of a changing topology.
254
+ def kill(expected_windows: nil, expected_panes: nil, timeout: 5.0, cancel: nil)
255
+ return super(timeout: timeout, cancel: cancel) if expected_windows.nil? && expected_panes.nil?
256
+
257
+ server.__send__(:kill_session_with_windows, ref, expected_windows, expected_panes, timeout: timeout, cancel: cancel)
258
+ end
259
+
260
+ def rename(name, timeout: 5.0, cancel: nil)
261
+ server.__send__(:execute_typed, ["rename-session", "-t", target, "--", server.__send__(:literal_name, name)], timeout: timeout, cancel: cancel)
262
+ end
263
+
264
+ def list_window_links(timeout: 5.0, cancel: nil)
265
+ server.__send__(:acquire_links, ["-t", target], timeout: timeout, cancel: cancel)
266
+ end
267
+
268
+ def link_window(window, index:, timeout: 5.0, cancel: nil)
269
+ raise ArgumentError, "index must be a nonnegative Integer" unless index.is_a?(Integer) && index >= 0
270
+
271
+ source = server.__send__(:target, window, :window)
272
+ server.__send__(:execute_typed, ["link-window", "-d", "-s", source, "-t", "#{target}:#{index}"], timeout: timeout, cancel: cancel)
273
+ end
274
+
275
+ def set_environment(name, value, hidden: false, timeout: 5.0, cancel: nil)
276
+ server.__send__(:mutate_environment, ["-t", target], name, value, hidden: hidden, timeout: timeout, cancel: cancel)
277
+ end
278
+
279
+ def environment(name, hidden: false, timeout: 5.0, cancel: nil)
280
+ server.__send__(:read_environment, ["-t", target], name, hidden: hidden, timeout: timeout, cancel: cancel)
281
+ end
282
+
283
+ def unset_environment(name, timeout: 5.0, cancel: nil)
284
+ server.__send__(:execute_typed, ["set-environment", "-t", target, "-u", "--", server.__send__(:environment_name, name)], timeout: timeout, cancel: cancel)
285
+ end
286
+ end
287
+
288
+ class Window
289
+ def rename(name, timeout: 5.0, cancel: nil)
290
+ server.__send__(:execute_typed, ["rename-window", "-t", target, "--", server.__send__(:literal_name, name)], timeout: timeout, cancel: cancel)
291
+ end
292
+
293
+ def respawn(command:, kill: false, cwd: nil, environment: {}, timeout: 5.0, cancel: nil)
294
+ budget = server.__send__(:operation_budget, timeout, cancel)
295
+ args = ["respawn-window", "-t", target, *(kill ? ["-k"] : [])]
296
+ args.concat(server.__send__(:creation_options, cwd: cwd, environment: environment))
297
+ server.__send__(:execute_typed, [*args, "--", *server.__send__(:pane_command, command)], **budget.options)
298
+ end
299
+
300
+ def resize(width: nil, height: nil, timeout: 5.0, cancel: nil)
301
+ dimensions = {"-x" => width, "-y" => height}.flat_map do |flag, value|
302
+ next [] if value.nil?
303
+ raise ArgumentError, "dimensions must be positive Integers" unless value.is_a?(Integer) && value.positive?
304
+
305
+ [flag, value.to_s]
306
+ end
307
+ raise ArgumentError, "provide width or height" if dimensions.empty?
308
+
309
+ server.__send__(:execute_typed, ["resize-window", "-t", target, *dimensions], timeout: timeout, cancel: cancel)
310
+ end
311
+ end
312
+
313
+ class Pane
314
+ def select(timeout: 5.0, cancel: nil)
315
+ server.__send__(:execute_typed, ["select-pane", "-t", target], timeout: timeout, cancel: cancel)
316
+ end
317
+
318
+ def resize(width: nil, height: nil, direction: nil, amount: 1, zoom: false, timeout: 5.0, cancel: nil)
319
+ args = ["resize-pane", "-t", target]
320
+ {"-x" => width, "-y" => height}.each do |flag, value|
321
+ next if value.nil?
322
+ raise ArgumentError, "dimensions must be positive Integers" unless value.is_a?(Integer) && value.positive?
323
+
324
+ args.concat([flag, value.to_s])
325
+ end
326
+ if direction
327
+ flag = {left: "-L", right: "-R", up: "-U", down: "-D"}.fetch(direction) { raise ArgumentError, "invalid resize direction" }
328
+ raise ArgumentError, "amount must be a positive Integer" unless amount.is_a?(Integer) && amount.positive?
329
+
330
+ args.concat([flag, amount.to_s])
331
+ end
332
+ args << "-Z" if zoom
333
+ server.__send__(:execute_typed, args, timeout: timeout, cancel: cancel)
334
+ end
335
+
336
+ def swap(other, timeout: 5.0, cancel: nil)
337
+ server.__send__(:execute_typed, ["swap-pane", "-d", "-s", target, "-t", server.__send__(:target, other, :pane)], timeout: timeout, cancel: cancel)
338
+ end
339
+
340
+ def join(other, direction:, before: false, timeout: 5.0, cancel: nil)
341
+ reposition("join-pane", other, direction: direction, before: before, timeout: timeout, cancel: cancel)
342
+ end
343
+
344
+ def move(other, direction:, before: false, timeout: 5.0, cancel: nil)
345
+ reposition("move-pane", other, direction: direction, before: before, timeout: timeout, cancel: cancel)
346
+ end
347
+
348
+ def break_out(session:, name:, timeout: 5.0, cancel: nil)
349
+ destination = server.__send__(:target, session, :session)
350
+ server.__send__(:create_entity, :window, ["break-pane", "-d", "-P", "-F", server.__send__(:id_format, :window),
351
+ "-s", target, "-t", "#{destination}:", "-n", server.__send__(:literal_name, name)], timeout: timeout, cancel: cancel)
352
+ end
353
+
354
+ def respawn(command:, kill: false, cwd: nil, environment: {}, timeout: 5.0, cancel: nil)
355
+ budget = server.__send__(:operation_budget, timeout, cancel)
356
+ args = ["respawn-pane", "-t", target, *(kill ? ["-k"] : [])]
357
+ args.concat(server.__send__(:creation_options, cwd: cwd, environment: environment))
358
+ server.__send__(:execute_typed, [*args, "--", *server.__send__(:pane_command, command)], **budget.options)
359
+ end
360
+
361
+ def paste(buffer:, delete: false, bracketed: false, separator: nil, timeout: 5.0, cancel: nil)
362
+ args = ["paste-buffer", "-t", target, "-b", buffer]
363
+ args << "-d" if delete
364
+ args << "-p" if bracketed
365
+ args.concat(["-s", separator]) if separator
366
+ server.__send__(:execute_typed, args, timeout: timeout, cancel: cancel)
367
+ end
368
+
369
+ # The shell command and its format expansion are explicitly requested here.
370
+ # only_if_closed follows tmux -o: an existing pipe is closed, not retained.
371
+ def pipe(shell_command: nil, input: false, output: true, only_if_closed: false, timeout: 5.0, cancel: nil)
372
+ if shell_command && !input && !output
373
+ raise ArgumentError, "a pipe command requires input or output"
374
+ end
375
+ args = ["pipe-pane", "-t", target]
376
+ args << "-I" if input
377
+ args << "-O" if output
378
+ args << "-o" if only_if_closed
379
+ args.concat(["--", shell_command]) if shell_command
380
+ server.__send__(:execute_typed, args, timeout: timeout, cancel: cancel)
381
+ end
382
+
383
+ def copy_mode(scroll_up: false, exit_on_bottom: false, mouse_drag: false, cancel_mode: false, page_down: false, source: nil, timeout: 5.0, cancel: nil)
384
+ budget = server.__send__(:operation_budget, timeout, cancel)
385
+ args = ["copy-mode", "-t", target]
386
+ {"u" => scroll_up, "e" => exit_on_bottom, "M" => mouse_drag, "q" => cancel_mode, "d" => page_down}.each do |flag, enabled|
387
+ next unless enabled
388
+
389
+ server.__send__(:require_command_flag, "copy-mode", flag, budget: budget)
390
+ args << "-#{flag}"
391
+ end
392
+ if source
393
+ server.__send__(:require_command_flag, "copy-mode", "s", budget: budget)
394
+ args.concat(["-s", server.__send__(:target, source, :pane)])
395
+ end
396
+ server.__send__(:execute_typed, args, **budget.options)
397
+ end
398
+
399
+ def copy_command(command, *arguments, timeout: 5.0, cancel: nil)
400
+ server.__send__(:execute_typed, ["send-keys", "-X", "-t", target, "--", command, *arguments], timeout: timeout, cancel: cancel)
401
+ end
402
+
403
+ private
404
+
405
+ def reposition(command, other, direction:, before:, timeout:, cancel:)
406
+ flag = {horizontal: "-h", vertical: "-v"}.fetch(direction) { raise ArgumentError, "invalid split direction" }
407
+ args = [command, "-d", flag, "-s", target, "-t", server.__send__(:target, other, :pane)]
408
+ args << "-b" if before
409
+ server.__send__(:execute_typed, args, timeout: timeout, cancel: cancel)
410
+ end
411
+ end
412
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LibTmux
4
+ # Raw bytes remain available even when a caller requests a typed conversion.
5
+ class OptionValue
6
+ attr_reader :name, :index, :raw
7
+
8
+ def initialize(name:, index:, raw:, inherited:, array:, present:)
9
+ @name, @raw = name.dup.freeze, raw.dup.freeze
10
+ @index, @inherited, @array, @present = index, inherited, array, present
11
+ freeze
12
+ end
13
+ private_class_method :new
14
+
15
+ def inherited?
16
+ @inherited
17
+ end
18
+
19
+ def array?
20
+ @array
21
+ end
22
+
23
+ def present?
24
+ @present
25
+ end
26
+
27
+ def as(type)
28
+ case type
29
+ when :bytes then raw
30
+ when :string
31
+ text = raw.dup.force_encoding(Encoding::UTF_8)
32
+ raise FieldDecodeError.new("option is not valid UTF-8", phase: :decode, delivery: :observed) unless text.valid_encoding?
33
+
34
+ text.freeze
35
+ when :integer
36
+ raise FieldDecodeError.new("option is not an integer", phase: :decode, delivery: :observed) unless raw.match?(/\A-?\d+\z/)
37
+
38
+ Integer(raw, 10)
39
+ when :boolean
40
+ return true if ["on", "1"].include?(raw)
41
+ return false if ["off", "0"].include?(raw)
42
+
43
+ raise FieldDecodeError.new("option is not a boolean", phase: :decode, delivery: :observed)
44
+ else raise ArgumentError, "type must be :bytes, :string, :integer or :boolean"
45
+ end
46
+ end
47
+ end
48
+
49
+ # Explicit acquisition and mutation at one option scope.
50
+ class Options
51
+ def initialize(server, ref: nil, scope: nil)
52
+ @server, @ref, @scope = server, ref, scope
53
+ freeze
54
+ end
55
+ private_class_method :new
56
+
57
+ def list(name: nil, inherited: false, timeout: 5.0, cancel: nil)
58
+ if inherited && is_a?(Hooks)
59
+ raise UnsupportedFeatureError.new("inherited hook acquisition is not implemented", phase: :admission)
60
+ end
61
+ command = is_a?(Hooks) ? "show-hooks" : "show-options"
62
+ args = [command, *scope_flags]
63
+ args << "-A" if inherited && !is_a?(Hooks)
64
+ args.concat(["--", option_name(name)]) if name
65
+ result = @server.__send__(:execute_typed, args, timeout: timeout, cancel: cancel)
66
+ result.stdout.lines.map do |line|
67
+ match = /\A([^\s\[\]*]+)(?:\[(\d+)\])?(\*)?(?: (.*))?\n\z/n.match(line)
68
+ raise ProtocolError.new("malformed escaped option record", phase: :decode, delivery: :observed) unless match
69
+
70
+ value = match[4]
71
+ raw = is_a?(Hooks) ? (value || "".b) : decode_escaped(value || "".b)
72
+ OptionValue.__send__(:new, name: match[1], index: match[2] && Integer(match[2], 10), raw: raw,
73
+ inherited: !match[3].nil?, array: !match[2].nil? || value.nil?, present: !value.nil?)
74
+ end.freeze
75
+ end
76
+
77
+ def get(name, index: nil, inherited: true, timeout: 5.0, cancel: nil)
78
+ unless index.nil? || (index.is_a?(Integer) && index >= 0)
79
+ raise ArgumentError, "index must be a nonnegative Integer"
80
+ end
81
+ # tmux prints a nonexistent requested index as an empty string. Acquire
82
+ # the array once so absent indexes remain distinct from present empties.
83
+ values = list(name: name, inherited: inherited, timeout: timeout, cancel: cancel)
84
+ values = values.select { |value| value.index == index } unless index.nil?
85
+ if values.empty?
86
+ raise NoMatchError.new("option is absent at the requested scope", phase: :decode, delivery: :observed)
87
+ end
88
+ unless values.length == 1
89
+ raise MultipleMatchesError.new("option has multiple array entries; provide an index", phase: :decode, delivery: :observed)
90
+ end
91
+
92
+ values.first
93
+ end
94
+
95
+ def set(name, value, index: nil, append: false, timeout: 5.0, cancel: nil)
96
+ value = case value
97
+ when true then "on"
98
+ when false then "off"
99
+ when Integer then value.to_s
100
+ when String then value
101
+ else raise ArgumentError, "option value must be String, Integer or boolean"
102
+ end
103
+ mutate("set-option", name, value, index: index, append: append, timeout: timeout, cancel: cancel)
104
+ end
105
+
106
+ def unset(name, index: nil, timeout: 5.0, cancel: nil)
107
+ command = is_a?(Hooks) ? "set-hook" : "set-option"
108
+ @server.__send__(:execute_typed, [command, *scope_flags, "-u", "--", option_name(indexed_name(name, index))], timeout: timeout, cancel: cancel)
109
+ end
110
+
111
+ private
112
+
113
+ def scope_flags
114
+ if @ref
115
+ flags = {session: [], window: ["-w"], pane: ["-p"]}.fetch(@ref.kind)
116
+ [*flags, "-t", @server.__send__(:target, @ref, @ref.kind)]
117
+ else
118
+ {server: ["-s"], session: ["-g"], window: ["-g", "-w"]}.fetch(@scope) do
119
+ raise ArgumentError, "scope must be :server, :session or :window"
120
+ end
121
+ end
122
+ end
123
+
124
+ def option_name(name)
125
+ unless name.is_a?(String) && name.match?(/\A[^\s\0]+\z/)
126
+ raise ArgumentError, "option names cannot contain whitespace or NUL"
127
+ end
128
+ @server.__send__(:literal_name, name)
129
+ end
130
+
131
+ def indexed_name(name, index)
132
+ return name if index.nil?
133
+ raise ArgumentError, "index must be a nonnegative Integer" unless index.is_a?(Integer) && index >= 0
134
+
135
+ "#{name}[#{index}]"
136
+ end
137
+
138
+ def mutate(command, name, value, index:, append:, timeout:, cancel:)
139
+ args = [command, *scope_flags]
140
+ args << "-a" if append
141
+ @server.__send__(:execute_typed, [*args, "--", option_name(indexed_name(name, index)), value], timeout: timeout, cancel: cancel)
142
+ end
143
+
144
+ # show-options uses args_escape (VIS_OCTAL|VIS_CSTYLE), not shell escaping.
145
+ def decode_escaped(value)
146
+ if value.start_with?("'", '"')
147
+ unless value.end_with?(value.byteslice(0, 1)) && value.bytesize >= 2
148
+ raise ProtocolError.new("unterminated escaped option value", phase: :decode, delivery: :observed)
149
+ end
150
+ value = value.byteslice(1, value.bytesize - 2)
151
+ end
152
+ escapes = {"a" => "\a", "b" => "\b", "t" => "\t", "n" => "\n", "v" => "\v", "f" => "\f", "r" => "\r", "s" => " ", "E" => "\e"}
153
+ value.gsub(/\\([0-7]{3}|.)/n) do
154
+ escaped = Regexp.last_match(1)
155
+ if escaped.match?(/\A[0-7]{3}\z/)
156
+ escaped.to_i(8).chr(Encoding::BINARY)
157
+ else
158
+ escapes.fetch(escaped, escaped)
159
+ end
160
+ end
161
+ end
162
+ end
163
+
164
+ # Hook values are executable tmux command strings, never literal data.
165
+ class Hooks < Options
166
+ def get(name, index: nil, inherited: false, timeout: 5.0, cancel: nil)
167
+ super
168
+ end
169
+
170
+ def set(name, command:, index: nil, append: false, timeout: 5.0, cancel: nil)
171
+ raise ArgumentError, "command must be a String" unless command.is_a?(String)
172
+
173
+ mutate("set-hook", name, command, index: index, append: append, timeout: timeout, cancel: cancel)
174
+ end
175
+
176
+ def run(name, timeout: 5.0, cancel: nil)
177
+ @server.__send__(:execute_typed, ["set-hook", *scope_flags, "-R", "--", option_name(name)], timeout: timeout, cancel: cancel)
178
+ end
179
+ end
180
+ end