asterism 0.8.0 → 0.9.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,396 @@
1
+ # The noun-verb commands of the command line (node, app, object, status:
2
+ # lib/asterism/cli.rb's NOUNS), `completion` and the hidden `__complete`
3
+ # (lib/asterism/cli/complete.rb).
4
+ module Asterism
5
+ class CLI
6
+ # ------------------------------------------------------------ node
7
+
8
+ def cmd_node_list(_rest)
9
+ connect do |_net, sv|
10
+ names = sv.nodes
11
+ structured? ? emit(names) : names.each { @out.puts paint(_1, :node) }
12
+ end
13
+ 0
14
+ end
15
+
16
+ def cmd_node_info(rest)
17
+ node = rest.shift
18
+ raise ArgumentError, "node info: which node? (asterism node info NODE)" unless node && !node.empty?
19
+ raise ArgumentError, "node info: NODE is a node's name (no /)" if node.include?("/")
20
+ code = 0
21
+ connect do |_net, sv|
22
+ entry = sv.tree(node: node).first
23
+ if entry.nil?
24
+ @err.puts "asterism: no node #{node} is visible"
25
+ code = 1
26
+ elsif structured?
27
+ emit(entry)
28
+ else
29
+ @out.puts paint(entry["node"], :node)
30
+ if entry["profile"]
31
+ pr = entry["profile"].except("icon")
32
+ Text.wrap(pr.map { |k, v| Text.pair(k, v) }, style.width, first: " #{paint('profile:', :note)} ",
33
+ rest: " ", break_long: false)
34
+ .each { @out.puts _1 }
35
+ end
36
+ entry["apps"].each { |a| app_block(a["app"], a, 2) }
37
+ end
38
+ end
39
+ code
40
+ end
41
+
42
+ # ------------------------------------------------------------ app
43
+
44
+ def cmd_app_list(_rest)
45
+ connect do |_net, sv|
46
+ names = sv.apps
47
+ structured? ? emit(names) : names.each { @out.puts paint_path(_1) }
48
+ end
49
+ 0
50
+ end
51
+
52
+ def cmd_app_info(rest)
53
+ node, app = app_path!(rest, "app info").split("/")
54
+ code = 0
55
+ connect do |_net, sv|
56
+ a = sv.tree(node: node, app: app).first&.dig("apps")&.find { _1["app"] == app }
57
+ if a.nil?
58
+ @err.puts "asterism: no application #{node}/#{app} is visible"
59
+ code = 1
60
+ elsif structured?
61
+ emit({ "node" => node }.merge(a))
62
+ else
63
+ app_block("#{node}/#{app}", a, 0)
64
+ end
65
+ end
66
+ code
67
+ end
68
+
69
+ # ------------------------------------------------------------ object
70
+
71
+ def cmd_object_list(rest)
72
+ pattern = rest.shift || "**"
73
+ connect do |_net, sv|
74
+ paths = sv.objects(pattern)
75
+ structured? ? emit(paths) : paths.each { @out.puts paint_path(_1) }
76
+ end
77
+ 0
78
+ end
79
+
80
+ def cmd_object_info(rest)
81
+ path = rest.shift
82
+ unless path && path.split("/", -1).size == 3 && path.split("/").none? { _1.empty? || _1.include?("*") }
83
+ raise ArgumentError, "object info: PATH is node/app/object (a pattern: asterism object list PATTERN)"
84
+ end
85
+ code = 0
86
+ connect do |_net, sv|
87
+ if sv.objects(path).empty?
88
+ @err.puts "asterism: no object #{path} is visible"
89
+ code = 1
90
+ next
91
+ end
92
+ methods = sv.metas([path])[path]
93
+ if structured?
94
+ h = { "path" => path, "methods" => methods }
95
+ h["doc"] = sv.object_doc(path) if sv.object_doc(path)
96
+ emit(h)
97
+ else
98
+ object_entry(path, methods, sv.object_doc(path))
99
+ end
100
+ end
101
+ code
102
+ end
103
+
104
+ def cmd_object_find(rest)
105
+ want = rest.shift
106
+ raise ArgumentError, "object find: which method? (asterism object find METHOD; * as a wildcard)" unless want
107
+ found = []
108
+ connect do |_net, sv|
109
+ paths = sv.objects
110
+ metas = sv.metas(paths)
111
+ paths.each do |p|
112
+ ms = (metas[p] || []).select { File.fnmatch?(want, _1["name"]) }
113
+ found << { "path" => p, "methods" => ms } unless ms.empty?
114
+ end
115
+ structured? ? emit(found) : found.each { @out.puts paint_path(_1["path"]) }
116
+ end
117
+ 0
118
+ end
119
+
120
+ # ------------------------------------------------------------ status
121
+
122
+ def cmd_status_get(rest)
123
+ path = app_path!(rest, "status get")
124
+ code = 0
125
+ connect do |_net, sv|
126
+ st = sv.statuses([path])[path]
127
+ if st.nil?
128
+ @err.puts no_status(path)
129
+ code = 1
130
+ elsif structured?
131
+ emit(json_safe(st))
132
+ else
133
+ Text.status_lines(json_safe(st), style.width).each { @out.puts _1 }
134
+ end
135
+ end
136
+ code
137
+ end
138
+
139
+ # The status, then each change as it comes (the text: what changed, as
140
+ # watch tells it); --once: the status now, once.
141
+ def cmd_status_echo(rest)
142
+ path = app_path!(rest, "status echo")
143
+ code = 0
144
+ connect do |net, sv|
145
+ q = Queue.new
146
+ subscribe_status(net, path) { |st| q << [Time.now, st] } unless @opts[:once]
147
+ st = sv.statuses([path])[path]
148
+ if st
149
+ echo_event(Time.now, path, st, true)
150
+ elsif @opts[:once]
151
+ @err.puts no_status(path)
152
+ code = 1
153
+ end
154
+ next if @opts[:once]
155
+
156
+ until_stopped(net) do |left|
157
+ got = q.pop(timeout: [left || 0.2, 0.2].min)
158
+ echo_event(got[0], path, got[1], false) if got
159
+ end
160
+ end
161
+ code
162
+ end
163
+
164
+ # How often the status changes: every --period, when a change came, the
165
+ # average rate and the intervals (Hz).
166
+ def cmd_status_hz(rest)
167
+ path = app_path!(rest, "status hz")
168
+ raise ArgumentError, "--window: 1 or more" unless @opts[:window].positive?
169
+ raise ArgumentError, "--period: more than 0" unless @opts[:period].positive?
170
+ connect do |net, sv|
171
+ hz = Hz.new(window: @opts[:window])
172
+ lock = Mutex.new
173
+ subscribe_status(net, path) { |st| lock.synchronize { hz.put(st) } }
174
+ st = sv.statuses([path])[path]
175
+ lock.synchronize { hz.seed(st) } if st
176
+ @err.puts "asterism: #{path}: counting the status's changes (Ctrl-C to stop)" unless structured?
177
+ nxt = now + @opts[:period]
178
+ until_stopped(net) do |_left|
179
+ sleep 0.05
180
+ next if now < nxt
181
+ nxt += @opts[:period]
182
+ s = lock.synchronize { hz.report }
183
+ next unless s
184
+ if structured?
185
+ emit_line({ "t" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"), "path" => path }.merge(s))
186
+ else
187
+ Hz.text(s).each { @out.puts _1 }
188
+ end
189
+ @out.flush
190
+ end
191
+ end
192
+ 0
193
+ end
194
+
195
+ # ------------------------------------------------------------ completion
196
+
197
+ def cmd_completion(rest)
198
+ case rest.shift
199
+ when "bash" then @out.write(Complete::BASH)
200
+ when "zsh" then @out.write(Complete::ZSH)
201
+ else raise ArgumentError, "completion: bash or zsh (source <(asterism completion bash))"
202
+ end
203
+ 0
204
+ end
205
+
206
+ # The candidates for the word being completed, one a line. The words
207
+ # come as arguments (the last one the word being completed), after
208
+ # "--"; or (--bash LINE) as bash's line up to the cursor.
209
+ def cmd_complete(argv)
210
+ bash = argv.first == "--bash"
211
+ words = if bash
212
+ Complete.split_line(argv[1].to_s)
213
+ else
214
+ argv.first == "--" ? argv.drop(1) : argv
215
+ end
216
+ words = [""] if words.empty?
217
+ @complete_words = words
218
+ cands = Complete.new(self, CompleteSource.new(self, words)).candidates(words)
219
+ cands = Complete.bash_trim(cands, words.last) if bash && cands != [Complete::FILES]
220
+ cands.each { @out.puts _1 }
221
+ @out.flush
222
+ # A connection still trying past the budget is left behind: the
223
+ # process ends without waiting for it.
224
+ Process.exit!(0) if @complete_abandoned
225
+ 0
226
+ end
227
+
228
+ # What __complete asks the network for, through the cache: the names
229
+ # (the tokens) and an object's methods (its meta).
230
+ class CompleteSource
231
+ def initialize(cli, words)
232
+ @cli = cli
233
+ @words = words
234
+ end
235
+
236
+ def names
237
+ cached("names") do
238
+ @cli.complete_fetch { |sv| { "apps" => sv.apps, "objects" => sv.objects } }
239
+ end
240
+ end
241
+
242
+ def methods(path)
243
+ cached("methods #{path}") do
244
+ @cli.complete_fetch { |sv| sv.metas([path])[path]&.map { _1["name"] } }
245
+ end
246
+ end
247
+
248
+ def all_methods
249
+ cached("methods") do
250
+ @cli.complete_fetch { |sv| sv.metas(sv.objects).values.flatten.map { _1["name"] }.uniq.sort }
251
+ end
252
+ end
253
+
254
+ private
255
+
256
+ def cached(entry, &blk)
257
+ net = @cli.complete_network(@words)
258
+ return nil if net.none?
259
+ dir = Complete::Cache.dir(@cli.env)
260
+ return blk.call unless dir
261
+ Complete::Cache.new(dir: dir, network: net).fetch(entry, &blk)
262
+ end
263
+ end
264
+
265
+ attr_reader :env
266
+
267
+ # Where the network is for __complete: the options among the words,
268
+ # else the environment (network). [router, credentials (its full path),
269
+ # peer]
270
+ def complete_network(words)
271
+ words.each_cons(2) do |a, b|
272
+ case a
273
+ when "--router" then @opts[:router] = b
274
+ when "--credentials" then @opts[:credentials] = b
275
+ when "--peer" then @opts[:peer] = b
276
+ end
277
+ end
278
+ r, c, pr = network
279
+ [r, c && File.expand_path(c), pr]
280
+ end
281
+
282
+ # Connects for at most Complete::BUDGET seconds and yields the Survey;
283
+ # the block's value, or nil when the network did not answer in time.
284
+ def complete_fetch
285
+ @opts[:settle] = 0.6
286
+ @opts[:timeout] = 0.6
287
+ out = Queue.new
288
+ th = Thread.new do
289
+ err = @err
290
+ @err = File.open(File::NULL, "w")
291
+ begin
292
+ connect { |_net, sv| out << yield(sv) }
293
+ rescue StandardError, ScriptError
294
+ out << nil
295
+ ensure
296
+ @err = err
297
+ end
298
+ end
299
+ v = out.pop(timeout: Complete::BUDGET)
300
+ if v.nil? && th.alive?
301
+ @complete_abandoned = true
302
+ return nil
303
+ end
304
+ th.join(0.5) || (@complete_abandoned = true)
305
+ v
306
+ end
307
+
308
+ # ------------------------------------------------------------ helpers
309
+
310
+ def app_path!(rest, what)
311
+ path = rest.shift
312
+ ok = path && path.split("/", -1).size == 2 && path.split("/").none? { _1.empty? || _1.include?("*") }
313
+ raise ArgumentError, "#{what}: which application? (asterism #{what} NODE/APP)" unless ok
314
+ path
315
+ end
316
+
317
+ def no_status(path)
318
+ "asterism: no status from #{path} (not there, no status, or no answer in time)"
319
+ end
320
+
321
+ # node/app[/object] painted part by part.
322
+ def paint_path(path)
323
+ parts = path.split("/")
324
+ roles = %i[node app object]
325
+ parts.each_with_index.map { |p, i| paint(p, roles[i] || :object) }.join(paint("/", :note))
326
+ end
327
+
328
+ # An application (a tree's entry) under its name: its status and its
329
+ # objects (how many methods, and the object's description).
330
+ def app_block(name, a, indent)
331
+ pad = " " * indent
332
+ @out.puts "#{pad}#{paint(name, :app)}"
333
+ status_block(a, indent + 2)
334
+ return if a["objects"].empty?
335
+ @out.puts "#{pad} #{paint('objects:', :note)}"
336
+ a["objects"].each do |o|
337
+ ms = o["methods"]
338
+ @out.puts "#{pad} #{paint(o['name'], :object)} #{paint(ms ? count(ms.size, 'method') : '(no meta)', :note)}"
339
+ Text.wrap_text(o["doc"], style.width, first: "#{pad} ").each { @out.puts _1 } if o["doc"]
340
+ end
341
+ end
342
+
343
+ # The status's puts of one application, cleaned (Status.parse).
344
+ def subscribe_status(net, path)
345
+ net.session.subscribe("asterism/#{path}/@status") do |sample|
346
+ got = ::Asterism::Status.parse(sample.key, sample.payload)
347
+ yield got[2] if got
348
+ end
349
+ end
350
+
351
+ # Yields until Ctrl-C, --duration or a lost connection (the seconds
352
+ # left of --duration, or nil).
353
+ def until_stopped(net)
354
+ deadline = @opts[:duration] && (now + @opts[:duration])
355
+ loop do
356
+ left = deadline && (deadline - now)
357
+ break if left && left <= 0
358
+ break unless net.connected?
359
+ yield left
360
+ end
361
+ end
362
+
363
+ def now
364
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
365
+ end
366
+
367
+ # One record of status echo: JSON / YAML, or the text (the status whole
368
+ # the first time, then a line for each change).
369
+ def echo_event(t, path, status, first)
370
+ status = json_safe(status)
371
+ if structured?
372
+ h = { "t" => t.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"), "path" => path, "status" => status }
373
+ h["now"] = true if first
374
+ emit_line(h)
375
+ else
376
+ text = echo_text(t, path, status, first)
377
+ @out.puts text if text
378
+ end
379
+ @out.flush
380
+ end
381
+
382
+ # The text of one record of status echo (nil: no change).
383
+ def echo_text(t, path, status, first)
384
+ head = "#{paint(t.strftime('%H:%M:%S.%L'), :time)} "
385
+ old = @echo_last
386
+ @echo_last = status
387
+ if first || !old.is_a?(Hash) || !status.is_a?(Hash)
388
+ lines = Text.status_lines(status, style.width, Text.width(head))
389
+ return "#{head}#{paint(path, :app)}#{paint(' (now)', :note) if first}\n#{lines.join("\n")}"
390
+ end
391
+ changes = Text.status_diff(old, status)
392
+ return nil if changes.empty?
393
+ changes.map { |c| "#{head}#{change_text(c)}" }.join("\n")
394
+ end
395
+ end
396
+ end
@@ -115,10 +115,12 @@ module Asterism
115
115
 
116
116
  # The tree: [{"node", "profile", "apps" => [{"app", "status",
117
117
  # "objects" => [{"name", "methods"}]}]}]. meta: false leaves the
118
- # methods out (no meta gets).
119
- def tree(meta: true)
120
- objs = objects
121
- app_list = apps
118
+ # methods out (no meta gets); node: (and app:) narrow it to one node
119
+ # (one application).
120
+ def tree(meta: true, node: nil, app: nil)
121
+ scope = node && "#{node}/#{app || '*'}"
122
+ objs = scope ? objects("#{scope}/*") : objects
123
+ app_list = scope ? apps(scope) : apps
122
124
  threads = []
123
125
  node_list = (app_list.map { _1.split("/")[0] } + objs.map { _1.split("/")[0] }).uniq
124
126
  threads << Thread.new { profiles(node_list) }
@@ -0,0 +1,256 @@
1
+ # How the command line's text looks (not --json, which stays as it is):
2
+ # colours and the terminal's width when the output is a terminal, plain
3
+ # text 80 columns wide (or COLUMNS) otherwise; NO_COLOR turns the colours
4
+ # off. Statuses are laid out as indented key=value lines, the methods and
5
+ # the descriptions are wrapped to the width, and watch tells what changed
6
+ # in a status. The standard library only (io/console for the width).
7
+ require "json"
8
+
9
+ module Asterism
10
+ class CLI
11
+ # Colours and width for one output.
12
+ class Style
13
+ CODES = { bold: 1, dim: 2, red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36 }.freeze
14
+ # What each kind of thing is painted with.
15
+ ROLES = {
16
+ node: %i[bold blue], app: [:cyan], object: [:green], method: [:bold], note: [:dim], time: [:dim],
17
+ join: [:green], leave: [:red], status: [:yellow], profile: [:magenta]
18
+ }.freeze
19
+ MIN_WIDTH = 20
20
+
21
+ attr_reader :width
22
+
23
+ def initialize(io, env = ENV)
24
+ tty = io.respond_to?(:tty?) && io.tty?
25
+ @color = tty && env["NO_COLOR"].to_s.empty?
26
+ w = (tty && terminal_width(io)) || env["COLUMNS"].to_i
27
+ @width = w.positive? ? [w, MIN_WIDTH].max : 80
28
+ end
29
+
30
+ def color?
31
+ @color
32
+ end
33
+
34
+ # text painted as role (a ROLES key) or with the CODES given.
35
+ def paint(text, *styles)
36
+ text = text.to_s
37
+ return text unless @color
38
+ codes = styles.flat_map { ROLES.fetch(_1, [_1]) }.map { CODES.fetch(_1) }
39
+ codes.empty? ? text : "\e[#{codes.join(';')}m#{text}\e[0m"
40
+ end
41
+
42
+ private
43
+
44
+ def terminal_width(io)
45
+ require "io/console"
46
+ w = io.winsize[1]
47
+ w.positive? ? w : nil
48
+ rescue StandardError, LoadError
49
+ nil
50
+ end
51
+ end
52
+
53
+ # Laying text out: display widths (East Asian wide characters take two
54
+ # columns), word wrapping, statuses and their differences.
55
+ module Text
56
+ module_function
57
+
58
+ ANSI = /\e\[[0-9;]*m/
59
+ # Code points that take two columns on a terminal (East Asian Wide and
60
+ # Fullwidth, roughly).
61
+ WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩\u{1F300}-\u{1F64F}\u{1F900}-\u{1F9FF}\u{20000}-\u{3FFFD}]/
62
+ # A string value longer than this (columns) is cut, and so is an
63
+ # array written on one line; --json has them whole.
64
+ LONG = 60
65
+ ARROW = "→"
66
+ ELLIPSIS = "…"
67
+
68
+ # The columns text takes on a terminal (colour codes take none).
69
+ def width(text)
70
+ s = text.to_s.gsub(ANSI, "")
71
+ s = s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) unless s.encoding == Encoding::UTF_8
72
+ s.scrub.each_char.sum { _1.match?(WIDE) ? 2 : 1 }
73
+ end
74
+
75
+ # The tokens joined with sep in lines at most cols columns wide: the
76
+ # first starts with first, the others with rest (spaces as wide as
77
+ # first by default). A plain token wider than a line is broken between
78
+ # characters (break_long: false keeps it whole, on a line of its own);
79
+ # a painted one (a method's signature) is kept whole. Returns the
80
+ # lines (at least one).
81
+ def wrap(tokens, cols, first: "", rest: nil, sep: " ", break_long: true)
82
+ rest ||= " " * width(first)
83
+ lines = []
84
+ line = first
85
+ lw = width(first)
86
+ empty = true
87
+ tokens.each do |tok|
88
+ tok = tok.to_s
89
+ tw = width(tok)
90
+ if !empty && lw + sep.size + tw > cols
91
+ lines << line
92
+ line = rest
93
+ lw = width(rest)
94
+ empty = true
95
+ end
96
+ while break_long && lw + tw > cols && !tok.match?(ANSI) && tok.size > 1
97
+ head, tail = split_width(tok, [cols - lw, 1].max)
98
+ break if tail.empty?
99
+ lines << (empty ? line : line + sep) + head
100
+ line = rest
101
+ lw = width(rest)
102
+ empty = true
103
+ tok = tail
104
+ tw = width(tok)
105
+ end
106
+ line += (empty ? "" : sep) + tok
107
+ lw += (empty ? 0 : sep.size) + tw
108
+ empty = false
109
+ end
110
+ lines << line
111
+ lines
112
+ end
113
+
114
+ # text's words wrapped (wrap), each paragraph (blank lines between)
115
+ # starting a new line with rest.
116
+ def wrap_text(text, cols, first: "", rest: nil)
117
+ rest ||= " " * width(first)
118
+ paras = text.to_s.strip.split(/\n\s*\n/).map(&:split).reject(&:empty?)
119
+ return [first.rstrip] if paras.empty?
120
+ paras.each_with_index.flat_map { |words, i| wrap(words, cols, first: i.zero? ? first : rest, rest: rest) }
121
+ end
122
+
123
+ # [the start of text no wider than cols (one character at least), the rest]
124
+ def split_width(text, cols)
125
+ w = 0
126
+ n = 0
127
+ text.each_char do |c|
128
+ cw = width(c)
129
+ break if n.positive? && w + cw > cols
130
+ w += cw
131
+ n += 1
132
+ end
133
+ [text[0, n], text[n..]]
134
+ end
135
+
136
+ # ------------------------------------------------------------ statuses
137
+
138
+ # A status (a Hash) as lines indented by indent spaces: the plain
139
+ # values of a level as key=value on one line (wrapped), a Hash of
140
+ # plain values as "key: k=v k=v", deeper ones as "key:" and their
141
+ # lines two spaces further in, an array with a Hash in it as "key:"
142
+ # and its elements as "- " lines. A key=value is never broken: one
143
+ # wider than the line has a line of its own (the terminal folds it).
144
+ def status_lines(h, cols, indent = 0)
145
+ pad = " " * indent
146
+ lines = []
147
+ flat = []
148
+ flush = lambda do
149
+ lines.concat(wrap(flat, cols, first: pad, break_long: false)) unless flat.empty?
150
+ flat = []
151
+ end
152
+ h.each do |k, v|
153
+ if plain?(v)
154
+ flat << pair(k, v)
155
+ next
156
+ end
157
+ flush.call
158
+ if v.is_a?(Hash) && v.empty?
159
+ lines << "#{pad}#{k}: {}"
160
+ elsif v.is_a?(Hash) && v.values.all? { plain?(_1) }
161
+ # The lines after the first under the first value (two spaces in
162
+ # when the key is long).
163
+ hang = width(k) + 2 > cols / 3 ? 2 : width(k) + 2
164
+ lines.concat(wrap(v.map { |kk, vv| pair(kk, vv) }, cols, first: "#{pad}#{k}: ", rest: pad + (" " * hang),
165
+ break_long: false))
166
+ elsif v.is_a?(Hash)
167
+ lines << "#{pad}#{k}:"
168
+ lines.concat(status_lines(v, cols, indent + 2))
169
+ else
170
+ lines << "#{pad}#{k}:"
171
+ v.each { |e| lines.concat(element_lines(e, cols, indent + 2)) }
172
+ end
173
+ end
174
+ flush.call
175
+ lines.empty? ? ["#{pad}{}"] : lines
176
+ end
177
+
178
+ # One element of an array that holds a Hash: "- " and the element.
179
+ def element_lines(e, cols, indent)
180
+ pad = " " * indent
181
+ if e.is_a?(Hash) && !e.empty? && e.values.all? { plain?(_1) }
182
+ wrap(e.map { |k, v| pair(k, v) }, cols, first: "#{pad}- ", rest: "#{pad} ", break_long: false)
183
+ elsif e.is_a?(Hash) && !e.empty?
184
+ sub = status_lines(e, cols, indent + 2)
185
+ sub[0] = "#{pad}- #{sub[0].lstrip}"
186
+ sub
187
+ else
188
+ ["#{pad}- #{inline(e)}"]
189
+ end
190
+ end
191
+
192
+ # No Hash anywhere in v (it goes on one line).
193
+ def plain?(v)
194
+ case v
195
+ when Hash then false
196
+ when Array then v.all? { plain?(_1) }
197
+ else true
198
+ end
199
+ end
200
+
201
+ def pair(k, v)
202
+ "#{k}=#{inline(v)}"
203
+ end
204
+
205
+ # A value on one line: a word as it is, other text quoted (JSON), a
206
+ # long one cut; [a, b] (a long array cut, with the count left out);
207
+ # {k=v k=v}; nil as nil.
208
+ def inline(v)
209
+ case v
210
+ when Hash then "{#{v.map { |k, x| pair(k, x) }.join(' ')}}"
211
+ when Array then inline_array(v)
212
+ when String then inline_string(v)
213
+ when nil then "nil"
214
+ else v.to_s
215
+ end
216
+ end
217
+
218
+ def inline_string(s)
219
+ s = s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) unless s.encoding == Encoding::UTF_8
220
+ s = "#{split_width(s, LONG - 1)[0]}#{ELLIPSIS}" if width(s) > LONG
221
+ s.match?(/\A[^\s="'{}\[\],]+\z/) ? s : JSON.generate(s)
222
+ end
223
+
224
+ def inline_array(a)
225
+ parts = a.map { inline(_1) }
226
+ text = "[#{parts.join(', ')}]"
227
+ return text if width(text) <= LONG || parts.size < 2
228
+ kept = []
229
+ parts.each do |p|
230
+ break if width("[#{(kept + [p]).join(', ')}, #{ELLIPSIS}+#{parts.size}]") > LONG && kept.any?
231
+ kept << p
232
+ end
233
+ "[#{kept.join(', ')}, #{ELLIPSIS}+#{parts.size - kept.size}]"
234
+ end
235
+
236
+ # What changed from the status old to new, one String a change:
237
+ # "a.b 0→1" (a value changed), "+a.b=1" (a key came; a Hash on one
238
+ # line), "-a.b" (a key went). Hashes in both are compared key by key.
239
+ def status_diff(old, new, prefix = nil)
240
+ out = []
241
+ at = ->(k) { prefix ? "#{prefix}.#{k}" : k.to_s }
242
+ new.each do |k, v|
243
+ if !old.key?(k)
244
+ out << "+#{pair(at.(k), v)}"
245
+ elsif old[k].is_a?(Hash) && v.is_a?(Hash)
246
+ out.concat(status_diff(old[k], v, at.(k)))
247
+ elsif old[k] != v
248
+ out << "#{at.(k)} #{inline(old[k])}#{ARROW}#{inline(v)}"
249
+ end
250
+ end
251
+ old.each_key { |k| out << "-#{at.(k)}" unless new.key?(k) }
252
+ out
253
+ end
254
+ end
255
+ end
256
+ end