nnq-cli 0.2.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.
@@ -0,0 +1,485 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module NNQ
6
+ module CLI
7
+ # Parses and validates command-line arguments for the nnq CLI.
8
+ #
9
+ class CliParser
10
+ EXAMPLES = <<~'TEXT'
11
+ -- Request / Reply ------------------------------------------
12
+
13
+ +-----+ "hello" +-----+
14
+ | REQ |------------->| REP |
15
+ | |<-------------| |
16
+ +-----+ "HELLO" +-----+
17
+
18
+ # terminal 1: echo server
19
+ nnq rep --bind tcp://:5555 --recv-eval '$_.upcase'
20
+
21
+ # terminal 2: send a request
22
+ echo "hello" | nnq req --connect tcp://localhost:5555
23
+
24
+ # or over IPC (unix socket, single machine)
25
+ nnq rep --bind ipc:///tmp/echo.sock --echo &
26
+ echo "hello" | nnq req --connect ipc:///tmp/echo.sock
27
+
28
+ -- Publish / Subscribe --------------------------------------
29
+
30
+ +-----+ "weather.nyc 72F" +-----+
31
+ | PUB |--------------------->| SUB | --subscribe "weather."
32
+ +-----+ +-----+
33
+
34
+ # terminal 1: subscriber (all topics by default)
35
+ nnq sub --bind tcp://:5556
36
+
37
+ # terminal 2: publisher (needs --delay for subscription to propagate)
38
+ echo "weather.nyc 72F" | nnq pub --connect tcp://localhost:5556 --delay 1
39
+
40
+ -- Periodic Publish -------------------------------------------
41
+
42
+ +-----+ "tick 1" +-----+
43
+ | PUB |--(every 1s)-->| SUB |
44
+ +-----+ +-----+
45
+
46
+ # terminal 1: subscriber
47
+ nnq sub --bind tcp://:5556
48
+
49
+ # terminal 2: publish a tick every second (wall-clock aligned)
50
+ nnq pub --connect tcp://localhost:5556 --delay 1 \
51
+ --data "tick" --interval 1
52
+
53
+ # 5 ticks, then exit
54
+ nnq pub --connect tcp://localhost:5556 --delay 1 \
55
+ --data "tick" --interval 1 --count 5
56
+
57
+ -- Pipeline -------------------------------------------------
58
+
59
+ +------+ +------+
60
+ | PUSH |----------->| PULL |
61
+ +------+ +------+
62
+
63
+ # terminal 1: worker
64
+ nnq pull --bind tcp://:5557
65
+
66
+ # terminal 2: send tasks
67
+ echo "task 1" | nnq push --connect tcp://localhost:5557
68
+
69
+ # or over IPC (unix socket)
70
+ nnq pull --bind ipc:///tmp/pipeline.sock &
71
+ echo "task 1" | nnq push --connect ipc:///tmp/pipeline.sock
72
+
73
+ -- Pipe (PULL -> eval -> PUSH) --------------------------------
74
+
75
+ +------+ +------+ +------+
76
+ | PUSH |--------->| pipe |--------->| PULL |
77
+ +------+ +------+ +------+
78
+
79
+ # terminal 1: producer
80
+ echo -e "hello\nworld" | nnq push --bind ipc://@work
81
+
82
+ # terminal 2: worker -- uppercase each message
83
+ nnq pipe -c ipc://@work -c ipc://@sink -e '$_.upcase'
84
+ # terminal 3: collector
85
+ nnq pull --bind ipc://@sink
86
+
87
+ # 4 Ractor workers in a single process (-P)
88
+ nnq pipe -c ipc://@work -c ipc://@sink -P4 -r./fib -e 'fib(Integer($_)).to_s'
89
+
90
+ # exit when producer disconnects (--transient)
91
+ nnq pipe -c ipc://@work -c ipc://@sink --transient -e '$_.upcase'
92
+
93
+ # fan-in: multiple sources -> one sink
94
+ nnq pipe --in -c ipc://@work1 -c ipc://@work2 \
95
+ --out -c ipc://@sink -e '$_.upcase'
96
+
97
+ # fan-out: one source -> multiple sinks (round-robin)
98
+ nnq pipe --in -b tcp://:5555 --out -c ipc://@sink1 -c ipc://@sink2 -e '$_'
99
+
100
+ -- Formats --------------------------------------------------
101
+
102
+ # ascii (default) -- non-printable replaced with dots
103
+ nnq pull --bind tcp://:5557 --ascii
104
+
105
+ # quoted -- lossless, round-trippable (uses String#dump escaping)
106
+ nnq pull --bind tcp://:5557 --quoted
107
+
108
+ # JSON Lines -- single-element arrays (nnq is single-body)
109
+ echo '["payload"]' | nnq push --connect tcp://localhost:5557 --jsonl
110
+ nnq pull --bind tcp://:5557 --jsonl
111
+
112
+ # raw -- emit the body verbatim (no framing, no newline)
113
+ nnq pull --bind tcp://:5557 --raw
114
+
115
+ -- Compression ----------------------------------------------
116
+
117
+ # both sides must use --compress
118
+ nnq pull --bind tcp://:5557 --compress &
119
+ echo "compressible data" | nnq push --connect tcp://localhost:5557 --compress
120
+
121
+ -- Ruby Eval ------------------------------------------------
122
+
123
+ # filter incoming: only pass messages containing "error"
124
+ nnq pull -b tcp://:5557 --recv-eval '$_.include?("error") ? $_ : nil'
125
+
126
+ # transform incoming with gems
127
+ nnq sub -c tcp://localhost:5556 -rjson -e 'JSON.parse($_)["temperature"]'
128
+
129
+ # require a local file, use its methods
130
+ nnq rep --bind tcp://:5555 --require ./transform.rb -e 'upcase($_)'
131
+
132
+ # next skips, break stops -- regexps match against $_
133
+ nnq pull -b tcp://:5557 -e 'next if /^#/; break if /quit/; $_'
134
+
135
+ # BEGIN/END blocks (like awk) -- accumulate and summarize
136
+ nnq pull -b tcp://:5557 -e 'BEGIN{@sum = 0} @sum += Integer($_); nil END{puts @sum}'
137
+
138
+ # transform outgoing messages
139
+ echo hello | nnq push -c tcp://localhost:5557 --send-eval '$_.upcase'
140
+
141
+ # REQ: transform request and reply independently
142
+ echo hello | nnq req -c tcp://localhost:5555 -E '$_.upcase' -e '$_'
143
+
144
+ -- Script Handlers (-r) ------------------------------------
145
+
146
+ # handler.rb -- register transforms from a file
147
+ # db = PG.connect("dbname=app")
148
+ # NNQ.incoming { |msg| db.exec(msg).values.flatten.first }
149
+ # at_exit { db.close }
150
+ nnq pull --bind tcp://:5557 -r./handler.rb
151
+
152
+ # combine script handlers with inline eval
153
+ nnq req -c tcp://localhost:5555 -r./handler.rb -E '$_.upcase'
154
+
155
+ # NNQ.outgoing { |msg| ... } -- registered outgoing transform
156
+ # NNQ.incoming { |msg| ... } -- registered incoming transform
157
+ # CLI flags (-e/-E) override registered handlers
158
+ TEXT
159
+
160
+
161
+ DEFAULT_OPTS = {
162
+ type_name: nil,
163
+ endpoints: [],
164
+ connects: [],
165
+ binds: [],
166
+ in_endpoints: [],
167
+ out_endpoints: [],
168
+ data: nil,
169
+ file: nil,
170
+ format: :ascii,
171
+ subscribes: [],
172
+ interval: nil,
173
+ count: nil,
174
+ delay: nil,
175
+ timeout: nil,
176
+ linger: 5,
177
+ reconnect_ivl: nil,
178
+ send_hwm: nil,
179
+ sndbuf: nil,
180
+ rcvbuf: nil,
181
+ compress: false,
182
+ compress_in: false,
183
+ compress_out: false,
184
+ send_expr: nil,
185
+ recv_expr: nil,
186
+ parallel: nil,
187
+ transient: false,
188
+ verbose: 0,
189
+ quiet: false,
190
+ echo: false,
191
+ scripts: [],
192
+ recv_maxsz: nil,
193
+ }.freeze
194
+
195
+
196
+ # Parses +argv+ and returns a mutable options hash.
197
+ #
198
+ def self.parse(argv)
199
+ new.parse(argv)
200
+ end
201
+
202
+
203
+ # Validates option combinations, aborting on bad combos.
204
+ #
205
+ def self.validate!(opts)
206
+ new.validate!(opts)
207
+ end
208
+
209
+
210
+ # Validates option combinations that depend on socket type.
211
+ #
212
+ def self.validate_gems!(config)
213
+ if config.recv_only? && (config.data || config.file)
214
+ abort "--data/--file not valid for #{config.type_name} (receive-only)"
215
+ end
216
+ end
217
+
218
+
219
+ # Parses +argv+ and returns a mutable options hash.
220
+ #
221
+ # @param argv [Array<String>] command-line arguments (mutated in place)
222
+ # @return [Hash] parsed options
223
+ def parse(argv)
224
+ opts = DEFAULT_OPTS.transform_values { |v| v.is_a?(Array) ? v.dup : v }
225
+ pipe_side = nil # nil = legacy positional mode; :in/:out = modal
226
+
227
+ parser = OptionParser.new do |o|
228
+ o.banner = "Usage: nnq TYPE [options]\n\n" \
229
+ "Types: req, rep, pub, sub, push, pull, pair\n" \
230
+ "Virtual: pipe (PULL -> eval -> PUSH)\n\n"
231
+
232
+ o.separator "Connection:"
233
+ o.on("-c", "--connect URL", "Connect to endpoint (repeatable)") { |v|
234
+ v = expand_endpoint(v)
235
+ ep = Endpoint.new(v, false)
236
+ case pipe_side
237
+ when :in
238
+ opts[:in_endpoints] << ep
239
+ when :out
240
+ opts[:out_endpoints] << ep
241
+ else
242
+ opts[:endpoints] << ep
243
+ opts[:connects] << v
244
+ end
245
+ }
246
+ o.on("-b", "--bind URL", "Bind to endpoint (repeatable)") { |v|
247
+ v = expand_endpoint(v)
248
+ ep = Endpoint.new(v, true)
249
+ case pipe_side
250
+ when :in
251
+ opts[:in_endpoints] << ep
252
+ when :out
253
+ opts[:out_endpoints] << ep
254
+ else
255
+ opts[:endpoints] << ep
256
+ opts[:binds] << v
257
+ end
258
+ }
259
+ o.on("--in", "Pipe: subsequent -b/-c attach to input (PULL) side") { pipe_side = :in }
260
+ o.on("--out", "Pipe: subsequent -b/-c attach to output (PUSH) side") { pipe_side = :out }
261
+
262
+ o.separator "\nData source (REP: reply source):"
263
+ o.on( "--echo", "Echo received messages back (REP)") { opts[:echo] = true }
264
+ o.on("-D", "--data DATA", "Message data (literal string)") { |v| opts[:data] = v }
265
+ o.on("-F", "--file FILE", "Read message from file (- = stdin)") { |v| opts[:file] = v }
266
+
267
+ o.separator "\nFormat (input + output):"
268
+ o.on("-A", "--ascii", "Safe ASCII, non-printable as dots (default)") { opts[:format] = :ascii }
269
+ o.on("-Q", "--quoted", "C-style quoted with escapes") { opts[:format] = :quoted }
270
+ o.on( "--raw", "Raw binary body, no framing, no newline") { opts[:format] = :raw }
271
+ o.on("-J", "--jsonl", "JSON Lines (single-element array per line)") { opts[:format] = :jsonl }
272
+ o.on( "--msgpack", "MessagePack (binary stream)") { require "msgpack"; opts[:format] = :msgpack }
273
+ o.on("-M", "--marshal", "Ruby Marshal stream (binary)") { opts[:format] = :marshal }
274
+
275
+ o.separator "\nSubscription:"
276
+ o.on("-s", "--subscribe PREFIX", "Subscribe prefix (SUB, default all)") { |v| opts[:subscribes] << v }
277
+
278
+ o.separator "\nTiming:"
279
+ o.on("-i", "--interval SECS", Float, "Repeat interval") { |v| opts[:interval] = v }
280
+ o.on("-n", "--count COUNT", Integer, "Max iterations (0=inf)") { |v| opts[:count] = v }
281
+ o.on("-d", "--delay SECS", Float, "Delay before first send") { |v| opts[:delay] = v }
282
+ o.on("-t", "--timeout SECS", Float, "Send/receive timeout") { |v| opts[:timeout] = v }
283
+ o.on("-l", "--linger SECS", Float, "Drain time on close (default 5)") { |v| opts[:linger] = v }
284
+ o.on("--reconnect-ivl IVL", "Reconnect interval: SECS or MIN..MAX (default 0.1)") { |v|
285
+ opts[:reconnect_ivl] = if v.include?("..")
286
+ lo, hi = v.split("..", 2)
287
+ Float(lo)..Float(hi)
288
+ else
289
+ Float(v)
290
+ end
291
+ }
292
+ o.on("--recv-maxsz SIZE", "Max inbound message size, e.g. 4096, 64K, 1M, 2G (default 1M, 0=unlimited; larger messages drop the connection)") { |v| opts[:recv_maxsz] = parse_byte_size(v) }
293
+ o.on("--hwm N", Integer, "Send high water mark (default 100, 0=unbounded)") { |v| opts[:send_hwm] = v }
294
+ o.on("--sndbuf N", "SO_SNDBUF kernel buffer size (e.g. 4K, 1M)") { |v| opts[:sndbuf] = parse_byte_size(v) }
295
+ o.on("--rcvbuf N", "SO_RCVBUF kernel buffer size (e.g. 4K, 1M)") { |v| opts[:rcvbuf] = parse_byte_size(v) }
296
+
297
+ o.separator "\nCompression:"
298
+ o.on("-z", "--compress", "LZ4 compression per message (modal with --in/--out)") do
299
+ require "rlz4"
300
+ case pipe_side
301
+ when :in
302
+ opts[:compress_in] = true
303
+ when :out
304
+ opts[:compress_out] = true
305
+ else
306
+ opts[:compress] = true
307
+ end
308
+ end
309
+
310
+ o.separator "\nProcessing (-e = incoming, -E = outgoing):"
311
+ o.on("-e", "--recv-eval EXPR", "Eval Ruby for each incoming message ($_ = body)") { |v| opts[:recv_expr] = v }
312
+ o.on("-E", "--send-eval EXPR", "Eval Ruby for each outgoing message ($_ = body)") { |v| opts[:send_expr] = v }
313
+ o.on("-r", "--require LIB", "Require lib/file in Async context; use '-' for stdin. Scripts can register NNQ.outgoing/incoming") { |v|
314
+ require "nnq" unless defined?(NNQ::VERSION)
315
+ opts[:scripts] << (v == "-" ? :stdin : (v.start_with?("./", "../") ? File.expand_path(v) : v))
316
+ }
317
+ o.on("-P", "--parallel N", Integer, "Parallel Ractor workers (max 16)") { |v|
318
+ opts[:parallel] = [v, 16].min
319
+ }
320
+
321
+ o.separator "\nOther:"
322
+ o.on("-v", "--verbose", "Verbosity: -v endpoints, -vv events, -vvv messages, -vvvv timestamps") { opts[:verbose] += 1 }
323
+ o.on("-q", "--quiet", "Suppress message output") { opts[:quiet] = true }
324
+ o.on( "--transient", "Exit when all peers disconnect") { opts[:transient] = true }
325
+ o.on("-V", "--version") {
326
+ if ENV["NNQ_DEV"]
327
+ require_relative "../../../../nnq/lib/nnq/version"
328
+ else
329
+ require "nnq/version"
330
+ end
331
+ puts "nnq-cli #{NNQ::CLI::VERSION} (nnq #{NNQ::VERSION})"
332
+ exit
333
+ }
334
+ o.on("-h") { puts o
335
+ exit }
336
+ o.on("--help") { CLI.page "#{o}\n#{EXAMPLES}"
337
+ exit }
338
+ o.on("--examples") { CLI.page EXAMPLES
339
+ exit }
340
+
341
+ o.separator "\nExit codes: 0 = success, 1 = error, 2 = timeout, 3 = eval error"
342
+ end
343
+
344
+ begin
345
+ parser.parse!(argv)
346
+ rescue OptionParser::ParseError => e
347
+ abort e.message
348
+ end
349
+
350
+ type_name = argv.shift
351
+ if type_name.nil?
352
+ abort parser.to_s if opts[:scripts].empty?
353
+ # bare script mode -- type_name stays nil
354
+ elsif !SOCKET_TYPE_NAMES.include?(type_name.downcase)
355
+ abort "Unknown socket type: #{type_name}. Known: #{SOCKET_TYPE_NAMES.join(', ')}"
356
+ else
357
+ opts[:type_name] = type_name.downcase
358
+ end
359
+
360
+ # Normalize shorthand hostnames to concrete addresses.
361
+ #
362
+ # Binds: tcp://:PORT → loopback (::1 if IPv6 available, else 127.0.0.1)
363
+ # tcp://*:PORT → 0.0.0.0 (all interfaces, IPv4)
364
+ #
365
+ # Connects: tcp://:PORT → localhost (Happy Eyeballs)
366
+ # tcp://*:PORT → localhost
367
+ loopback = self.class.loopback_bind_host
368
+ normalize_bind = ->(url) { url.sub(%r{\Atcp://\*:}, "tcp://0.0.0.0:").sub(%r{\Atcp://:}, "tcp://#{loopback}:") }
369
+ normalize_connect = ->(url) { url.sub(%r{\Atcp://(\*|):}, "tcp://localhost:") }
370
+ normalize_ep = ->(ep) { Endpoint.new(ep.bind? ? normalize_bind.call(ep.url) : normalize_connect.call(ep.url), ep.bind?) }
371
+ opts[:binds].map!(&normalize_bind)
372
+ opts[:connects].map!(&normalize_connect)
373
+ opts[:endpoints].map!(&normalize_ep)
374
+ opts[:in_endpoints].map!(&normalize_ep)
375
+ opts[:out_endpoints].map!(&normalize_ep)
376
+
377
+ opts
378
+ end
379
+
380
+
381
+ # Parses a byte size string with an optional K/M/G suffix (binary,
382
+ # i.e. 1K = 1024 bytes).
383
+ #
384
+ # @param str [String] e.g. "4096", "4K", "1M", "2G"
385
+ # @return [Integer] size in bytes
386
+ #
387
+ def parse_byte_size(str)
388
+ case str
389
+ when /\A(\d+)[kK]\z/ then $1.to_i * 1024
390
+ when /\A(\d+)[mM]\z/ then $1.to_i * 1024 * 1024
391
+ when /\A(\d+)[gG]\z/ then $1.to_i * 1024 * 1024 * 1024
392
+ when /\A\d+\z/ then str.to_i
393
+ else
394
+ abort "invalid byte size: #{str} (use e.g. 4096, 4K, 1M, 2G)"
395
+ end
396
+ end
397
+
398
+
399
+ # Returns the loopback address for bind normalization.
400
+ # Prefers IPv6 loopback ([::1]) when the host has at least one
401
+ # non-loopback, non-link-local IPv6 address, otherwise 127.0.0.1.
402
+ def self.loopback_bind_host
403
+ @loopback_bind_host ||= begin
404
+ has_ipv6 = ::Socket.getifaddrs.any? { |ifa|
405
+ addr = ifa.addr
406
+ addr&.ipv6? && !addr.ipv6_loopback? && !addr.ipv6_linklocal?
407
+ }
408
+ has_ipv6 ? "[::1]" : "127.0.0.1"
409
+ end
410
+ end
411
+
412
+
413
+ # Validates option combinations, aborting on invalid combos.
414
+ #
415
+ # @param opts [Hash] parsed options from {#parse}
416
+ # @return [void]
417
+ def validate!(opts)
418
+ return if opts[:type_name].nil? # bare script mode
419
+
420
+ abort "-r- (stdin script) and -F- (stdin data) cannot both be used" if opts[:scripts]&.include?(:stdin) && opts[:file] == "-"
421
+
422
+ type_name = opts[:type_name]
423
+
424
+ if type_name == "pipe"
425
+ has_in_out = opts[:in_endpoints].any? || opts[:out_endpoints].any?
426
+ if has_in_out
427
+ # Promote bare endpoints into the missing side:
428
+ # `pipe -c SRC --out -c DST` → bare SRC becomes --in
429
+ if opts[:in_endpoints].empty? && opts[:endpoints].any?
430
+ opts[:in_endpoints] = opts[:endpoints]
431
+ opts[:endpoints] = []
432
+ elsif opts[:out_endpoints].empty? && opts[:endpoints].any?
433
+ opts[:out_endpoints] = opts[:endpoints]
434
+ opts[:endpoints] = []
435
+ end
436
+ abort "pipe --in requires at least one endpoint" if opts[:in_endpoints].empty?
437
+ abort "pipe --out requires at least one endpoint" if opts[:out_endpoints].empty?
438
+ abort "pipe: don't mix --in/--out with bare -b/-c endpoints" unless opts[:endpoints].empty?
439
+ else
440
+ abort "pipe requires exactly 2 endpoints (pull-side and push-side), or use --in/--out" if opts[:endpoints].size != 2
441
+ end
442
+ else
443
+ abort "--in/--out are only valid for pipe" if opts[:in_endpoints].any? || opts[:out_endpoints].any?
444
+ abort "At least one --connect or --bind is required" if opts[:connects].empty? && opts[:binds].empty?
445
+ end
446
+ abort "--data and --file are mutually exclusive" if opts[:data] && opts[:file]
447
+ abort "--subscribe is only valid for SUB" if !opts[:subscribes].empty? && type_name != "sub"
448
+ abort "--recv-eval is not valid for send-only sockets (use --send-eval / -E)" if opts[:recv_expr] && SEND_ONLY.include?(type_name)
449
+ abort "--send-eval is not valid for recv-only sockets (use --recv-eval / -e)" if opts[:send_expr] && RECV_ONLY.include?(type_name)
450
+ abort "--send-eval is not valid for REP (the reply is the result of --recv-eval / -e)" if opts[:send_expr] && type_name == "rep"
451
+
452
+ if opts[:parallel]
453
+ parallel_types = %w[pipe]
454
+ abort "-P/--parallel is only valid for #{parallel_types.join(", ")}" unless parallel_types.include?(type_name)
455
+ abort "-P/--parallel must be 1..16" unless (1..16).include?(opts[:parallel])
456
+ all_eps = if type_name == "pipe"
457
+ opts[:in_endpoints] + opts[:out_endpoints] + opts[:endpoints]
458
+ else
459
+ opts[:endpoints]
460
+ end
461
+ abort "-P/--parallel requires all endpoints to use --connect (not --bind)" if all_eps.any?(&:bind?)
462
+ end
463
+
464
+ (opts[:connects] + opts[:binds]).each do |url|
465
+ abort "inproc not supported, use tcp:// or ipc://" if url.include?("inproc://")
466
+ end
467
+
468
+ all_urls = if type_name == "pipe"
469
+ (opts[:in_endpoints] + opts[:out_endpoints] + opts[:endpoints]).map(&:url)
470
+ else
471
+ opts[:connects] + opts[:binds]
472
+ end
473
+ dups = all_urls.tally.select { |_, n| n > 1 }.keys
474
+ abort "duplicate endpoint: #{dups.first}" if dups.any?
475
+ end
476
+
477
+
478
+ # Expands shorthand `@name` to `ipc://@name` (Linux abstract namespace).
479
+ # Only triggers when the value starts with `@` and has no `://` scheme.
480
+ def expand_endpoint(url)
481
+ url.start_with?("@") && !url.include?("://") ? "ipc://#{url}" : url
482
+ end
483
+ end
484
+ end
485
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NNQ
4
+ module CLI
5
+ # Socket type names that only send messages.
6
+ SEND_ONLY = %w[pub push].freeze
7
+ # Socket type names that only receive messages.
8
+ RECV_ONLY = %w[sub pull].freeze
9
+
10
+
11
+ # A bind or connect endpoint with its URL and direction.
12
+ Endpoint = Data.define(:url, :bind?) do
13
+ # @return [Boolean] true if this endpoint connects rather than binds
14
+ def connect? = !bind?
15
+ end
16
+
17
+
18
+ # Frozen, Ractor-shareable configuration data class for a CLI invocation.
19
+ Config = Data.define(
20
+ :type_name,
21
+ :endpoints,
22
+ :connects,
23
+ :binds,
24
+ :in_endpoints,
25
+ :out_endpoints,
26
+ :data,
27
+ :file,
28
+ :format,
29
+ :subscribes,
30
+ :interval,
31
+ :count,
32
+ :delay,
33
+ :timeout,
34
+ :linger,
35
+ :reconnect_ivl,
36
+ :send_hwm,
37
+ :sndbuf,
38
+ :rcvbuf,
39
+ :compress,
40
+ :compress_in,
41
+ :compress_out,
42
+ :send_expr,
43
+ :recv_expr,
44
+ :parallel,
45
+ :transient,
46
+ :verbose,
47
+ :quiet,
48
+ :echo,
49
+ :scripts,
50
+ :recv_maxsz,
51
+ :stdin_is_tty,
52
+ ) do
53
+ # @return [Boolean] true if this socket type only sends
54
+ def send_only? = SEND_ONLY.include?(type_name)
55
+ # @return [Boolean] true if this socket type only receives
56
+ def recv_only? = RECV_ONLY.include?(type_name)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NNQ
4
+ module CLI
5
+ # Compiles and evaluates a single Ruby expression string for use in
6
+ # --recv-eval / --send-eval. Handles BEGIN{}/END{} block extraction,
7
+ # proc compilation, and result normalisation.
8
+ #
9
+ # One instance per direction (send or recv).
10
+ #
11
+ # nnq has no multipart, so `$F` is always a 1-element array and `$_`
12
+ # is the body string.
13
+ #
14
+ class ExpressionEvaluator
15
+ attr_reader :begin_proc, :end_proc, :eval_proc
16
+
17
+ # Sentinel: eval proc returned the context object, meaning it already
18
+ # sent the reply itself.
19
+ SENT = Object.new.freeze
20
+
21
+
22
+ # @param src [String, nil] raw expression string (may include BEGIN{}/END{})
23
+ # @param format [Symbol] active format, used to normalise results
24
+ # @param fallback_proc [Proc, nil] registered NNQ.outgoing/incoming handler;
25
+ # used only when +src+ is nil
26
+ def initialize(src, format:, fallback_proc: nil)
27
+ @format = format
28
+
29
+ if src
30
+ expr, begin_body, end_body = extract_blocks(src)
31
+ @begin_proc = eval("proc { #{begin_body} }") if begin_body # rubocop:disable Security/Eval
32
+ @end_proc = eval("proc { #{end_body} }") if end_body # rubocop:disable Security/Eval
33
+ if expr && !expr.strip.empty?
34
+ @eval_proc = eval("proc { $_ = $F&.first; #{expr} }") # rubocop:disable Security/Eval
35
+ end
36
+ elsif fallback_proc
37
+ @eval_proc = proc { |msg|
38
+ body = msg&.first
39
+ $_ = body
40
+ fallback_proc.call(body)
41
+ }
42
+ end
43
+ end
44
+
45
+
46
+ # Runs the eval proc against +msg+ using +context+ as self.
47
+ # Returns the normalised result Array, nil (filter/skip), or SENT.
48
+ def call(msg, context)
49
+ return msg unless @eval_proc
50
+
51
+ $F = msg
52
+ result = context.instance_exec(msg, &@eval_proc)
53
+ return nil if result.nil?
54
+ return SENT if result.equal?(context)
55
+ return [result] if @format == :marshal
56
+
57
+ result = result.is_a?(Array) ? result.first(1) : [result]
58
+ result.map!(&:to_s)
59
+ rescue => e
60
+ $stderr.puts "nnq: eval error: #{e.message} (#{e.class})"
61
+ exit 3
62
+ end
63
+
64
+
65
+ # Normalises an eval result to nil (skip) or a 1-element Array of strings.
66
+ # Used inside Ractor worker blocks.
67
+ def self.normalize_result(result)
68
+ return nil if result.nil?
69
+ result = result.is_a?(Array) ? result.first(1) : [result]
70
+ result.map!(&:to_s)
71
+ end
72
+
73
+
74
+ # Compiles begin/end/eval procs inside a Ractor from a raw expression
75
+ # string. Returns [begin_proc, end_proc, eval_proc], any may be nil.
76
+ # Must be called inside the Ractor block.
77
+ def self.compile_inside_ractor(src)
78
+ return [nil, nil, nil] unless src
79
+
80
+ extract = ->(expr, kw) {
81
+ s = expr.index(/#{kw}\s*\{/)
82
+ return [expr, nil] unless s
83
+ ci = expr.index("{", s)
84
+ depth = 1
85
+ j = ci + 1
86
+ while j < expr.length && depth > 0
87
+ depth += 1 if expr[j] == "{"
88
+ depth -= 1 if expr[j] == "}"
89
+ j += 1
90
+ end
91
+ [expr[0...s] + expr[j..], expr[(ci + 1)..(j - 2)]]
92
+ }
93
+
94
+ expr, begin_body = extract.(src, "BEGIN")
95
+ expr, end_body = extract.(expr, "END")
96
+
97
+ begin_proc = eval("proc { #{begin_body} }") if begin_body # rubocop:disable Security/Eval
98
+ end_proc = eval("proc { #{end_body} }") if end_body # rubocop:disable Security/Eval
99
+ eval_proc = nil
100
+ if expr && !expr.strip.empty?
101
+ ractor_expr = expr.gsub(/\$F\b/, "__F")
102
+ eval_proc = eval("proc { |__F| $_ = __F&.first; #{ractor_expr} }") # rubocop:disable Security/Eval
103
+ end
104
+
105
+ [begin_proc, end_proc, eval_proc]
106
+ end
107
+
108
+
109
+ private
110
+
111
+
112
+ def extract_blocks(expr)
113
+ expr, begin_body = extract_block(expr, "BEGIN")
114
+ expr, end_body = extract_block(expr, "END")
115
+ [expr, begin_body, end_body]
116
+ end
117
+
118
+
119
+ def extract_block(expr, keyword)
120
+ start = expr.index(/#{keyword}\s*\{/)
121
+ return [expr, nil] unless start
122
+
123
+ i = expr.index("{", start)
124
+ depth = 1
125
+ j = i + 1
126
+ while j < expr.length && depth > 0
127
+ case expr[j]
128
+ when "{"
129
+ depth += 1
130
+ when "}"
131
+ depth -= 1
132
+ end
133
+ j += 1
134
+ end
135
+
136
+ body = expr[(i + 1)..(j - 2)]
137
+ trimmed = expr[0...start] + expr[j..]
138
+ [trimmed, body]
139
+ end
140
+ end
141
+ end
142
+ end