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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -0
- data/README.md +143 -21
- data/lib/asterism/cli/complete.rb +282 -0
- data/lib/asterism/cli/hz.rb +77 -0
- data/lib/asterism/cli/nouns.rb +396 -0
- data/lib/asterism/cli/survey.rb +6 -4
- data/lib/asterism/cli/text.rb +256 -0
- data/lib/asterism/cli.rb +375 -98
- data/lib/asterism/version.rb +1 -1
- metadata +5 -1
|
@@ -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
|
data/lib/asterism/cli/survey.rb
CHANGED
|
@@ -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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|