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,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LibTmux
4
+ # IDs printed by the creation command itself, before later topology changes.
5
+ # A receipt proves creation, not continued ownership or current membership.
6
+ class CreationReceipt
7
+ attr_reader :entity, :window, :pane, :result
8
+
9
+ def initialize(entity:, window:, pane:, result:)
10
+ @entity, @window, @pane, @result = entity, window, pane, result
11
+ freeze
12
+ end
13
+ private_class_method :new
14
+
15
+ def inspect
16
+ "#<#{self.class} entity=#{entity.id} window=#{window.id} pane=#{pane.id}>"
17
+ end
18
+ end
19
+
20
+ # A target belongs to one open server binding; names never identify it.
21
+ class EntityRef
22
+ attr_reader :binding_key, :kind, :id, :session_id, :index
23
+
24
+ def initialize(binding_key:, kind:, id:, session_id: nil, index: nil)
25
+ prefix = {session: "$", window: "@", pane: "%", window_link: "@"}.fetch(kind)
26
+ unless id.is_a?(String) && id.match?(/\A#{Regexp.escape(prefix)}\d+\z/)
27
+ raise ProtocolError.new("tmux returned an invalid #{kind} ID", delivery: :observed, phase: :decode)
28
+ end
29
+ @binding_key = binding_key.dup.freeze
30
+ @kind = kind
31
+ @id = id.dup.freeze
32
+ if kind == :window_link
33
+ unless session_id.is_a?(String) && session_id.match?(/\A\$\d+\z/) && index.is_a?(Integer) && index >= 0
34
+ raise ProtocolError.new("tmux returned invalid window link context", delivery: :observed, phase: :decode)
35
+ end
36
+ @session_id = session_id.dup.freeze
37
+ @index = index
38
+ elsif session_id || index
39
+ raise ArgumentError, "only window links have session and index context"
40
+ end
41
+ freeze
42
+ end
43
+ private_class_method :new
44
+
45
+ def ==(other)
46
+ other.is_a?(EntityRef) && [binding_key, kind, id, session_id, index] ==
47
+ [other.binding_key, other.kind, other.id, other.session_id, other.index]
48
+ end
49
+ alias eql? ==
50
+
51
+ def hash
52
+ [binding_key, kind, id, session_id, index].hash
53
+ end
54
+
55
+ def inspect
56
+ "#<#{self.class} #{kind} #{id}>"
57
+ end
58
+ end
59
+
60
+ class Entity
61
+ attr_reader :server, :ref
62
+
63
+ def initialize(server, ref)
64
+ @server = server
65
+ @ref = ref
66
+ freeze
67
+ end
68
+ private_class_method :new
69
+
70
+ def id
71
+ ref.id
72
+ end
73
+
74
+ def snapshot(**options)
75
+ server.snapshot(**options).resolve(ref)
76
+ end
77
+
78
+ def ==(other)
79
+ other.is_a?(Entity) && ref == other.ref
80
+ end
81
+ alias eql? ==
82
+
83
+ def hash
84
+ ref.hash
85
+ end
86
+
87
+ def kill(timeout: 5.0, cancel: nil)
88
+ server.__send__(:execute_typed, ["kill-#{ref.kind}", "-t", target], timeout: timeout, cancel: cancel)
89
+ end
90
+
91
+ def inspect
92
+ "#<#{self.class} #{id}>"
93
+ end
94
+
95
+ private
96
+
97
+ def target
98
+ server.__send__(:target, ref, ref.kind)
99
+ end
100
+ end
101
+
102
+ class Session < Entity
103
+ def new_window(name:, command:, index: nil, cwd: nil, environment: {}, focus: false, receipt: false, timeout: 5.0, cancel: nil)
104
+ server.__send__(:create_window, ref, name: name, command: command, index: index,
105
+ cwd: cwd, environment: environment, focus: focus, receipt: receipt, timeout: timeout, cancel: cancel)
106
+ end
107
+
108
+ def list_windows(timeout: 5.0, cancel: nil)
109
+ server.__send__(:list_entities, :window, ["-t", target], timeout: timeout, cancel: cancel)
110
+ end
111
+
112
+ def list_panes(timeout: 5.0, cancel: nil)
113
+ server.__send__(:list_entities, :pane, ["-s", "-t", target], timeout: timeout, cancel: cancel)
114
+ end
115
+ end
116
+
117
+ class Window < Entity
118
+ LAYOUTS = %w[even-horizontal even-vertical main-horizontal main-vertical tiled].freeze
119
+ MIRRORED_LAYOUTS = %w[main-horizontal-mirrored main-vertical-mirrored].freeze
120
+ private_constant :LAYOUTS, :MIRRORED_LAYOUTS
121
+
122
+ def list_panes(timeout: 5.0, cancel: nil)
123
+ server.__send__(:list_entities, :pane, ["-t", target], timeout: timeout, cancel: cancel)
124
+ end
125
+
126
+ def split(direction:, command:, size: nil, cwd: nil, environment: {}, focus: false, timeout: 5.0, cancel: nil)
127
+ server.__send__(:split_window, ref, direction: direction, command: command, size: size,
128
+ cwd: cwd, environment: environment, focus: focus, timeout: timeout, cancel: cancel)
129
+ end
130
+
131
+ def select_layout(layout, timeout: 5.0, cancel: nil)
132
+ unless layout.is_a?(String) || layout.is_a?(Symbol)
133
+ raise ArgumentError, "layout must be a String or Symbol"
134
+ end
135
+ value = layout.is_a?(Symbol) ? layout.to_s.tr("_", "-") : layout
136
+ budget = server.__send__(:operation_budget, timeout, cancel)
137
+ # Older tmux can access an invalid pointer for a malformed layout header.
138
+ unless LAYOUTS.include?(value) || /\A[0-9a-fA-F]{4},/.match?(value)
139
+ choices = (LAYOUTS + MIRRORED_LAYOUTS).select { |name| name.start_with?(value) }
140
+ raise ArgumentError, "layout must be a native name or a saved layout" if value.empty? || choices.empty?
141
+
142
+ if choices.any? { |name| MIRRORED_LAYOUTS.include?(name) }
143
+ version = server.display('#{version}', **budget.options).text
144
+ release = /\A(\d+)\.(\d+)/.match(version)
145
+ raise ProtocolError.new("tmux version is invalid", phase: :decode, delivery: :observed) unless release
146
+
147
+ if ([release[1].to_i, release[2].to_i] <=> [3, 5]).negative?
148
+ choices &= LAYOUTS
149
+ if choices.empty?
150
+ raise UnsupportedFeatureError.new("mirrored layouts require tmux 3.5+", phase: :admission)
151
+ end
152
+ end
153
+ end
154
+ choices = [value] if choices.include?(value)
155
+ raise ArgumentError, "layout name is ambiguous" unless choices.length == 1
156
+
157
+ value = choices.first
158
+ end
159
+ server.__send__(:execute_typed, ["select-layout", "-t", target, "--", value], **budget.options)
160
+ end
161
+ end
162
+
163
+ class Pane < Entity
164
+ def split(direction:, command:, size: nil, cwd: nil, environment: {}, focus: false, timeout: 5.0, cancel: nil)
165
+ server.__send__(:split_window, ref, direction: direction, command: command, size: size,
166
+ cwd: cwd, environment: environment, focus: focus, timeout: timeout, cancel: cancel)
167
+ end
168
+
169
+ def capture(start: nil, finish: nil, join: false, escapes: false, escape_bytes: false, preserve_trailing: false,
170
+ trim_trailing: false, alternate: false, mode_screen: false, pending: false, timeout: 5.0, cancel: nil)
171
+ budget = server.__send__(:operation_budget, timeout, cancel)
172
+ if [alternate, mode_screen, pending].count { |value| value } > 1
173
+ raise ArgumentError, "alternate, mode_screen and pending captures are mutually exclusive"
174
+ end
175
+ arguments = ["capture-pane", "-p", "-t", target]
176
+ {"-S" => start, "-E" => finish}.each do |flag, value|
177
+ next if value.nil?
178
+ raise ArgumentError, "capture ranges must be integers or '-'" unless value.is_a?(Integer) || value == "-"
179
+
180
+ arguments.concat([flag, value.to_s])
181
+ end
182
+ {"T" => trim_trailing, "M" => mode_screen}.each do |flag, enabled|
183
+ next unless enabled
184
+
185
+ server.__send__(:require_command_flag, "capture-pane", flag, budget: budget)
186
+ arguments << "-#{flag}"
187
+ end
188
+ {"-J" => join, "-e" => escapes, "-C" => escape_bytes, "-N" => preserve_trailing,
189
+ "-a" => alternate, "-P" => pending}.each { |flag, enabled| arguments << flag if enabled }
190
+ server.__send__(:execute_typed, arguments, **budget.options)
191
+ end
192
+
193
+ def send_text(text, timeout: 5.0, cancel: nil)
194
+ raise ArgumentError, "text must be a String" unless text.is_a?(String)
195
+
196
+ server.__send__(:execute_typed, ["send-keys", "-l", "-t", target, "--", text], timeout: timeout, cancel: cancel)
197
+ end
198
+
199
+ def send_keys(*keys, timeout: 5.0, cancel: nil)
200
+ raise ArgumentError, "provide at least one key name" if keys.empty?
201
+
202
+ server.__send__(:execute_typed, ["send-keys", "-t", target, "--", *keys], timeout: timeout, cancel: cancel)
203
+ end
204
+ end
205
+
206
+ class WindowLink < Entity
207
+ def index
208
+ ref.index
209
+ end
210
+
211
+ def window
212
+ server.__send__(:build_entity, :window, id)
213
+ end
214
+
215
+ def session
216
+ server.__send__(:build_entity, :session, ref.session_id)
217
+ end
218
+
219
+ def select(timeout: 5.0, cancel: nil)
220
+ server.__send__(:execute_link, [ref], "select-window", ["-t", context], timeout: timeout, cancel: cancel)
221
+ end
222
+
223
+ def unlink(force: false, timeout: 5.0, cancel: nil)
224
+ server.__send__(:execute_link, [ref], "unlink-window", [*(force ? ["-k"] : []), "-t", context], timeout: timeout, cancel: cancel)
225
+ end
226
+
227
+ def kill(timeout: 5.0, cancel: nil)
228
+ server.__send__(:execute_link, [ref], "kill-window", ["-t", context], timeout: timeout, cancel: cancel)
229
+ end
230
+
231
+ def move(session:, index:, timeout: 5.0, cancel: nil)
232
+ raise ArgumentError, "index must be a nonnegative Integer" unless index.is_a?(Integer) && index >= 0
233
+
234
+ destination = server.__send__(:target, session, :session)
235
+ server.__send__(:execute_link, [ref], "move-window", ["-d", "-s", context, "-t", "#{destination}:#{index}"], timeout: timeout, cancel: cancel)
236
+ end
237
+
238
+ def swap(other, timeout: 5.0, cancel: nil)
239
+ server.__send__(:target, other, :window_link)
240
+ server.__send__(:execute_link, [ref, other], "swap-window",
241
+ ["-d", "-s", context, "-t", "#{other.session_id}:#{other.index}"], timeout: timeout, cancel: cancel)
242
+ end
243
+
244
+ def display(format, timeout: 5.0, cancel: nil)
245
+ server.__send__(:execute_link, [ref], "display-message", ["-p", "-t", context, "--", format], timeout: timeout, cancel: cancel)
246
+ end
247
+
248
+ def options
249
+ raise UnsupportedFeatureError.new("link-scoped options are not implemented; use an explicit window handle", phase: :admission)
250
+ end
251
+
252
+ def hooks
253
+ raise UnsupportedFeatureError.new("link-scoped hooks are not implemented; use an explicit window handle", phase: :admission)
254
+ end
255
+
256
+ private
257
+
258
+ def context
259
+ "#{ref.session_id}:#{ref.index}"
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LibTmux
4
+ class Error < StandardError
5
+ attr_reader :delivery, :phase, :pid, :cleanup_errors, :entity, :path, :expected
6
+
7
+ def initialize(message, delivery: :not_sent, phase: nil, pid: nil, cleanup_errors: [], entity: nil, path: nil, expected: nil)
8
+ super(message)
9
+ @delivery = delivery
10
+ @phase = phase
11
+ @pid = pid
12
+ @cleanup_errors = cleanup_errors.dup.freeze
13
+ @entity = entity
14
+ @path = path&.dup&.freeze
15
+ @expected = expected&.dup&.freeze
16
+ end
17
+
18
+ private
19
+
20
+ def attach_cleanup_errors(errors)
21
+ @cleanup_errors = (@cleanup_errors + errors).freeze
22
+ end
23
+ end
24
+
25
+ class InvalidFilterError < Error
26
+ def initialize(message = "invalid filter", entity: nil, path: "$", expected: nil, **details)
27
+ super("#{message} at #{path}#{expected ? "; expected #{expected}" : ""}",
28
+ entity: entity, path: path, expected: expected, **details)
29
+ end
30
+ end
31
+ class IncompleteSnapshotError < Error; end
32
+ class InconsistentSnapshotError < Error; end
33
+ class NoMatchError < Error; end
34
+ class MultipleMatchesError < Error; end
35
+ class TargetNotFoundError < Error; end
36
+ class FieldDecodeError < Error; end
37
+ class UnsupportedFeatureError < Error; end
38
+ class TransportError < Error; end
39
+ class ProtocolError < Error; end
40
+ class CapacityError < Error; end
41
+ class DeadlineExceeded < Error; end
42
+ class Cancelled < Error; end
43
+ class OutcomeUnknown < Error; end
44
+ class ClosedError < Error; end
45
+
46
+ class CommandError < Error
47
+ attr_reader :result
48
+
49
+ def initialize(message = "tmux command failed", result: nil, **details)
50
+ @result = result
51
+ super(message, **{delivery: result&.delivery || :not_sent, pid: result&.pid}.merge(details))
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "server"
4
+
5
+ module LibTmux
6
+ # Final client status cannot attribute a merged group result to individual steps.
7
+ class GroupResult
8
+ attr_reader :result, :steps
9
+
10
+ def initialize(result, count)
11
+ @result = result
12
+ @steps = Array.new(count) { |index| {index: index, outcome: :unknown}.freeze }.freeze
13
+ freeze
14
+ end
15
+ private_class_method :new
16
+
17
+ def success?
18
+ result.success?
19
+ end
20
+
21
+ def delivery
22
+ result.delivery
23
+ end
24
+
25
+ def inspect
26
+ "#<#{self.class} commands=#{steps.size} client_success=#{success?} delivery=#{delivery}>"
27
+ end
28
+ end
29
+
30
+ class Server
31
+ # One nontransactional tmux command group, with literal arguments per command.
32
+ def run_group(commands, input: "".b, timeout: 5.0, cancel: nil)
33
+ started = monotonic
34
+ unless timeout.is_a?(Numeric) && timeout.finite?
35
+ raise ArgumentError, "group timeout must be finite"
36
+ end
37
+ unless commands.is_a?(Array) && !commands.empty?
38
+ raise ArgumentError, "commands must be a nonempty Array of argument Arrays"
39
+ end
40
+ if commands.length > 128
41
+ raise CapacityError.new("command group exceeds 128 members", phase: :admission)
42
+ end
43
+ encoded = commands.map do |command|
44
+ validate_argv(command)
45
+ if command.first.start_with?("-")
46
+ raise ArgumentError, "group members cannot override endpoint flags"
47
+ end
48
+ encode_arguments(command)
49
+ end
50
+ argv = []
51
+ encoded.each_with_index do |command, index|
52
+ argv << ";" unless index.zero?
53
+ argv.concat(command)
54
+ end
55
+ result = run(argv, input: input, timeout: timeout - (monotonic - started), cancel: cancel)
56
+ GroupResult.__send__(:new, result, encoded.length)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module LibTmux
6
+ module Internal
7
+ module Metadata
8
+ module_function
9
+
10
+ def format(fields)
11
+ fields.map { |field| "\#{n:#{field}}:\#{q:#{field}}" }.join
12
+ end
13
+
14
+ # q doubles literal backslashes before tmux 3.5's VIS_NOSLASH output
15
+ # escaping. Decode that transport layer before using original byte lengths.
16
+ def unquote(value)
17
+ escapes = {"a" => "\a", "b" => "\b", "t" => "\t", "n" => "\n", "v" => "\v",
18
+ "f" => "\f", "r" => "\r", "s" => " ", "E" => "\e"}
19
+ value.b.gsub(/\\([0-7]{3}|.)/n) do
20
+ escaped = Regexp.last_match(1)
21
+ if escaped.match?(/\A[0-7]{3}\z/)
22
+ byte = escaped.to_i(8)
23
+ protocol_error("invalid metadata byte escape") if byte > 255
24
+ byte.chr(Encoding::BINARY)
25
+ else
26
+ escapes.fetch(escaped, escaped)
27
+ end
28
+ end
29
+ end
30
+
31
+ def decode(bytes, fields:, max_field_bytes: 1 << 20, max_bytes: 1 << 20, max_rows: 10_000, quoted: false)
32
+ unless bytes.is_a?(String) && fields.is_a?(Integer) && fields.between?(1, 64)
33
+ raise ArgumentError, "metadata requires String bytes and between 1 and 64 fields"
34
+ end
35
+ unless [max_field_bytes, max_bytes, max_rows].all? { |limit| limit.is_a?(Integer) && limit.positive? }
36
+ raise ArgumentError, "metadata limits must be positive integers"
37
+ end
38
+ capacity_error("metadata output exceeds its byte limit") if bytes.bytesize > max_bytes
39
+
40
+ bytes = quoted ? unquote(bytes) : bytes.b
41
+ rows = []
42
+ offset = 0
43
+ while offset < bytes.bytesize
44
+ capacity_error("metadata exceeds its row limit") if rows.length >= max_rows
45
+ row = Array.new(fields) do
46
+ length = 0
47
+ digits = 0
48
+ loop do
49
+ byte = bytes.getbyte(offset)
50
+ if byte == 58 && digits.positive?
51
+ offset += 1
52
+ break
53
+ end
54
+ unless byte && byte.between?(48, 57) && digits < 9
55
+ protocol_error("invalid metadata length prefix")
56
+ end
57
+ length = length * 10 + byte - 48
58
+ digits += 1
59
+ offset += 1
60
+ end
61
+ capacity_error("metadata field exceeds its byte limit") if length > max_field_bytes
62
+ protocol_error("truncated metadata field") if offset + length > bytes.bytesize
63
+ value = bytes.byteslice(offset, length).freeze
64
+ offset += length
65
+ value
66
+ end
67
+ protocol_error("missing metadata row terminator") unless bytes.getbyte(offset) == 10
68
+ offset += 1
69
+ rows << row.freeze
70
+ end
71
+ rows.freeze
72
+ end
73
+
74
+ def protocol_error(message)
75
+ raise ProtocolError.new(message, delivery: :observed, phase: :decode)
76
+ end
77
+ private_class_method :protocol_error
78
+
79
+ def capacity_error(message)
80
+ raise CapacityError.new(message, delivery: :observed, phase: :decode)
81
+ end
82
+ private_class_method :capacity_error
83
+ end
84
+ end
85
+ end