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,282 @@
|
|
|
1
|
+
# The shell's Tab completion: `asterism completion bash|zsh` prints a
|
|
2
|
+
# script, and the script calls the hidden `asterism __complete` on each
|
|
3
|
+
# Tab, which prints the candidates, one a line.
|
|
4
|
+
#
|
|
5
|
+
# The commands, the verbs and the options come from the command line's own
|
|
6
|
+
# tables (no network). The names on the network (nodes, node/app,
|
|
7
|
+
# node/app/object, an object's methods) come from the network named on the
|
|
8
|
+
# line (--router, --credentials, --peer) or in the environment, asked
|
|
9
|
+
# briefly (Complete::BUDGET: nothing comes back when it does not answer in
|
|
10
|
+
# time, the shell is not kept waiting) and remembered for a few seconds
|
|
11
|
+
# (Cache: a file per network under $XDG_CACHE_HOME/asterism, named by a
|
|
12
|
+
# digest of where the network is; it holds names only, never a key or a
|
|
13
|
+
# certificate). A name with "/" is completed a part at a time:
|
|
14
|
+
# `ga<Tab>` gives `games/`, `games/games/<Tab>` the objects.
|
|
15
|
+
require "json"
|
|
16
|
+
require "digest"
|
|
17
|
+
require "fileutils"
|
|
18
|
+
require "shellwords"
|
|
19
|
+
|
|
20
|
+
module Asterism
|
|
21
|
+
class CLI
|
|
22
|
+
class Complete
|
|
23
|
+
# Seconds the network may take to answer one Tab (connecting, the
|
|
24
|
+
# tokens, a meta get).
|
|
25
|
+
BUDGET = 1.5
|
|
26
|
+
# What a line of the output means "complete a file name here".
|
|
27
|
+
FILES = ":files"
|
|
28
|
+
|
|
29
|
+
# spec: the CLI (its command tables and option lists); source: what
|
|
30
|
+
# the network names (#names, #methods(path), #all_methods), each
|
|
31
|
+
# nil when it did not answer.
|
|
32
|
+
def initialize(spec, source)
|
|
33
|
+
@spec = spec
|
|
34
|
+
@source = source
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The candidates for the last word of words (the words after
|
|
38
|
+
# `asterism`, the last one the word being completed, "" for a new
|
|
39
|
+
# one), those that start with it, sorted.
|
|
40
|
+
def candidates(words)
|
|
41
|
+
words = words.dup
|
|
42
|
+
cur = words.pop || ""
|
|
43
|
+
pos = []
|
|
44
|
+
prev = nil
|
|
45
|
+
words.each do |w|
|
|
46
|
+
if prev
|
|
47
|
+
prev = nil
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
if w.start_with?("-") && w.size > 1
|
|
51
|
+
prev = w if @spec.option_takes_value?(w)
|
|
52
|
+
next
|
|
53
|
+
end
|
|
54
|
+
pos << w
|
|
55
|
+
end
|
|
56
|
+
key, args = @spec.command_key(pos)
|
|
57
|
+
list =
|
|
58
|
+
if prev then values(prev, key, args)
|
|
59
|
+
elsif cur.start_with?("-") then key ? @spec.options_for(key) : []
|
|
60
|
+
else positional(pos, key, args, cur)
|
|
61
|
+
end
|
|
62
|
+
return list if list == [FILES]
|
|
63
|
+
list.select { _1.start_with?(cur) }.uniq.sort
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def values(opt, key, args)
|
|
69
|
+
case opt
|
|
70
|
+
when "-o", "--output" then %w[json yaml]
|
|
71
|
+
when "--credentials" then [FILES]
|
|
72
|
+
when "--call" then key == "ping" && args[0] ? methods_of(args[0]) : []
|
|
73
|
+
else []
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def positional(pos, key, args, cur)
|
|
78
|
+
return @spec.command_words if pos.empty?
|
|
79
|
+
return @spec.verbs(pos[0]) if pos.size == 1 && @spec.noun?(pos[0])
|
|
80
|
+
return [] unless key
|
|
81
|
+
i = args.size
|
|
82
|
+
case [key, i]
|
|
83
|
+
when ["node info", 0] then nodes
|
|
84
|
+
when ["app info", 0], ["status get", 0], ["status echo", 0], ["status hz", 0] then staged(cur, apps, 2)
|
|
85
|
+
when ["object list", 0], ["object info", 0], ["objects", 0], ["call", 0] then staged(cur, objects, 3)
|
|
86
|
+
when ["ping", 0], ["echo", 0] then (cur.include?("/") ? [] : nodes) + staged(cur, objects, 3)
|
|
87
|
+
when ["object find", 0] then @source.all_methods || []
|
|
88
|
+
when ["call", 1] then methods_of(args[0])
|
|
89
|
+
when ["completion", 0] then %w[bash zsh]
|
|
90
|
+
when ["help", 0] then @spec.nouns
|
|
91
|
+
else []
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def methods_of(path)
|
|
96
|
+
path.count("/") == 2 ? (@source.methods(path) || []) : []
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def names
|
|
100
|
+
@names ||= @source.names || { "apps" => [], "objects" => [] }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def objects
|
|
104
|
+
names["objects"]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The applications, with those that only objects tell of.
|
|
108
|
+
def apps
|
|
109
|
+
(names["apps"] + objects.map { _1.split("/")[0, 2].join("/") }).uniq
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def nodes
|
|
113
|
+
(apps + objects).map { _1.split("/")[0] }.uniq
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The next part of the paths (depth parts each) that start with cur:
|
|
117
|
+
# the parts up to the one cur is in, with "/" after it when more come.
|
|
118
|
+
def staged(cur, paths, depth)
|
|
119
|
+
k = cur.count("/")
|
|
120
|
+
return [] if k >= depth
|
|
121
|
+
paths.filter_map do |p|
|
|
122
|
+
s = p.split("/")
|
|
123
|
+
next if s.size < depth
|
|
124
|
+
c = s[0..k].join("/")
|
|
125
|
+
c += "/" if k + 1 < depth
|
|
126
|
+
c if c.start_with?(cur)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
public
|
|
131
|
+
|
|
132
|
+
# The words of a command line up to the cursor (bash's COMP_LINE cut
|
|
133
|
+
# at COMP_POINT): the words after the command, the last one the word
|
|
134
|
+
# being completed ("" after a space). An open quote is closed.
|
|
135
|
+
def self.split_line(line)
|
|
136
|
+
words = begin
|
|
137
|
+
Shellwords.split(line)
|
|
138
|
+
rescue ArgumentError
|
|
139
|
+
begin
|
|
140
|
+
Shellwords.split("#{line}\"")
|
|
141
|
+
rescue ArgumentError
|
|
142
|
+
Shellwords.split("#{line}'")
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
words << "" if line.match?(/\s\z/) || words.empty?
|
|
146
|
+
words.drop(1)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Bash splits the word being completed at ":" and "=" (COMP_WORDBREAKS):
|
|
150
|
+
# the candidates without what comes before the last of them in cur.
|
|
151
|
+
def self.bash_trim(cands, cur)
|
|
152
|
+
cut = cur.rindex(/[:=]/)
|
|
153
|
+
return cands unless cut
|
|
154
|
+
cands.map { _1.start_with?(cur[0..cut]) ? _1[(cut + 1)..] : _1 }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# ------------------------------------------------------------ the cache
|
|
158
|
+
|
|
159
|
+
# What __complete remembers of a network for a few seconds: a JSON
|
|
160
|
+
# file per network ({entry => {"t" => seconds, "v" => value}}), the
|
|
161
|
+
# directory 0700 and the files 0600. The network's identity is a
|
|
162
|
+
# digest of where it is (router, the credentials file's path, peer);
|
|
163
|
+
# nothing else about it is written.
|
|
164
|
+
class Cache
|
|
165
|
+
TTL = 20
|
|
166
|
+
|
|
167
|
+
def initialize(dir:, network:, ttl: TTL, clock: -> { Time.now.to_f })
|
|
168
|
+
@dir = dir
|
|
169
|
+
@ttl = ttl
|
|
170
|
+
@clock = clock
|
|
171
|
+
id = network.map { _1.to_s }.join("\n")
|
|
172
|
+
@file = File.join(dir, "#{Digest::SHA256.hexdigest(id)[0, 24]}.json")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
attr_reader :file
|
|
176
|
+
|
|
177
|
+
# The cache directory: $XDG_CACHE_HOME/asterism, else
|
|
178
|
+
# ~/.cache/asterism.
|
|
179
|
+
def self.dir(env)
|
|
180
|
+
base = env["XDG_CACHE_HOME"].to_s
|
|
181
|
+
base = File.join(Dir.home, ".cache") if base.empty?
|
|
182
|
+
File.join(base, "asterism")
|
|
183
|
+
rescue ArgumentError
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# The entry's value when it is fresh, else the block's (kept unless
|
|
188
|
+
# it is nil: what did not answer is asked again next time).
|
|
189
|
+
def fetch(entry)
|
|
190
|
+
data = read
|
|
191
|
+
e = data[entry]
|
|
192
|
+
now = @clock.call
|
|
193
|
+
return e["v"] if e.is_a?(Hash) && e["t"].is_a?(Numeric) && now - e["t"] < @ttl && now >= e["t"]
|
|
194
|
+
v = yield
|
|
195
|
+
return v if v.nil?
|
|
196
|
+
data = read.reject { |_, x| !x.is_a?(Hash) || !x["t"].is_a?(Numeric) || now - x["t"] >= @ttl }
|
|
197
|
+
data[entry] = { "t" => now, "v" => v }
|
|
198
|
+
write(data)
|
|
199
|
+
v
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
private
|
|
203
|
+
|
|
204
|
+
def read
|
|
205
|
+
h = JSON.parse(File.read(@file))
|
|
206
|
+
h.is_a?(Hash) ? h : {}
|
|
207
|
+
rescue StandardError
|
|
208
|
+
{}
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def write(data)
|
|
212
|
+
FileUtils.mkdir_p(@dir, mode: 0o700)
|
|
213
|
+
tmp = "#{@file}.#{Process.pid}.tmp"
|
|
214
|
+
File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { _1.write(JSON.generate(data)) }
|
|
215
|
+
File.rename(tmp, @file)
|
|
216
|
+
rescue StandardError
|
|
217
|
+
begin
|
|
218
|
+
File.unlink(tmp) if tmp
|
|
219
|
+
rescue StandardError
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
nil
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# ------------------------------------------------------------ the scripts
|
|
227
|
+
|
|
228
|
+
BASH = <<~'BASH'
|
|
229
|
+
# asterism's Tab completion for bash: source <(asterism completion bash)
|
|
230
|
+
# (or put that line in ~/.bashrc). Each Tab runs `asterism __complete`;
|
|
231
|
+
# the names on the network come from the network named on the line
|
|
232
|
+
# (--router, --credentials, --peer) or in ASTERISM_CREDENTIALS /
|
|
233
|
+
# ASTERISM_ROUTER, asked briefly and remembered for a few seconds.
|
|
234
|
+
_asterism() {
|
|
235
|
+
local cur=${COMP_WORDS[COMP_CWORD]}
|
|
236
|
+
local IFS=$'\n'
|
|
237
|
+
local out
|
|
238
|
+
out=$(asterism __complete --bash "${COMP_LINE:0:COMP_POINT}" 2>/dev/null)
|
|
239
|
+
if [[ $out == ":files" ]]; then
|
|
240
|
+
compopt -o filenames 2>/dev/null
|
|
241
|
+
COMPREPLY=($(compgen -f -- "$cur"))
|
|
242
|
+
return 0
|
|
243
|
+
fi
|
|
244
|
+
COMPREPLY=($out)
|
|
245
|
+
local c
|
|
246
|
+
for c in "${COMPREPLY[@]}"; do
|
|
247
|
+
if [[ $c == */ ]]; then
|
|
248
|
+
compopt -o nospace 2>/dev/null
|
|
249
|
+
break
|
|
250
|
+
fi
|
|
251
|
+
done
|
|
252
|
+
return 0
|
|
253
|
+
}
|
|
254
|
+
complete -F _asterism asterism
|
|
255
|
+
BASH
|
|
256
|
+
|
|
257
|
+
ZSH = <<~'ZSH'
|
|
258
|
+
# asterism's Tab completion for zsh: source <(asterism completion zsh)
|
|
259
|
+
# after compinit (or put that line in ~/.zshrc). Each Tab runs
|
|
260
|
+
# `asterism __complete`; the names on the network come from the network
|
|
261
|
+
# named on the line (--router, --credentials, --peer) or in
|
|
262
|
+
# ASTERISM_CREDENTIALS / ASTERISM_ROUTER, asked briefly and remembered
|
|
263
|
+
# for a few seconds.
|
|
264
|
+
_asterism() {
|
|
265
|
+
local -a cands slash plain
|
|
266
|
+
cands=("${(@f)$(asterism __complete -- "${(@)words[2,CURRENT]}" 2>/dev/null)}")
|
|
267
|
+
cands=(${cands:#})
|
|
268
|
+
if [[ ${cands[1]} == ":files" ]]; then
|
|
269
|
+
_files
|
|
270
|
+
return
|
|
271
|
+
fi
|
|
272
|
+
slash=(${(M)cands:#*/})
|
|
273
|
+
plain=(${cands:#*/})
|
|
274
|
+
(( ${#plain} )) && compadd -- "${plain[@]}"
|
|
275
|
+
(( ${#slash} )) && compadd -S '' -- "${slash[@]}"
|
|
276
|
+
return 0
|
|
277
|
+
}
|
|
278
|
+
compdef _asterism asterism
|
|
279
|
+
ZSH
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# `asterism status hz`: how often an application's status changes, as
|
|
2
|
+
# `ros2 topic hz` tells how often a topic is published. Each change is a
|
|
3
|
+
# time (a monotonic clock, given or Process.clock_gettime); the intervals
|
|
4
|
+
# between the last ones (a window of them) give the average rate and the
|
|
5
|
+
# intervals' min, max and standard deviation. A put that brings the same
|
|
6
|
+
# status again (as after a reconnection) is not a change.
|
|
7
|
+
module Asterism
|
|
8
|
+
class CLI
|
|
9
|
+
class Hz
|
|
10
|
+
attr_reader :count
|
|
11
|
+
|
|
12
|
+
# window: how many of the last intervals count. clock: a callable
|
|
13
|
+
# that returns the time in seconds (monotonic).
|
|
14
|
+
def initialize(window: 1000, clock: nil)
|
|
15
|
+
@window = window
|
|
16
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
17
|
+
@times = []
|
|
18
|
+
@last = nil
|
|
19
|
+
@seeded = false
|
|
20
|
+
@count = 0
|
|
21
|
+
@reported = 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The status there at the start: what the first put is compared with
|
|
25
|
+
# (not a change itself).
|
|
26
|
+
def seed(status)
|
|
27
|
+
@last = status
|
|
28
|
+
@seeded = true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A put of the status: a change (true) when it differs from the last
|
|
32
|
+
# one seen (the first put without a seed is one).
|
|
33
|
+
def put(status)
|
|
34
|
+
return false if @seeded && status == @last
|
|
35
|
+
@last = status
|
|
36
|
+
@seeded = true
|
|
37
|
+
add(@clock.call)
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# A change at time t (seconds).
|
|
42
|
+
def add(t)
|
|
43
|
+
@count += 1
|
|
44
|
+
@times << t
|
|
45
|
+
@times.shift while @times.size > @window + 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# {"rate_hz", "min_s", "max_s", "std_s", "window", "count"} over the
|
|
49
|
+
# intervals in the window, or nil before two changes.
|
|
50
|
+
def stats
|
|
51
|
+
return nil if @times.size < 2
|
|
52
|
+
ints = @times.each_cons(2).map { |a, b| b - a }
|
|
53
|
+
avg = ints.sum / ints.size
|
|
54
|
+
var = ints.sum { (_1 - avg)**2 } / ints.size
|
|
55
|
+
{ "rate_hz" => avg.positive? ? 1.0 / avg : nil, "min_s" => ints.min, "max_s" => ints.max,
|
|
56
|
+
"std_s" => Math.sqrt(var), "window" => ints.size, "count" => @count }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# stats when a change came since the last report (else nil): what a
|
|
60
|
+
# period prints.
|
|
61
|
+
def report
|
|
62
|
+
return nil if @count == @reported
|
|
63
|
+
s = stats
|
|
64
|
+
@reported = @count if s
|
|
65
|
+
s
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The lines of a report, as ros2 topic hz prints them.
|
|
69
|
+
def self.text(s)
|
|
70
|
+
rate = s["rate_hz"] ? format("%.3f", s["rate_hz"]) : "inf"
|
|
71
|
+
["average rate: #{rate} Hz",
|
|
72
|
+
format(" min: %.3f s max: %.3f s std dev: %.3f s window: %d", s["min_s"], s["max_s"], s["std_s"],
|
|
73
|
+
s["window"])]
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# The arguments of `asterism call` (and of the MCP bridge's tests): each is
|
|
2
|
+
# read as JSON, else as a Ruby literal, else taken as the text it is.
|
|
3
|
+
#
|
|
4
|
+
# Literal.parse('{"seat": 1}') # => {"seat" => 1} (JSON)
|
|
5
|
+
# Literal.parse("[1, :a, nil]") # => [1, :a, nil] (Ruby)
|
|
6
|
+
# Literal.parse("{seat: 1}") # => {seat: 1} (Ruby)
|
|
7
|
+
# Literal.parse("hello") # => "hello" (text)
|
|
8
|
+
#
|
|
9
|
+
# The Ruby form is read with Ripper and only literals are taken: nil,
|
|
10
|
+
# true, false, integers, floats, strings without interpolation, symbols,
|
|
11
|
+
# arrays (also %w / %i) and hashes. Nothing is evaluated: anything else (a
|
|
12
|
+
# method call, a constant, a variable) makes it not a literal, and the
|
|
13
|
+
# argument is then the text as written.
|
|
14
|
+
require "json"
|
|
15
|
+
require "ripper"
|
|
16
|
+
|
|
17
|
+
module Asterism
|
|
18
|
+
class CLI
|
|
19
|
+
module Literal
|
|
20
|
+
class NotLiteral < StandardError; end
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# The value of one argument (see above).
|
|
25
|
+
def parse(text)
|
|
26
|
+
json(text)
|
|
27
|
+
rescue JSON::ParserError
|
|
28
|
+
begin
|
|
29
|
+
ruby(text)
|
|
30
|
+
rescue NotLiteral
|
|
31
|
+
text
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# JSON: a value of any kind at the top ("1", "\"x\"", "null" too).
|
|
36
|
+
def json(text)
|
|
37
|
+
JSON.parse(text)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A Ruby literal, or NotLiteral.
|
|
41
|
+
def ruby(text)
|
|
42
|
+
tree = Ripper.sexp(text)
|
|
43
|
+
raise NotLiteral, "not Ruby" unless tree && tree[0] == :program
|
|
44
|
+
body = tree[1]
|
|
45
|
+
raise NotLiteral, "one value expected" unless body.is_a?(Array) && body.size == 1
|
|
46
|
+
node(body[0])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def node(n)
|
|
50
|
+
raise NotLiteral, "unexpected #{n.inspect}" unless n.is_a?(Array)
|
|
51
|
+
case n[0]
|
|
52
|
+
when :@int then Integer(n[1].delete("_"), 0)
|
|
53
|
+
when :@float then Float(n[1].delete("_"))
|
|
54
|
+
when :unary
|
|
55
|
+
raise NotLiteral, "only - before a number" unless n[1] == :-@ && %i[@int @float].include?(n[2][0])
|
|
56
|
+
-node(n[2])
|
|
57
|
+
when :var_ref, :@kw then keyword(n[0] == :var_ref ? n[1] : n)
|
|
58
|
+
when :string_literal then string(n[1])
|
|
59
|
+
when :symbol_literal then symbol(n[1])
|
|
60
|
+
when :dyna_symbol then string(n[1]).to_sym
|
|
61
|
+
when :array then array(n[1])
|
|
62
|
+
when :hash then hash(n[1])
|
|
63
|
+
when :bare_assoc_hash then pairs(n[1])
|
|
64
|
+
else raise NotLiteral, "not a literal: #{n[0]}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def keyword(kw)
|
|
69
|
+
raise NotLiteral, "not a keyword" unless kw[0] == :@kw
|
|
70
|
+
case kw[1]
|
|
71
|
+
when "nil" then nil
|
|
72
|
+
when "true" then true
|
|
73
|
+
when "false" then false
|
|
74
|
+
else raise NotLiteral, "keyword #{kw[1]}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# string_content: plain parts only (no #{...}).
|
|
79
|
+
def string(content)
|
|
80
|
+
return "" if content.nil?
|
|
81
|
+
parts = content[0] == :string_content ? content[1..] : content
|
|
82
|
+
parts.map do |p|
|
|
83
|
+
raise NotLiteral, "interpolation" unless p.is_a?(Array) && p[0] == :@tstring_content
|
|
84
|
+
unescape(p[1])
|
|
85
|
+
end.join
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The escapes of a double-quoted string (\n, \t, \", \\, \u...).
|
|
89
|
+
def unescape(s)
|
|
90
|
+
return s unless s.include?("\\")
|
|
91
|
+
"\"#{s}\"".undump
|
|
92
|
+
rescue RuntimeError
|
|
93
|
+
s
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def symbol(sym)
|
|
97
|
+
raise NotLiteral, "symbol" unless sym[0] == :symbol
|
|
98
|
+
sym[1][1].to_sym
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def array(items)
|
|
102
|
+
return [] if items.nil?
|
|
103
|
+
items.map do |e|
|
|
104
|
+
if e.is_a?(Array) && e[0] == :@tstring_content # %w[...]
|
|
105
|
+
e[1]
|
|
106
|
+
elsif e.is_a?(Array) && %i[qwords_add words_add qsymbols_add symbols_add].include?(e[0])
|
|
107
|
+
raise NotLiteral, "old Ripper form"
|
|
108
|
+
elsif e.is_a?(Array) && e[0] == :@ident # %i[...]
|
|
109
|
+
e[1].to_sym
|
|
110
|
+
else
|
|
111
|
+
node(e)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def hash(list)
|
|
117
|
+
return {} if list.nil?
|
|
118
|
+
raise NotLiteral, "hash" unless list[0] == :assoclist_from_args
|
|
119
|
+
pairs(list[1])
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def pairs(assocs)
|
|
123
|
+
assocs.to_h do |a|
|
|
124
|
+
raise NotLiteral, "not a key => value" unless a[0] == :assoc_new
|
|
125
|
+
k = a[1]
|
|
126
|
+
key = k[0] == :@label ? k[1].chomp(":").to_sym : node(k)
|
|
127
|
+
[key, node(a[2])]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|