pray-cli 1.6.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 +7 -0
- data/CHANGELOG.md +47 -0
- data/LICENSE.md +21 -0
- data/README.md +125 -0
- data/SECURITY.md +28 -0
- data/bin/polyrun +108 -0
- data/bin/pray +26 -0
- data/lib/pray/archive.rb +68 -0
- data/lib/pray/auth_client.rb +97 -0
- data/lib/pray/cli/commands/auth.rb +42 -0
- data/lib/pray/cli/commands/distribution.rb +17 -0
- data/lib/pray/cli/commands/init.rb +62 -0
- data/lib/pray/cli/commands/meta.rb +15 -0
- data/lib/pray/cli/commands/packages.rb +107 -0
- data/lib/pray/cli/commands/trust.rb +73 -0
- data/lib/pray/cli/commands/workflow.rb +126 -0
- data/lib/pray/cli/help.rb +168 -0
- data/lib/pray/cli/helpers.rb +166 -0
- data/lib/pray/cli/parse.rb +136 -0
- data/lib/pray/cli/parse_auth.rb +91 -0
- data/lib/pray/cli/parse_trust.rb +152 -0
- data/lib/pray/cli/suggest.rb +57 -0
- data/lib/pray/cli.rb +117 -0
- data/lib/pray/confess.rb +113 -0
- data/lib/pray/config.rb +52 -0
- data/lib/pray/constraint.rb +80 -0
- data/lib/pray/destination.rb +158 -0
- data/lib/pray/dotenv.rb +46 -0
- data/lib/pray/environment.rb +41 -0
- data/lib/pray/error.rb +55 -0
- data/lib/pray/format_manifest.rb +255 -0
- data/lib/pray/format_serialize.rb +135 -0
- data/lib/pray/git_sources.rb +228 -0
- data/lib/pray/hashing.rb +59 -0
- data/lib/pray/invocation.rb +110 -0
- data/lib/pray/literal.rb +382 -0
- data/lib/pray/lockfile.rb +259 -0
- data/lib/pray/lockfile_serialize.rb +125 -0
- data/lib/pray/manifest.rb +126 -0
- data/lib/pray/manifest_formatter.rb +56 -0
- data/lib/pray/manifest_json.rb +128 -0
- data/lib/pray/manifest_parser.rb +152 -0
- data/lib/pray/manifest_parser_blocks.rb +216 -0
- data/lib/pray/manifest_parser_helpers.rb +208 -0
- data/lib/pray/materialize.rb +120 -0
- data/lib/pray/package_spec.rb +245 -0
- data/lib/pray/path_safety.rb +41 -0
- data/lib/pray/plan.rb +83 -0
- data/lib/pray/project_context.rb +67 -0
- data/lib/pray/publish.rb +162 -0
- data/lib/pray/registry.rb +355 -0
- data/lib/pray/render.rb +360 -0
- data/lib/pray/resolve.rb +396 -0
- data/lib/pray/resolve_context.rb +19 -0
- data/lib/pray/serve.rb +130 -0
- data/lib/pray/serve_federation.rb +109 -0
- data/lib/pray/session.rb +90 -0
- data/lib/pray/ssh_agent.rb +115 -0
- data/lib/pray/statement_surface.rb +262 -0
- data/lib/pray/substitute.rb +50 -0
- data/lib/pray/sync.rb +219 -0
- data/lib/pray/terminal.rb +30 -0
- data/lib/pray/trust.rb +201 -0
- data/lib/pray/trust_feed.rb +110 -0
- data/lib/pray/trust_ops.rb +202 -0
- data/lib/pray/verify.rb +257 -0
- data/lib/pray/version.rb +6 -0
- data/lib/pray-cli.rb +4 -0
- data/lib/pray.rb +46 -0
- data/pray-cli.gemspec +48 -0
- metadata +187 -0
data/lib/pray/session.rb
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Pray
|
|
7
|
+
SessionFile = Struct.new(
|
|
8
|
+
:server_url, :email, :token, :kind, :signer_fingerprint,
|
|
9
|
+
keyword_init: true
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
module Session
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def session_file_path(root)
|
|
16
|
+
File.join(root, ".pray", "session.json")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def persist(root, session)
|
|
20
|
+
path = session_file_path(root)
|
|
21
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
22
|
+
sessions = load_sessions(path)
|
|
23
|
+
existing = sessions.find { |entry| entry.server_url == session.server_url }
|
|
24
|
+
if existing
|
|
25
|
+
existing.email = session.email
|
|
26
|
+
existing.token = session.token
|
|
27
|
+
existing.kind = session.kind
|
|
28
|
+
existing.signer_fingerprint = session.signer_fingerprint
|
|
29
|
+
else
|
|
30
|
+
sessions << session
|
|
31
|
+
end
|
|
32
|
+
document = if sessions.length == 1
|
|
33
|
+
session_to_hash(sessions.first)
|
|
34
|
+
else
|
|
35
|
+
{"sessions" => sessions.map { |entry| session_to_hash(entry) }}
|
|
36
|
+
end
|
|
37
|
+
File.write(path, JSON.pretty_generate(document))
|
|
38
|
+
session
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def load_latest(root)
|
|
42
|
+
sessions = load_sessions(session_file_path(root))
|
|
43
|
+
sessions.reverse.find { |session| !session.email.to_s.strip.empty? }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def current_signer(root)
|
|
47
|
+
session = load_latest(root)
|
|
48
|
+
return session.email if session && !session.email.to_s.strip.empty?
|
|
49
|
+
|
|
50
|
+
"local"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def current_signer_fingerprint(root)
|
|
54
|
+
session = load_latest(root)
|
|
55
|
+
fingerprint = session&.signer_fingerprint.to_s.strip
|
|
56
|
+
fingerprint.empty? ? nil : fingerprint
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def load_sessions(path)
|
|
60
|
+
return [] unless File.file?(path)
|
|
61
|
+
|
|
62
|
+
data = JSON.parse(File.read(path))
|
|
63
|
+
entries = (data.is_a?(Hash) && data.key?("sessions")) ? data["sessions"] : [data]
|
|
64
|
+
Array(entries).map { |entry| session_from_hash(entry) }
|
|
65
|
+
rescue JSON::ParserError => error
|
|
66
|
+
raise Error.parse("session file", error.message)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def session_to_hash(session)
|
|
70
|
+
hash = {
|
|
71
|
+
"server_url" => session.server_url,
|
|
72
|
+
"email" => session.email,
|
|
73
|
+
"token" => session.token,
|
|
74
|
+
"kind" => session.kind
|
|
75
|
+
}
|
|
76
|
+
hash["signer_fingerprint"] = session.signer_fingerprint if session.signer_fingerprint
|
|
77
|
+
hash
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def session_from_hash(entry)
|
|
81
|
+
SessionFile.new(
|
|
82
|
+
server_url: entry["server_url"],
|
|
83
|
+
email: entry["email"],
|
|
84
|
+
token: entry["token"],
|
|
85
|
+
kind: entry["kind"],
|
|
86
|
+
signer_fingerprint: entry["signer_fingerprint"]
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
module Pray
|
|
7
|
+
module SshAgent
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def sign(public_key, message)
|
|
11
|
+
agent_socket = ENV["SSH_AUTH_SOCK"]
|
|
12
|
+
raise Error.unsupported("SSH_AUTH_SOCK is not set") if agent_socket.to_s.empty?
|
|
13
|
+
|
|
14
|
+
raw_key_bytes = parse_ssh_ed25519_public_key(public_key)
|
|
15
|
+
public_key_blob = +""
|
|
16
|
+
write_ssh_string(public_key_blob, "ssh-ed25519")
|
|
17
|
+
write_ssh_string(public_key_blob, raw_key_bytes)
|
|
18
|
+
|
|
19
|
+
payload = +""
|
|
20
|
+
write_ssh_string(payload, public_key_blob)
|
|
21
|
+
write_ssh_string(payload, message)
|
|
22
|
+
write_u32(payload, 0)
|
|
23
|
+
|
|
24
|
+
UNIXSocket.open(agent_socket) do |stream|
|
|
25
|
+
write_ssh_message(stream, 13, payload)
|
|
26
|
+
message_type, response = read_ssh_message(stream)
|
|
27
|
+
unless message_type == 14
|
|
28
|
+
raise Error.resolution("ssh agent returned unexpected message type: #{message_type}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
signature_blob = read_ssh_string_bytes(response)
|
|
32
|
+
parse_ssh_signature_blob(signature_blob)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def parse_ssh_ed25519_public_key(public_key)
|
|
37
|
+
fields = public_key.split(/\s+/)
|
|
38
|
+
algorithm = fields[0]
|
|
39
|
+
raise Error.unsupported("public key must include an algorithm") unless algorithm
|
|
40
|
+
unless algorithm == "ssh-ed25519"
|
|
41
|
+
raise Error.unsupported("unsupported public key algorithm: #{algorithm}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
key_value = fields[1]
|
|
45
|
+
raise Error.unsupported("public key must include key bytes") unless key_value
|
|
46
|
+
|
|
47
|
+
blob = Base64.decode64(key_value)
|
|
48
|
+
cursor = blob.b
|
|
49
|
+
blob_algorithm = read_ssh_string!(cursor)
|
|
50
|
+
unless blob_algorithm == "ssh-ed25519"
|
|
51
|
+
raise Error.parse("public key", "ed25519 public key blob must start with ssh-ed25519")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
key_bytes = read_ssh_string!(cursor)
|
|
55
|
+
unless key_bytes.bytesize == 32
|
|
56
|
+
raise Error.parse("public key", "ed25519 public key must be 32 bytes")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
key_bytes
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse_ssh_signature_blob(signature_blob)
|
|
63
|
+
cursor = signature_blob.b
|
|
64
|
+
algorithm = read_ssh_string!(cursor)
|
|
65
|
+
unless algorithm == "ssh-ed25519"
|
|
66
|
+
raise Error.unsupported("unsupported ssh signature algorithm: #{algorithm}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
Base64.strict_encode64(read_ssh_string!(cursor))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def write_ssh_message(stream, message_type, payload)
|
|
73
|
+
buffer = message_type.chr + payload
|
|
74
|
+
stream.write([buffer.bytesize].pack("N"))
|
|
75
|
+
stream.write(buffer)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def read_ssh_message(stream)
|
|
79
|
+
length = stream.read(4)&.unpack1("N")
|
|
80
|
+
raise Error.resolution("empty ssh agent response") unless length
|
|
81
|
+
|
|
82
|
+
buffer = stream.read(length)
|
|
83
|
+
raise Error.resolution("truncated ssh agent response") if buffer.nil? || buffer.bytesize < 1
|
|
84
|
+
|
|
85
|
+
[buffer.getbyte(0), buffer.byteslice(1, buffer.bytesize - 1).to_s]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def write_ssh_string(buffer, bytes)
|
|
89
|
+
bytes = bytes.b
|
|
90
|
+
buffer << [bytes.bytesize].pack("N")
|
|
91
|
+
buffer << bytes
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def write_u32(buffer, value)
|
|
95
|
+
buffer << [value].pack("N")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def read_ssh_string!(cursor)
|
|
99
|
+
raise Error.resolution("truncated ssh agent response") if cursor.bytesize < 4
|
|
100
|
+
|
|
101
|
+
length = cursor.byteslice(0, 4).unpack1("N")
|
|
102
|
+
cursor.slice!(0, 4)
|
|
103
|
+
raise Error.resolution("truncated ssh agent response") if cursor.bytesize < length
|
|
104
|
+
|
|
105
|
+
value = cursor.byteslice(0, length)
|
|
106
|
+
cursor.slice!(0, length)
|
|
107
|
+
value
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def read_ssh_string_bytes(buffer)
|
|
111
|
+
cursor = buffer.b
|
|
112
|
+
read_ssh_string!(cursor)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pray
|
|
4
|
+
module StatementSurface
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def expand_statement_surface(statement)
|
|
8
|
+
trimmed = statement.strip
|
|
9
|
+
return [] if trimmed.empty?
|
|
10
|
+
|
|
11
|
+
Literal.split_top_level(trimmed, ";").flat_map { |segment| expand_one_surface(segment) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def expand_one_surface(statement)
|
|
15
|
+
trimmed = statement.strip
|
|
16
|
+
return [] if trimmed.empty?
|
|
17
|
+
|
|
18
|
+
braced = expand_brace_block(trimmed)
|
|
19
|
+
return braced if braced
|
|
20
|
+
|
|
21
|
+
[normalize_keyword_call(trimmed)]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def expand_brace_block(statement)
|
|
25
|
+
keyword = leading_identifier(statement)
|
|
26
|
+
return nil unless keyword
|
|
27
|
+
|
|
28
|
+
after_keyword = statement[keyword.length..].lstrip
|
|
29
|
+
# Only keyword{…}, keyword(…){…}, or keyword "…"{…} — not spec.exports = {…}.
|
|
30
|
+
header = split_brace_header(after_keyword)
|
|
31
|
+
return nil unless header
|
|
32
|
+
|
|
33
|
+
args, after_open = header
|
|
34
|
+
close_offset = matching_close_brace(after_open)
|
|
35
|
+
return nil unless close_offset
|
|
36
|
+
return nil unless after_open[(close_offset + 1)..].to_s.strip.empty?
|
|
37
|
+
|
|
38
|
+
body = after_open[0...close_offset].strip
|
|
39
|
+
return nil unless Literal.is_balanced?(body)
|
|
40
|
+
|
|
41
|
+
header_args = unwrap_outer_parens(args)
|
|
42
|
+
open = header_args.empty? ? "#{keyword} do" : "#{keyword} #{header_args} do"
|
|
43
|
+
output = [open]
|
|
44
|
+
output.concat(expand_statement_surface(body)) unless body.empty?
|
|
45
|
+
output << "end"
|
|
46
|
+
output
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def split_brace_header(after_keyword)
|
|
50
|
+
if after_keyword.start_with?("{")
|
|
51
|
+
return ["", after_keyword[1..]]
|
|
52
|
+
end
|
|
53
|
+
if after_keyword.start_with?("(")
|
|
54
|
+
close = matching_close_paren(after_keyword)
|
|
55
|
+
return nil unless close
|
|
56
|
+
|
|
57
|
+
trailing = after_keyword[(close + 1)..].to_s.lstrip
|
|
58
|
+
return nil unless trailing.start_with?("{")
|
|
59
|
+
|
|
60
|
+
return [after_keyword[1...close].strip, trailing[1..]]
|
|
61
|
+
end
|
|
62
|
+
first = after_keyword[0]
|
|
63
|
+
return nil unless ['"', "'", ":"].include?(first)
|
|
64
|
+
|
|
65
|
+
brace_offset = find_top_level_char(after_keyword, "{")
|
|
66
|
+
return nil unless brace_offset
|
|
67
|
+
|
|
68
|
+
[after_keyword[0...brace_offset].strip, after_keyword[(brace_offset + 1)..]]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def normalize_keyword_call(statement)
|
|
72
|
+
spaced = normalize_spaced_block_opener(statement)
|
|
73
|
+
return spaced if spaced
|
|
74
|
+
|
|
75
|
+
keyword = leading_identifier(statement)
|
|
76
|
+
return statement unless keyword
|
|
77
|
+
|
|
78
|
+
after_keyword = statement[keyword.length..].lstrip
|
|
79
|
+
return statement unless after_keyword.start_with?("(")
|
|
80
|
+
|
|
81
|
+
close = matching_close_paren(after_keyword)
|
|
82
|
+
return statement unless close
|
|
83
|
+
|
|
84
|
+
inner = after_keyword[1...close].strip
|
|
85
|
+
trailing = after_keyword[(close + 1)..].to_s.strip
|
|
86
|
+
trailing.empty? ? "#{keyword} #{inner}" : "#{keyword} #{inner} #{trailing}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def normalize_spaced_block_opener(statement)
|
|
90
|
+
trimmed = statement.strip
|
|
91
|
+
%w[pray template].each do |keyword|
|
|
92
|
+
next unless trimmed.start_with?(keyword)
|
|
93
|
+
|
|
94
|
+
rest = trimmed[keyword.length..].lstrip
|
|
95
|
+
return "#{keyword} do" if rest == "do"
|
|
96
|
+
end
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def split_symbol_assignment(statement)
|
|
101
|
+
trimmed = statement.strip
|
|
102
|
+
call = split_symbol_call(trimmed)
|
|
103
|
+
return call if call
|
|
104
|
+
|
|
105
|
+
match = trimmed.match(/\A(\S+)\s+(.+)\z/)
|
|
106
|
+
return nil unless match
|
|
107
|
+
|
|
108
|
+
key = match[1].strip
|
|
109
|
+
value = match[2].strip
|
|
110
|
+
return nil if key.empty? || value.empty?
|
|
111
|
+
|
|
112
|
+
[key, value]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def split_symbol_call(statement)
|
|
116
|
+
key = leading_identifier(statement)
|
|
117
|
+
return nil unless key
|
|
118
|
+
|
|
119
|
+
after_key = statement[key.length..].lstrip
|
|
120
|
+
return nil unless after_key.start_with?("(") && after_key.end_with?(")")
|
|
121
|
+
return nil unless matching_close_paren(after_key) == after_key.length - 1
|
|
122
|
+
|
|
123
|
+
inner = after_key[1...-1].strip
|
|
124
|
+
return nil if inner.empty?
|
|
125
|
+
|
|
126
|
+
[key, inner]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def leading_identifier(input)
|
|
130
|
+
trimmed = input.lstrip
|
|
131
|
+
end_index = 0
|
|
132
|
+
while end_index < trimmed.length
|
|
133
|
+
character = trimmed[end_index]
|
|
134
|
+
break unless character.match?(/[A-Za-z0-9_]/)
|
|
135
|
+
|
|
136
|
+
end_index += 1
|
|
137
|
+
end
|
|
138
|
+
return nil if end_index.zero?
|
|
139
|
+
|
|
140
|
+
ident = trimmed[0...end_index]
|
|
141
|
+
return nil unless ident[0].match?(/[A-Za-z]/)
|
|
142
|
+
|
|
143
|
+
ident
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def unwrap_outer_parens(input)
|
|
147
|
+
trimmed = input.strip
|
|
148
|
+
if trimmed.start_with?("(") && matching_close_paren(trimmed) == trimmed.length - 1
|
|
149
|
+
return trimmed[1...-1].strip
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
trimmed
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def matching_close_paren(input)
|
|
156
|
+
matching_close_delimited(input, "(", ")")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def matching_close_brace(input)
|
|
160
|
+
depth = 1
|
|
161
|
+
quote = nil
|
|
162
|
+
escaped = false
|
|
163
|
+
input.each_char.with_index do |character, index|
|
|
164
|
+
if quote
|
|
165
|
+
if escaped
|
|
166
|
+
escaped = false
|
|
167
|
+
elsif character == "\\"
|
|
168
|
+
escaped = true
|
|
169
|
+
elsif character == quote
|
|
170
|
+
quote = nil
|
|
171
|
+
end
|
|
172
|
+
next
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
case character
|
|
176
|
+
when '"', "'"
|
|
177
|
+
quote = character
|
|
178
|
+
when "{"
|
|
179
|
+
depth += 1
|
|
180
|
+
when "}"
|
|
181
|
+
depth -= 1
|
|
182
|
+
return index if depth.zero?
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def matching_close_delimited(input, open, close)
|
|
189
|
+
return nil unless input.start_with?(open)
|
|
190
|
+
|
|
191
|
+
depth = 0
|
|
192
|
+
quote = nil
|
|
193
|
+
escaped = false
|
|
194
|
+
input.each_char.with_index do |character, index|
|
|
195
|
+
if quote
|
|
196
|
+
if escaped
|
|
197
|
+
escaped = false
|
|
198
|
+
elsif character == "\\"
|
|
199
|
+
escaped = true
|
|
200
|
+
elsif character == quote
|
|
201
|
+
quote = nil
|
|
202
|
+
end
|
|
203
|
+
next
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
if character == open
|
|
207
|
+
depth += 1
|
|
208
|
+
elsif character == close
|
|
209
|
+
depth -= 1
|
|
210
|
+
return index if depth.zero?
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def find_top_level_char(input, needle)
|
|
217
|
+
depth = 0
|
|
218
|
+
quote = nil
|
|
219
|
+
escaped = false
|
|
220
|
+
input.each_char.with_index do |character, index|
|
|
221
|
+
if quote
|
|
222
|
+
if escaped
|
|
223
|
+
escaped = false
|
|
224
|
+
elsif character == "\\"
|
|
225
|
+
escaped = true
|
|
226
|
+
elsif character == quote
|
|
227
|
+
quote = nil
|
|
228
|
+
end
|
|
229
|
+
next
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
case character
|
|
233
|
+
when '"', "'"
|
|
234
|
+
quote = character
|
|
235
|
+
when "(", "[", "{"
|
|
236
|
+
return index if depth.zero? && character == needle
|
|
237
|
+
|
|
238
|
+
depth += 1
|
|
239
|
+
when ")", "]", "}"
|
|
240
|
+
depth -= 1
|
|
241
|
+
else
|
|
242
|
+
return index if depth.zero? && character == needle
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
class Reader
|
|
249
|
+
def initialize
|
|
250
|
+
@pending = []
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def push_raw(statement)
|
|
254
|
+
@pending.concat(StatementSurface.expand_statement_surface(statement))
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def next
|
|
258
|
+
@pending.shift
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pray
|
|
4
|
+
module Substitute
|
|
5
|
+
PLACEHOLDER_PREFIX = "((pray:"
|
|
6
|
+
PLACEHOLDER_SUFFIX = "))"
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def pray_symbol_key?(key)
|
|
11
|
+
return false if key.nil? || key.empty?
|
|
12
|
+
|
|
13
|
+
key.match?(/\A[A-Za-z0-9._\/-]+\z/)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def substitute_pray_symbols(text, symbols)
|
|
17
|
+
lookup = symbols.is_a?(Hash) ? symbols : symbols.to_h
|
|
18
|
+
output = +""
|
|
19
|
+
rest = text
|
|
20
|
+
|
|
21
|
+
loop do
|
|
22
|
+
start = rest.index(PLACEHOLDER_PREFIX)
|
|
23
|
+
unless start
|
|
24
|
+
output << rest
|
|
25
|
+
return output
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
output << rest[0...start]
|
|
29
|
+
after_prefix = rest[(start + PLACEHOLDER_PREFIX.length)..]
|
|
30
|
+
end_index = after_prefix.index(PLACEHOLDER_SUFFIX)
|
|
31
|
+
raise Error.render("unclosed ((pray:...) placeholder") unless end_index
|
|
32
|
+
|
|
33
|
+
path = after_prefix[0...end_index]
|
|
34
|
+
unless pray_symbol_key?(path)
|
|
35
|
+
raise Error.render("invalid ((pray:...)) path `#{path}`")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
value = lookup[path]
|
|
39
|
+
unless value
|
|
40
|
+
raise Error.render(
|
|
41
|
+
"unknown pray symbol `#{path}`; declare it in `pray do ... end`"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
output << value
|
|
46
|
+
rest = after_prefix[(end_index + PLACEHOLDER_SUFFIX.length)..]
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|