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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +109 -0
- data/lib/libtmux/capture.rb +146 -0
- data/lib/libtmux/catalog.rb +116 -0
- data/lib/libtmux/child.rb +156 -0
- data/lib/libtmux/control.rb +885 -0
- data/lib/libtmux/criteria.rb +410 -0
- data/lib/libtmux/endpoint.rb +213 -0
- data/lib/libtmux/entity.rb +262 -0
- data/lib/libtmux/errors.rb +54 -0
- data/lib/libtmux/group.rb +59 -0
- data/lib/libtmux/metadata.rb +85 -0
- data/lib/libtmux/operations.rb +412 -0
- data/lib/libtmux/options.rb +180 -0
- data/lib/libtmux/owned.rb +252 -0
- data/lib/libtmux/process.rb +327 -0
- data/lib/libtmux/process_wait.rb +66 -0
- data/lib/libtmux/selection.rb +95 -0
- data/lib/libtmux/server.rb +534 -0
- data/lib/libtmux/snapshot.rb +428 -0
- data/lib/libtmux/socket_readiness.rb +232 -0
- data/lib/libtmux/source_query.rb +57 -0
- data/lib/libtmux/terminal.rb +197 -0
- data/lib/libtmux/version.rb +5 -0
- data/lib/libtmux.rb +18 -0
- data/schema/where-v1.json +2291 -0
- data/sig/control.rbs +75 -0
- data/sig/criteria.rbs +36 -0
- data/sig/fields.rbs +72 -0
- data/sig/group.rbs +14 -0
- data/sig/libtmux.rbs +186 -0
- data/sig/operations.rbs +91 -0
- data/sig/owned.rbs +7 -0
- data/sig/snapshot.rbs +70 -0
- data/sig/terminal.rbs +15 -0
- metadata +145 -0
|
@@ -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
|