asterism 0.7.1 → 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 +100 -0
- data/README.md +288 -5
- data/exe/asterism +8 -0
- data/lib/asterism/cli/allow.rb +53 -0
- data/lib/asterism/cli/complete.rb +282 -0
- data/lib/asterism/cli/hz.rb +77 -0
- data/lib/asterism/cli/literal.rb +132 -0
- data/lib/asterism/cli/mcp.rb +272 -0
- data/lib/asterism/cli/nouns.rb +396 -0
- data/lib/asterism/cli/survey.rb +206 -0
- data/lib/asterism/cli/text.rb +256 -0
- data/lib/asterism/cli.rb +701 -0
- data/lib/asterism/cruby/credentials.rb +61 -116
- data/lib/asterism/cruby/objects.rb +60 -7
- data/lib/asterism/version.rb +1 -1
- data/lib/asterism.rb +1 -1
- data/mrblib/asterism.rb +206 -23
- data/mrblib/credentials.rb +197 -0
- data/mrblib/status.rb +198 -0
- metadata +17 -4
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# What `asterism nodes / objects / tree` and the MCP bridge read from the
|
|
2
|
+
# network, on a connection that is already open (Asterism.connect): the
|
|
3
|
+
# applications and objects from the liveliness tokens, then the meta of
|
|
4
|
+
# the objects, the nodes' profiles and the applications' statuses
|
|
5
|
+
# (docs/protocol.md 5, 7, 8) with one get for each node or application.
|
|
6
|
+
#
|
|
7
|
+
# Not one wildcard get for all (asterism/*/*/@status): behind a router's
|
|
8
|
+
# access control a query must fit the rules of every node it would reach
|
|
9
|
+
# (a game host may answer asterism/games/*/@status, not asterism/*/*/@status),
|
|
10
|
+
# so a wildcard over the nodes is refused where the plain keys pass. The
|
|
11
|
+
# gets go out at once and are read as their replies come (zenoh-c fills
|
|
12
|
+
# them on its own threads), so a listing costs one time limit at most,
|
|
13
|
+
# whatever the number of nodes. What a router refuses is simply not there,
|
|
14
|
+
# and the listing says so instead of calling a node gone.
|
|
15
|
+
module Asterism
|
|
16
|
+
class CLI
|
|
17
|
+
class Survey
|
|
18
|
+
# What watch subscribes to (puts are routed per subscriber, so the
|
|
19
|
+
# wildcards pass where the rules let them through).
|
|
20
|
+
PROFILES_ALL = "asterism/*/@profile"
|
|
21
|
+
STATUS_ALL = "asterism/*/*/@status"
|
|
22
|
+
STEP = 0.005
|
|
23
|
+
|
|
24
|
+
attr_reader :timeout
|
|
25
|
+
|
|
26
|
+
# own: this connection's "<node>/<app>", left out of the listings.
|
|
27
|
+
def initialize(timeout: 2.0, own: nil)
|
|
28
|
+
@timeout = timeout
|
|
29
|
+
@own = own || "#{::Asterism.node_id}/#{::Asterism.app}"
|
|
30
|
+
@object_docs = {}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Waits for the tokens alive at connect to come in (they come in one
|
|
34
|
+
# burst from the router): until the applications stop changing for
|
|
35
|
+
# quiet seconds, at most max seconds.
|
|
36
|
+
def settle(max = 1.5, quiet: 0.3)
|
|
37
|
+
t0 = now
|
|
38
|
+
last = nil
|
|
39
|
+
since = now
|
|
40
|
+
loop do
|
|
41
|
+
cur = [::Asterism.apps, ::Asterism.each("**").size]
|
|
42
|
+
if cur != last
|
|
43
|
+
last = cur
|
|
44
|
+
since = now
|
|
45
|
+
end
|
|
46
|
+
break if now - since >= quiet || now - t0 >= max
|
|
47
|
+
sleep 0.02
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The applications alive, "<node>/<app>", sorted (this one left out).
|
|
52
|
+
def apps(pattern = "*/*")
|
|
53
|
+
(::Asterism.apps(pattern) - [@own]).sort
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The nodes that have an application or an object alive (sorted).
|
|
57
|
+
def nodes
|
|
58
|
+
(apps.map { _1.split("/")[0] } + objects.map { _1.split("/")[0] }).uniq.sort
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The objects alive, "<node>/<app>/<object>", sorted (this
|
|
62
|
+
# application's own left out).
|
|
63
|
+
def objects(pattern = "**")
|
|
64
|
+
::Asterism.each(pattern).map(&:asterism_path).reject { _1.start_with?("#{@own}/") }.sort
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# {path => [{"name", "arity"}, ...]} for the paths given (an object
|
|
68
|
+
# that does not answer its meta in time is left out). One get per
|
|
69
|
+
# application (asterism/<node>/<app>/*/meta), then one for each object
|
|
70
|
+
# it did not bring.
|
|
71
|
+
def metas(paths)
|
|
72
|
+
return {} if paths.empty?
|
|
73
|
+
out = {}
|
|
74
|
+
apps = paths.map { _1.split("/")[0, 2].join("/") }.uniq
|
|
75
|
+
gets(apps.map { "asterism/#{_1}/*/meta" }).each_value do |replies|
|
|
76
|
+
replies.each do |key, payload|
|
|
77
|
+
path = key.delete_prefix("asterism/").delete_suffix("/meta")
|
|
78
|
+
m = meta_list(payload, path)
|
|
79
|
+
out[path] = m if m && paths.include?(path)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
missing = paths - out.keys
|
|
83
|
+
unless missing.empty?
|
|
84
|
+
gets(missing.map { "asterism/#{_1}/meta" }).each do |key, replies|
|
|
85
|
+
path = key.delete_prefix("asterism/").delete_suffix("/meta")
|
|
86
|
+
m = replies.first && meta_list(replies.first[1], path)
|
|
87
|
+
out[path] = m if m
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
out
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# {node => profile without the icon} of the nodes that answered (one
|
|
94
|
+
# get per node, all at once).
|
|
95
|
+
def profiles(list = nodes)
|
|
96
|
+
out = {}
|
|
97
|
+
gets(list.map { "asterism/#{_1}/@profile" }, params: "icon=0").each do |key, replies|
|
|
98
|
+
pr = replies.first && ::Asterism::Profile.check(::Asterism::Codec.unpack(replies.first[1]))
|
|
99
|
+
out[key.split("/")[1]] = pr if pr
|
|
100
|
+
end
|
|
101
|
+
out
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# {"<node>/<app>" => status} of the applications that answered (one
|
|
105
|
+
# get per application, all at once).
|
|
106
|
+
def statuses(list = apps)
|
|
107
|
+
out = {}
|
|
108
|
+
gets(list.map { "asterism/#{_1}/@status" }).each do |key, replies|
|
|
109
|
+
st = replies.first && ::Asterism::Status.check(::Asterism::Codec.unpack(replies.first[1]))
|
|
110
|
+
parts = key.split("/")
|
|
111
|
+
out["#{parts[1]}/#{parts[2]}"] = st if st
|
|
112
|
+
end
|
|
113
|
+
out
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The tree: [{"node", "profile", "apps" => [{"app", "status",
|
|
117
|
+
# "objects" => [{"name", "methods"}]}]}]. meta: false leaves the
|
|
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
|
|
124
|
+
threads = []
|
|
125
|
+
node_list = (app_list.map { _1.split("/")[0] } + objs.map { _1.split("/")[0] }).uniq
|
|
126
|
+
threads << Thread.new { profiles(node_list) }
|
|
127
|
+
threads << Thread.new { statuses(app_list) }
|
|
128
|
+
threads << Thread.new { meta ? metas(objs) : {} }
|
|
129
|
+
prof, stat, met = threads.map(&:value)
|
|
130
|
+
by_node = Hash.new { |h, k| h[k] = {} }
|
|
131
|
+
app_list.each { |a| n, ap = a.split("/"); by_node[n][ap] ||= [] }
|
|
132
|
+
objs.each do |o|
|
|
133
|
+
n, ap, name = o.split("/")
|
|
134
|
+
(by_node[n][ap] ||= []) << name
|
|
135
|
+
end
|
|
136
|
+
by_node.keys.sort.map do |n|
|
|
137
|
+
apps = by_node[n].keys.sort.map do |ap|
|
|
138
|
+
h = { "app" => ap }
|
|
139
|
+
h["status"] = stat["#{n}/#{ap}"] if stat.key?("#{n}/#{ap}")
|
|
140
|
+
h["objects"] = by_node[n][ap].sort.map do |name|
|
|
141
|
+
e = { "name" => name }
|
|
142
|
+
e["methods"] = met["#{n}/#{ap}/#{name}"] if met.key?("#{n}/#{ap}/#{name}")
|
|
143
|
+
d = object_doc("#{n}/#{ap}/#{name}")
|
|
144
|
+
e["doc"] = d if d
|
|
145
|
+
e
|
|
146
|
+
end
|
|
147
|
+
h
|
|
148
|
+
end
|
|
149
|
+
h = { "node" => n }
|
|
150
|
+
h["profile"] = prof[n] if prof[n]
|
|
151
|
+
h["apps"] = apps
|
|
152
|
+
h
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Sends a get on each key at once and waits for all of them (at most
|
|
157
|
+
# the time limit): {key => [[reply key, payload], ...]}.
|
|
158
|
+
def gets(keys, params: nil)
|
|
159
|
+
s = ::Asterism.session
|
|
160
|
+
ms = (@timeout * 1000).round
|
|
161
|
+
pending = keys.to_h { |k| [k, s.get(k, timeout_ms: ms, params: params)] }
|
|
162
|
+
out = keys.to_h { [_1, []] }
|
|
163
|
+
until pending.empty?
|
|
164
|
+
pending.delete_if do |k, g|
|
|
165
|
+
ended = g.done?
|
|
166
|
+
out[k].concat(g.each_reply.map { |r| [r[0], r[1]] })
|
|
167
|
+
ended
|
|
168
|
+
end
|
|
169
|
+
sleep STEP unless pending.empty?
|
|
170
|
+
end
|
|
171
|
+
out
|
|
172
|
+
rescue ::Asterism::Zenoh::Error => e
|
|
173
|
+
raise ::Asterism::Disconnected, e.message
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# A meta reply's methods: [{"name", "arity"} and, when the object
|
|
177
|
+
# tells them (docs/protocol.md 5), "args" (names) and "doc"]. Fields
|
|
178
|
+
# of another shape are left out, unknown ones ignored.
|
|
179
|
+
def meta_list(payload, path = nil)
|
|
180
|
+
m = ::Asterism::Codec.unpack(payload)
|
|
181
|
+
list = m.is_a?(Hash) ? m["methods"] : nil
|
|
182
|
+
return nil unless list.is_a?(Array)
|
|
183
|
+
@object_docs[path] = m["doc"] if path && m["doc"].is_a?(String)
|
|
184
|
+
list.filter_map do |e|
|
|
185
|
+
next unless e.is_a?(Hash) && e["name"]
|
|
186
|
+
h = { "name" => e["name"].to_s, "arity" => e["arity"].to_i }
|
|
187
|
+
args = e["args"]
|
|
188
|
+
h["args"] = args if args.is_a?(Array) && args.all? { _1.is_a?(String) } &&
|
|
189
|
+
(h["arity"].negative? || args.size == h["arity"])
|
|
190
|
+
h["doc"] = e["doc"] if e["doc"].is_a?(String)
|
|
191
|
+
h
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The text an object tells about itself in its meta (read by metas),
|
|
196
|
+
# or nil.
|
|
197
|
+
def object_doc(path)
|
|
198
|
+
@object_docs[path]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def now
|
|
202
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -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
|