haveapi-rust-client 0.30.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,307 @@
1
+ require 'erb'
2
+ require 'fileutils'
3
+ require 'tmpdir'
4
+ require 'open3'
5
+ require 'fiddle/import'
6
+ require 'tomlrb'
7
+
8
+ module HaveAPI::RustClient
9
+ class Generator
10
+ ROOT = File.expand_path('../../..', __dir__)
11
+ CONFIG_KEYS = %w[crate_name crate_version license edition rust_version rustfmt_version].freeze
12
+ OWNER_FILE = '.haveapi-generated.json'.freeze
13
+ attr_reader :schema, :config, :ir
14
+
15
+ def initialize(schema:, config:)
16
+ @schema = Snapshot.validate(schema)
17
+ @config = config
18
+ validate_config
19
+ @ir = IR.new(schema)
20
+ end
21
+
22
+ def generate(output:, check: false)
23
+ output = File.expand_path(output)
24
+ validate_destination(output)
25
+ validate_manifest(output)
26
+ formatter = ENV.fetch('RUSTFMT', 'rustfmt')
27
+ stdout, _stderr, status = Open3.capture3(formatter, '--version')
28
+ unless status.success? && stdout.strip == config.fetch('rustfmt_version')
29
+ raise Error, 'rustfmt version differs from generator configuration'
30
+ end
31
+
32
+ Dir.mktmpdir('haveapi-rust-') do |temp|
33
+ render_tree(temp)
34
+ generated = Dir.glob(File.join(temp, '**/*.rs')).reject { |path| path.start_with?(File.join(temp, 'runtime/')) }
35
+ _stdout, stderr, status = Open3.capture3(
36
+ formatter, '--edition', config.fetch('edition'), '--config', 'skip_children=true', *generated
37
+ )
38
+ raise Error, "rustfmt failed: #{stderr.lines.first&.strip}" unless status.success?
39
+
40
+ files = tree_files(temp)
41
+ manifest = {
42
+ 'owner' => 'haveapi-rust-client', 'format' => 1,
43
+ 'generator_version' => VERSION, 'generator_source_sha256' => generator_digest,
44
+ 'schema_sha256' => Snapshot.sha256(schema), 'runtime_sha256' => runtime_digest,
45
+ 'api_version' => schema.fetch('api_version'), 'protocol_version' => schema.fetch('protocol_version'),
46
+ 'profile' => schema.fetch('profile'), 'config' => config,
47
+ 'ruby_dependency_lock_sha256' => Digest::SHA256.file(File.join(ROOT, 'Gemfile.lock')).hexdigest,
48
+ 'files' => files.to_h { |path| [path, Digest::SHA256.file(File.join(temp, path)).hexdigest] }
49
+ }
50
+ File.write(File.join(temp, OWNER_FILE), Snapshot.canonical(manifest))
51
+ return true if equal_trees?(temp, output)
52
+ raise Error, 'generated tree is stale; regenerate without --check' if check
53
+
54
+ install_tree(temp, output)
55
+ end
56
+ true
57
+ rescue Errno::ENOENT
58
+ raise Error, 'required generation file/tool is missing'
59
+ end
60
+
61
+ def runtime_digest
62
+ digest_files(runtime_files, File.join(ROOT, 'runtime/src/runtime'))
63
+ end
64
+
65
+ def manifest_requirements
66
+ render('Cargo.toml', {})
67
+ end
68
+
69
+ private
70
+
71
+ def validate_config
72
+ raise Error, 'generator config keys do not match supported contract' unless config.keys.sort == CONFIG_KEYS.sort
73
+ unless config['crate_name'].is_a?(String) && config['crate_name'].match?(/\A[a-z][a-z0-9_-]*\z/) &&
74
+ config['crate_version'].is_a?(String) && config['crate_version'].match?(/\A\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?\z/)
75
+ raise Error, 'invalid crate name/version'
76
+ end
77
+ unless config['license'] == 'MIT' && config['edition'] == '2024' && config['rust_version'] == '1.95' &&
78
+ config['rustfmt_version'].is_a?(String) && !config['rustfmt_version'].empty?
79
+ raise Error, 'unsupported license, Rust edition/MSRV or formatter configuration'
80
+ end
81
+ end
82
+
83
+ def validate_manifest(output)
84
+ return unless File.basename(output) == 'generated' && File.basename(File.dirname(output)) == 'src'
85
+
86
+ path = File.join(File.dirname(output, 2), 'Cargo.toml')
87
+ return unless File.exist?(path)
88
+ raise Error, 'crate manifest must not be a symlink' if File.symlink?(path)
89
+
90
+ manifest = Tomlrb.load_file(path)
91
+ package = manifest.fetch('package')
92
+ %w[name version license edition rust-version].zip(%w[crate_name crate_version license edition rust_version]).each do |cargo, key|
93
+ raise Error, "Cargo.toml #{cargo} differs from generator config" unless package[cargo] == config[key]
94
+ end
95
+ deps = manifest.fetch('dependencies')
96
+ { 'serde' => '1', 'serde_json' => '1', 'url' => '2.5', 'reqwest' => '0.13.5' }.each do |name, version|
97
+ dependency = deps.fetch(name)
98
+ actual = dependency.is_a?(Hash) ? dependency['version'] : dependency
99
+ unless actual == version && (!dependency.is_a?(Hash) || !dependency.keys.intersect?(%w[path git]))
100
+ raise Error, "Cargo.toml dependency #{name} differs from runtime requirements"
101
+ end
102
+ end
103
+ unless deps['serde'].is_a?(Hash) && deps['serde'].fetch('features', []).include?('derive')
104
+ raise Error, 'Cargo.toml serde derive feature is required'
105
+ end
106
+
107
+ reqwest = deps.fetch('reqwest')
108
+ unless reqwest.is_a?(Hash) && reqwest['default-features'] == false &&
109
+ reqwest.fetch('features', []).sort == %w[json rustls] &&
110
+ manifest.dig('features', 'default') == [] &&
111
+ manifest.dig('features', 'blocking')&.include?('reqwest/blocking')
112
+ raise Error, 'Cargo.toml reqwest TLS/JSON/blocking requirements do not match runtime'
113
+ end
114
+ rescue KeyError, Tomlrb::ParseError
115
+ raise Error, 'crate manifest is incomplete or invalid TOML'
116
+ end
117
+
118
+ def render_tree(temp)
119
+ File.write(File.join(temp, 'mod.rs'), render('mod.rs', {}))
120
+ FileUtils.mkdir_p(File.join(temp, 'resources'))
121
+ File.write(File.join(temp, 'resources/mod.rs'), render('resources.rs', {}))
122
+ ir.resources.each do |resource|
123
+ dir = File.join(temp, 'resources', resource[:module])
124
+ FileUtils.mkdir_p(dir)
125
+ File.write(File.join(dir, 'mod.rs'), render('resource.rs', resource:))
126
+ resource[:actions].each do |action|
127
+ File.write(File.join(dir, "#{action[:method]}.rs"), render('action.rs', action:))
128
+ end
129
+ end
130
+ runtime_files.each do |relative|
131
+ target = File.join(temp, 'runtime', relative)
132
+ FileUtils.mkdir_p(File.dirname(target))
133
+ FileUtils.cp(File.join(ROOT, 'runtime/src/runtime', relative), target)
134
+ end
135
+ end
136
+
137
+ def render(template, locals)
138
+ ERB.new(File.read(File.join(ROOT, 'template', "#{template}.erb")), trim_mode: '-').result_with_hash(
139
+ { generator: self, schema:, config:, ir: }.merge(locals)
140
+ )
141
+ end
142
+
143
+ def runtime_files
144
+ root = File.join(ROOT, 'runtime/src/runtime')
145
+ raise Error, 'maintained runtime source is missing' unless File.file?(File.join(root, 'mod.rs'))
146
+
147
+ ensure_no_symlinks(root)
148
+ tree_files(root)
149
+ end
150
+
151
+ def generator_digest
152
+ paths = Dir.chdir(ROOT) { Dir['lib/**/*.rb', 'template/**/*'].select { |path| File.file?(path) }.sort }
153
+ digest_files(paths, ROOT)
154
+ end
155
+
156
+ def digest_files(paths, root)
157
+ digest = Digest::SHA256.new
158
+ paths.sort.each { |path| digest.update(path).update("\0").update(File.binread(File.join(root, path))).update("\0") }
159
+ digest.hexdigest
160
+ end
161
+
162
+ def validate_destination(output)
163
+ raise Error, 'refusing filesystem root output' if output == '/'
164
+
165
+ cursor = output
166
+ loop do
167
+ raise Error, 'generated destination contains a symlink' if File.symlink?(cursor)
168
+ break if cursor == File.dirname(cursor)
169
+
170
+ cursor = File.dirname(cursor)
171
+ end
172
+ return unless File.exist?(output)
173
+ raise Error, 'generated destination is not a directory' unless File.directory?(output)
174
+
175
+ ensure_no_symlinks(output)
176
+ entries = tree_files(output)
177
+ return if entries.empty?
178
+
179
+ marker = File.join(output, OWNER_FILE)
180
+ raise Error, 'nonempty destination has no generator ownership manifest' unless File.file?(marker)
181
+
182
+ owner = JSON.parse(File.read(marker))
183
+ unless owner['owner'] == 'haveapi-rust-client' && owner['format'] == 1 && owner['files'].is_a?(Hash)
184
+ raise Error, 'invalid generator ownership manifest'
185
+ end
186
+
187
+ owner.fetch('files').each_key do |path|
188
+ unless path.is_a?(String) && !path.start_with?('/') && Snapshot.safe_segments?(path)
189
+ raise Error, 'unsafe owned path in manifest'
190
+ end
191
+ end
192
+ unknown = entries - owner.fetch('files').keys - [OWNER_FILE]
193
+ raise Error, 'destination contains files not owned by generator' unless unknown.empty?
194
+ rescue JSON::ParserError
195
+ raise Error, 'invalid generator ownership JSON'
196
+ end
197
+
198
+ def ensure_no_symlinks(root)
199
+ return unless Dir.glob(File.join(root, '**/*'), File::FNM_DOTMATCH).any? { |path| File.symlink?(path) }
200
+
201
+ raise Error, 'symlink in generator-owned source/destination'
202
+ end
203
+
204
+ def tree_files(root)
205
+ return [] unless File.directory?(root)
206
+
207
+ Dir.chdir(root) { Dir.glob('**/*', File::FNM_DOTMATCH).select { |path| File.file?(path) }.sort }
208
+ end
209
+
210
+ def equal_trees?(left, right)
211
+ files = tree_files(left)
212
+ files == tree_files(right) && files.all? do |path|
213
+ File.binread(File.join(left, path)) == File.binread(File.join(right, path))
214
+ end
215
+ end
216
+
217
+ def install_tree(temp, output)
218
+ FileUtils.mkdir_p(File.dirname(output))
219
+ Dir.mktmpdir('.haveapi-rust-', File.dirname(output)) do |staging|
220
+ replacement = File.join(staging, 'generated')
221
+ FileUtils.cp_r(temp, replacement)
222
+ if File.exist?(output)
223
+ # Directory exchange is atomic even for a nonempty destination. Refuse
224
+ # unsupported hosts instead of exposing a half-replaced generated tree.
225
+ AtomicRename.exchange(replacement, output)
226
+ else
227
+ File.rename(replacement, output)
228
+ end
229
+ end
230
+ end
231
+
232
+ module AtomicRename
233
+ extend Fiddle::Importer
234
+
235
+ dlload Fiddle.dlopen(nil)
236
+
237
+ def self.exchange(left, right)
238
+ function = Fiddle::Function.new(handler['renameat2'], [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP,
239
+ Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP,
240
+ Fiddle::TYPE_INT], Fiddle::TYPE_INT)
241
+ return if function.call(-100, left, -100, right, 2) == 0
242
+
243
+ raise Error, 'atomic generated-directory exchange failed'
244
+ rescue Fiddle::DLError
245
+ raise Error, 'atomic replacement requires renameat2 support on this host'
246
+ end
247
+ end
248
+
249
+ public
250
+
251
+ def literal(value)
252
+ Naming.literal(value)
253
+ end
254
+
255
+ def option(value)
256
+ value.nil? ? 'None' : "Some(#{literal(value)})"
257
+ end
258
+
259
+ def layout(value)
260
+ value.split('_').map(&:capitalize).join
261
+ end
262
+
263
+ def rust_type(parameter, output: false)
264
+ case parameter[:desc].fetch('type')
265
+ when 'String', 'Text', 'Datetime' then 'String'
266
+ when 'Integer', 'Resource' then output && parameter[:desc]['type'] == 'Resource' ? 'runtime::Association' : 'i64'
267
+ when 'Float' then 'f64'
268
+ when 'Boolean' then 'bool'
269
+ when 'Custom' then 'serde_json::Value'
270
+ end
271
+ end
272
+
273
+ def input_type(action)
274
+ return '()' unless action[:input]
275
+
276
+ action[:input][:layout].end_with?('_list') ? 'Vec<Input>' : 'Input'
277
+ end
278
+
279
+ def output_type(action)
280
+ return '()' unless action[:output]
281
+
282
+ action[:output][:layout].end_with?('_list') ? 'Vec<Output>' : 'Output'
283
+ end
284
+
285
+ def meta_parameters(action)
286
+ values = [action[:global_output]]
287
+ values << action[:object_output] if action[:output] && !action[:output][:layout].end_with?('_list')
288
+ fields = values.compact.flat_map { |io| io[:parameters] }
289
+ fields.group_by { |field| field[:name] }.map do |name, matches|
290
+ raise Error, "inconsistent metadata field #{name}" if matches.map { |field| field[:desc] }.uniq.length > 1
291
+
292
+ matches.first
293
+ end.sort_by { |field| field[:name] }
294
+ end
295
+
296
+ def parameter_specs(io)
297
+ return '&[]' unless io
298
+
299
+ parameters = io[:parameters].map do |parameter|
300
+ desc = parameter[:desc]
301
+ "runtime::ParameterSpec { name: #{literal(parameter[:name])}, kind: runtime::ParameterKind::#{desc.fetch('type')}, " \
302
+ "required: #{desc['required'] == true}, nullable: #{desc['nullable'] == true}, protected: #{desc['protected'] == true} }"
303
+ end.join(',')
304
+ "&[#{parameters}]"
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,195 @@
1
+ module HaveAPI::RustClient
2
+ class IR
3
+ TYPES = %w[String Text Integer Float Boolean Datetime Resource Custom].freeze
4
+ LAYOUTS = %w[object object_list hash hash_list].freeze
5
+ METHODS = %w[GET DELETE POST PUT PATCH].freeze
6
+ attr_reader :snapshot, :resources, :actions
7
+
8
+ def initialize(snapshot)
9
+ @snapshot = Snapshot.validate(snapshot)
10
+ @resources = []
11
+ @actions = []
12
+ @module_scope = Naming::Scope.new(%w[runtime resources API_SPEC API_VERSION SCHEMA_SHA256 builder config])
13
+ description = snapshot.fetch('description')
14
+ walk(description.fetch('resources'), [])
15
+ description.fetch('authentication').each do |provider, auth|
16
+ next unless auth['resources']
17
+
18
+ walk(auth.fetch('resources'), ['authentication', provider], auth_provider: provider)
19
+ end
20
+ bind_ancestors
21
+ validate_auth(description.fetch('authentication'))
22
+ end
23
+
24
+ def inventory
25
+ {
26
+ 'api_version' => snapshot.fetch('api_version'), 'schema_sha256' => Snapshot.sha256(snapshot),
27
+ 'profile' => snapshot.fetch('profile'), 'coverage' => 'captured description only; plugin coverage unknown',
28
+ 'resource_count' => resources.length, 'action_count' => actions.length,
29
+ 'parameter_count' => actions.sum { |action| all_parameters(action).length },
30
+ 'resources' => resources.map { |resource| resource[:path] },
31
+ 'actions' => actions.map do |action|
32
+ { 'name' => action[:path], 'method' => action[:desc].fetch('method'),
33
+ 'path' => action[:desc].fetch('path'),
34
+ 'parameters' => all_parameters(action).map { |parameter| parameter[:path] } }
35
+ end
36
+ }
37
+ end
38
+
39
+ def all_parameters(action)
40
+ %i[input output global_input global_output object_input object_output].flat_map do |key|
41
+ action[key] ? action[key][:parameters] : []
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def walk(map, parents, auth_provider: nil)
48
+ raise Error, "resources must be an object at #{parents.join('/')}" unless map.is_a?(Hash)
49
+
50
+ map.sort.each do |name, desc|
51
+ path = parents + [name]
52
+ module_name = path.map { |item| Naming.snake(item) }.join('__')
53
+ @module_scope.add(module_name, path.join('/'))
54
+ resource = { name:, path:, module: module_name, desc:, actions: [], parent: parents }
55
+ resources << resource
56
+ scope = Naming::Scope.new
57
+ desc.fetch('actions').sort.each do |action_name, action_desc|
58
+ action_path = path + [action_name]
59
+ method_name = Naming.snake(action_name)
60
+ scope.add(method_name, action_path.join('/'))
61
+ aliases = action_desc.fetch('aliases', []).map do |item|
62
+ alias_name = Naming.snake(item)
63
+ next if alias_name == method_name
64
+
65
+ scope.add(alias_name, (action_path + [item]).join('/'))
66
+ end.compact
67
+ action = {
68
+ name: action_name, method: method_name, aliases:, path: action_path, resource:,
69
+ desc: action_desc, auth_provider:, route: route(action_desc.fetch('path'), auth_provider),
70
+ input: io(action_desc['input'], :input, action_path + ['input']),
71
+ output: io(action_desc['output'], :output, action_path + ['output'])
72
+ }
73
+ raise Error, "unsupported HTTP method at #{action_path.join('/')}" unless METHODS.include?(action_desc['method'])
74
+
75
+ %w[global object].each do |role|
76
+ %w[input output].each do |direction|
77
+ action[:"#{role}_#{direction}"] = io(
78
+ action_desc.dig('meta', role, direction), :metadata, action_path + ['meta', role, direction]
79
+ )
80
+ end
81
+ end
82
+ action[:placeholders] = action[:route].scan(/\{([^}]+)\}/).flatten.uniq
83
+ argument_scope = Naming::Scope.new(%w[self path_params])
84
+ action[:arguments] = action[:placeholders].to_h do |placeholder|
85
+ [placeholder, argument_scope.add(Naming.snake(placeholder), action_path.join('/') + "/#{placeholder}")]
86
+ end
87
+ resource[:actions] << action
88
+ actions << action
89
+ end
90
+ walk(desc.fetch('resources'), path, auth_provider:)
91
+ end
92
+ rescue KeyError => e
93
+ raise Error, "incomplete resource description at #{parents.join('/')}: #{e.key}"
94
+ end
95
+
96
+ def io(desc, direction, path)
97
+ return nil if desc.nil? || desc == { 'parameters' => {} }
98
+ raise Error, "invalid IO at #{path.join('/')}" unless desc.is_a?(Hash)
99
+ raise Error, "unsupported layout at #{path.join('/')}" unless LAYOUTS.include?(desc['layout'])
100
+
101
+ namespace = desc['namespace']
102
+ unless namespace == false || (namespace.is_a?(String) && !namespace.empty?)
103
+ raise Error, "missing/invalid namespace at #{path.join('/')}"
104
+ end
105
+ if direction == :output && namespace == false
106
+ raise Error, "ordinary output namespace=false unsupported at #{path.join('/')}"
107
+ end
108
+ if direction == :input && namespace == false && desc['layout'].end_with?('_list')
109
+ raise Error, "unnamespaced list input unsupported at #{path.join('/')}"
110
+ end
111
+
112
+ parameters = desc.fetch('parameters')
113
+ raise Error, "parameters must be an object at #{path.join('/')}" unless parameters.is_a?(Hash)
114
+ raise Error, "custom IO format unsupported at #{path.join('/')}" if desc.has_key?('format')
115
+
116
+ scope = Naming::Scope.new(direction == :input ? %w[new input global_meta object_meta send input_mut prepared] : %w[extra object_meta])
117
+ fields = parameters.sort.map do |name, parameter|
118
+ unless parameter.is_a?(Hash) && TYPES.include?(parameter['type'])
119
+ raise Error, "unknown parameter type at #{(path + [name]).join('/')}"
120
+ end
121
+
122
+ field = scope.add(Naming.snake(name), (path + [name]).join('/'))
123
+ { name:, field:, desc: parameter, path: path + [name] }
124
+ end
125
+ if direction == :input
126
+ fields.select { |parameter| parameter[:desc]['nullable'] == true }.each do |parameter|
127
+ scope.add("#{parameter[:field]}_null", (parameter[:path] + ['null setter']).join('/'))
128
+ end
129
+ end
130
+ { desc:, parameters: fields, layout: desc.fetch('layout'), namespace: namespace == false ? nil : namespace }
131
+ end
132
+
133
+ def route(path, provider)
134
+ unless path.is_a?(String) && path.start_with?('/') && !path.match?(/[?#]/) && Snapshot.safe_segments?(path)
135
+ raise Error, 'unsafe action path in schema'
136
+ end
137
+
138
+ mount = snapshot.fetch('mount_path')
139
+ raise Error, 'action path escapes capture mount' unless path.start_with?(mount)
140
+
141
+ relative = path.delete_prefix(mount)
142
+ unless provider || relative.start_with?("v#{snapshot.fetch('api_version')}/")
143
+ raise Error, 'action path does not match selected API version'
144
+ end
145
+ unless relative.gsub(/\{[a-zA-Z0-9_-]+\}/, '').match?(%r{\A[a-zA-Z0-9_/.%~-]*\z})
146
+ raise Error, 'invalid action path/template'
147
+ end
148
+
149
+ relative
150
+ end
151
+
152
+ def bind_ancestors
153
+ resources.each do |resource|
154
+ parent = resources.find { |item| item[:path] == resource[:parent] }
155
+ resource[:bound] = []
156
+ next unless parent
157
+
158
+ parent_routes = parent[:actions].map { |action| action[:route] }
159
+ ancestors = resource[:actions].map do |action|
160
+ matches = parent_routes.filter_map do |route|
161
+ next unless action[:route].start_with?("#{route}/")
162
+
163
+ route.scan(/\{([^}]+)\}/).flatten.uniq
164
+ end
165
+ matches.max_by(&:length) || []
166
+ end
167
+ unique = ancestors.uniq
168
+ if unique.length > 1
169
+ raise Error, "ambiguous ancestor placeholders at #{resource[:path].join('/')}"
170
+ end
171
+
172
+ resource[:bound] = unique.first || []
173
+ resource[:actions].each do |action|
174
+ next if action[:placeholders].take(resource[:bound].length) == resource[:bound]
175
+
176
+ raise Error, "ancestor template mismatch at #{action[:path].join('/')}"
177
+ end
178
+ end
179
+ end
180
+
181
+ def validate_auth(auth)
182
+ auth.each do |provider, config|
183
+ next unless config.is_a?(Hash)
184
+
185
+ config.each do |key, value|
186
+ Snapshot.endpoint!(value) if key.end_with?('_url') && value
187
+ next unless key.end_with?('_path') && value
188
+ next if value.start_with?('/') && !value.match?(/[?#]/) && Snapshot.safe_segments?(value)
189
+
190
+ raise Error, "unsafe authentication endpoint for #{provider}"
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,46 @@
1
+ module HaveAPI::RustClient
2
+ module Naming
3
+ RESERVED = %w[
4
+ as async await break const continue crate dyn else enum extern false fn for if impl in let loop
5
+ match mod move mut pub ref return self Self static struct super trait true type union unsafe use
6
+ where while abstract become box do final macro override priv typeof unsized virtual yield try gen
7
+ ].freeze
8
+
9
+ def self.snake(value)
10
+ result = value.to_s.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
11
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2').gsub(/[^a-zA-Z0-9_]/, '_').downcase
12
+ result = "field_#{result}" if result.empty? || result == '_' || result.match?(/\A\d/)
13
+ RESERVED.include?(result) ? "#{result}_" : result
14
+ end
15
+
16
+ def self.literal(value)
17
+ escaped = value.to_s.codepoints.map do |char|
18
+ case char
19
+ when 34 then '\"'
20
+ when 92 then '\\\\'
21
+ when 10 then '\n'
22
+ when 13 then '\r'
23
+ when 9 then '\t'
24
+ when 0..31, 127 then "\\u{#{char.to_s(16)}}"
25
+ else char.chr(Encoding::UTF_8)
26
+ end
27
+ end.join
28
+ "\"#{escaped}\""
29
+ end
30
+
31
+ class Scope
32
+ def initialize(reserved = [])
33
+ @names = reserved.to_h { |name| [name, "reserved member #{name}"] }
34
+ end
35
+
36
+ def add(name, path)
37
+ if @names.has_key?(name)
38
+ raise Error, "Rust name collision #{name}: #{@names[name]} and #{path}"
39
+ end
40
+
41
+ @names[name] = path
42
+ name
43
+ end
44
+ end
45
+ end
46
+ end