gloss 0.0.2 → 0.1.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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +3 -0
  3. data/.github/workflows/{crystal.yml → crystal_specs.yml} +1 -1
  4. data/.github/workflows/{ruby.yml → ruby_specs.yml} +1 -1
  5. data/.gloss.yml +1 -0
  6. data/.rspec +1 -0
  7. data/Gemfile.lock +25 -27
  8. data/README.md +36 -5
  9. data/exe/gloss +13 -2
  10. data/ext/gloss/{src/lib → lib}/cr_ruby.cr +0 -0
  11. data/ext/gloss/lib/rbs_types.cr +3 -0
  12. data/ext/gloss/spec/parser_spec.cr +83 -50
  13. data/ext/gloss/src/cr_ast.cr +146 -72
  14. data/ext/gloss/src/gloss.cr +2 -2
  15. data/ext/gloss/src/lexer.cr +59 -1
  16. data/ext/gloss/src/parser.cr +4 -4
  17. data/ext/gloss/src/rb_ast.cr +152 -57
  18. data/lib/gloss.rb +15 -7
  19. data/lib/gloss/cli.rb +85 -27
  20. data/lib/gloss/config.rb +18 -10
  21. data/lib/gloss/errors.rb +13 -7
  22. data/lib/gloss/initializer.rb +11 -6
  23. data/lib/gloss/logger.rb +29 -0
  24. data/lib/gloss/parser.rb +22 -5
  25. data/lib/gloss/prog_loader.rb +141 -0
  26. data/lib/gloss/scope.rb +7 -2
  27. data/lib/gloss/source.rb +17 -14
  28. data/lib/gloss/type_checker.rb +105 -66
  29. data/lib/gloss/utils.rb +44 -0
  30. data/lib/gloss/version.rb +6 -1
  31. data/lib/gloss/visitor.rb +667 -0
  32. data/lib/gloss/watcher.rb +63 -19
  33. data/lib/gloss/writer.rb +35 -18
  34. data/sig/core.rbs +2 -0
  35. data/sig/fast_blank.rbs +4 -0
  36. data/sig/gls.rbs +3 -0
  37. data/sig/listen.rbs +1 -0
  38. data/sig/optparse.rbs +6 -0
  39. data/sig/rubygems.rbs +9 -0
  40. data/sig/yaml.rbs +3 -0
  41. data/src/exe/gloss +19 -0
  42. data/src/lib/gloss.gl +26 -0
  43. data/src/lib/gloss/cli.gl +70 -0
  44. data/src/lib/gloss/config.gl +21 -0
  45. data/src/lib/gloss/errors.gl +11 -0
  46. data/src/lib/gloss/initializer.gl +20 -0
  47. data/src/lib/gloss/logger.gl +21 -0
  48. data/src/lib/gloss/parser.gl +31 -0
  49. data/src/lib/gloss/prog_loader.gl +133 -0
  50. data/src/lib/gloss/scope.gl +7 -0
  51. data/src/lib/gloss/source.gl +32 -0
  52. data/src/lib/gloss/type_checker.gl +119 -0
  53. data/src/lib/gloss/utils.gl +38 -0
  54. data/src/lib/gloss/version.gl +3 -0
  55. data/src/lib/gloss/visitor.gl +575 -0
  56. data/src/lib/gloss/watcher.gl +66 -0
  57. data/src/lib/gloss/writer.gl +35 -0
  58. metadata +35 -8
  59. data/lib/gloss/builder.rb +0 -393
  60. data/src/lib/hrb/initializer.gl +0 -22
  61. data/src/lib/hrb/watcher.gl +0 -32
@@ -0,0 +1,66 @@
1
+ require "listen"
2
+
3
+ module Gloss
4
+ class Watcher
5
+ def initialize(@paths : Array[String])
6
+ if @paths.empty?
7
+ @paths = [File.join(Dir.pwd, Config.src_dir)]
8
+ # either any filepath with .gl extension, or executable with extension
9
+ @only = /(?:(\.gl|(?:(?<=\/)[^\.\/]+))\z|\A[^\.\/]+\z)/
10
+ else
11
+ file_names = Array.new
12
+ paths = Array.new
13
+ @paths.each do |pa|
14
+ pn = Pathname.new(pa)
15
+ paths << pn.parent.to_s
16
+ file_names << (pn.file? ? pn.basename.to_s : pa)
17
+ end
18
+ @paths = paths.uniq
19
+ @only = /#{Regexp.union(file_names)}/
20
+ end
21
+ end
22
+
23
+ def watch
24
+ Gloss.logger.info "Now listening for changes in #{@paths.join(', ')}"
25
+ listener = Listen.to(
26
+ *@paths,
27
+ latency: 2,
28
+ only: @only
29
+ ) do |modified, added, removed|
30
+ (modified + added).each do |f|
31
+ Gloss.logger.info "Rewriting #{f}"
32
+ content = File.read(f)
33
+ err = catch :error do
34
+ Writer.new(
35
+ Visitor.new(
36
+ Parser.new(
37
+ content
38
+ ).run
39
+ ).run, f
40
+ ).run
41
+ nil
42
+ end
43
+ if err
44
+ Gloss.logger.error err
45
+ else
46
+ Gloss.logger.info "Done"
47
+ end
48
+ end
49
+ removed.each do |f|
50
+ out_path = Utils.src_path_to_output_path(f)
51
+ Gloss.logger.info "Removing #{out_path}"
52
+ File.delete out_path if File.exist? out_path
53
+
54
+ Gloss.logger.info "Done"
55
+ end
56
+ end
57
+ begin
58
+ listener.start
59
+ sleep
60
+ rescue Interrupt
61
+ Gloss.logger.info "Interrupt signal received, shutting down"
62
+ exit 0
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "fileutils"
5
+
6
+ module Gloss
7
+ class Writer
8
+ def initialize(
9
+ @content,
10
+ @src_path : String,
11
+ @output_path : Pathname? = Pathname.new(
12
+ Utils.src_path_to_output_path(src_path)
13
+ )
14
+ )
15
+ end
16
+
17
+ def run
18
+ FileUtils.mkdir_p(@output_path.parent) unless @output_path.parent.exist?
19
+ File.open(@output_path, "wb") do |file|
20
+ sb = shebang
21
+ file.puts sb if sb
22
+ file.puts @content
23
+ end
24
+ end
25
+
26
+ private def shebang
27
+ if @output_path.executable?
28
+ first_line = File.open(@src_path) { |f| f.readline }
29
+ first_line.start_with?("#!") ? first_line : nil
30
+ else
31
+ nil
32
+ end
33
+ end
34
+ end
35
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gloss
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - johansenja
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-01-03 00:00:00.000000000 Z
11
+ date: 2021-02-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: fast_blank
@@ -131,10 +131,12 @@ extensions:
131
131
  - ext/gloss/extconf.rb
132
132
  extra_rdoc_files: []
133
133
  files:
134
- - ".github/workflows/crystal.yml"
135
- - ".github/workflows/ruby.yml"
134
+ - ".gitattributes"
135
+ - ".github/workflows/crystal_specs.yml"
136
+ - ".github/workflows/ruby_specs.yml"
136
137
  - ".gitignore"
137
138
  - ".gloss.yml"
139
+ - ".rspec"
138
140
  - ".rubocop.yml"
139
141
  - Gemfile
140
142
  - Gemfile.lock
@@ -145,32 +147,57 @@ files:
145
147
  - exe/gloss
146
148
  - ext/gloss/Makefile
147
149
  - ext/gloss/extconf.rb
150
+ - ext/gloss/lib/cr_ruby.cr
151
+ - ext/gloss/lib/rbs_types.cr
148
152
  - ext/gloss/shard.yml
149
153
  - ext/gloss/spec/parser_spec.cr
150
154
  - ext/gloss/spec/spec_helper.cr
151
155
  - ext/gloss/src/cr_ast.cr
152
156
  - ext/gloss/src/gloss.cr
153
157
  - ext/gloss/src/lexer.cr
154
- - ext/gloss/src/lib/cr_ruby.cr
155
158
  - ext/gloss/src/parser.cr
156
159
  - ext/gloss/src/rb_ast.cr
157
160
  - gloss.gemspec
158
161
  - lib/gloss.rb
159
- - lib/gloss/builder.rb
160
162
  - lib/gloss/cli.rb
161
163
  - lib/gloss/config.rb
162
164
  - lib/gloss/errors.rb
163
165
  - lib/gloss/initializer.rb
166
+ - lib/gloss/logger.rb
164
167
  - lib/gloss/parser.rb
168
+ - lib/gloss/prog_loader.rb
165
169
  - lib/gloss/scope.rb
166
170
  - lib/gloss/source.rb
167
171
  - lib/gloss/type_checker.rb
172
+ - lib/gloss/utils.rb
168
173
  - lib/gloss/version.rb
174
+ - lib/gloss/visitor.rb
169
175
  - lib/gloss/watcher.rb
170
176
  - lib/gloss/writer.rb
177
+ - sig/core.rbs
178
+ - sig/fast_blank.rbs
179
+ - sig/gls.rbs
171
180
  - sig/listen.rbs
172
- - src/lib/hrb/initializer.gl
173
- - src/lib/hrb/watcher.gl
181
+ - sig/optparse.rbs
182
+ - sig/rubygems.rbs
183
+ - sig/yaml.rbs
184
+ - src/exe/gloss
185
+ - src/lib/gloss.gl
186
+ - src/lib/gloss/cli.gl
187
+ - src/lib/gloss/config.gl
188
+ - src/lib/gloss/errors.gl
189
+ - src/lib/gloss/initializer.gl
190
+ - src/lib/gloss/logger.gl
191
+ - src/lib/gloss/parser.gl
192
+ - src/lib/gloss/prog_loader.gl
193
+ - src/lib/gloss/scope.gl
194
+ - src/lib/gloss/source.gl
195
+ - src/lib/gloss/type_checker.gl
196
+ - src/lib/gloss/utils.gl
197
+ - src/lib/gloss/version.gl
198
+ - src/lib/gloss/visitor.gl
199
+ - src/lib/gloss/watcher.gl
200
+ - src/lib/gloss/writer.gl
174
201
  homepage:
175
202
  licenses:
176
203
  - MIT
data/lib/gloss/builder.rb DELETED
@@ -1,393 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Gloss
4
- class Builder
5
- attr_reader :tree
6
-
7
- def initialize(tree_hash, type_checker = nil)
8
- @indent_level = 0
9
- @inside_macro = false
10
- @eval_vars = false
11
- @current_scope = nil
12
- @tree = tree_hash
13
- @type_checker = type_checker
14
- end
15
-
16
- def run
17
- rb_output = visit_node(@tree)
18
- rb_output = "# frozen_string_literal: true\n#{rb_output}" if Config.frozen_string_literals
19
- rb_output
20
- end
21
-
22
- def visit_node(node, scope = Scope.new)
23
- src = Source.new(@indent_level)
24
- case node[:type]
25
- when "ClassNode"
26
- class_name = visit_node(node[:name])
27
- superclass = if node[:superclass]
28
- @eval_vars = true
29
- visit_node(node[:superclass])
30
- @eval_vars = false
31
- else
32
- nil
33
- end
34
-
35
- src.write_ln "class #{class_name}#{" < #{superclass}" if superclass}"
36
-
37
- current_namespace = @current_scope ? @current_scope.name.to_namespace : RBS::Namespace.root
38
- class_type = RBS::AST::Declarations::Class.new(
39
- name: RBS::TypeName.new(
40
- namespace: current_namespace,
41
- name: class_name.to_sym
42
- ),
43
- type_params: RBS::AST::Declarations::ModuleTypeParams.new, # responds to #add to add params
44
- super_class: superclass ? RBS::AST::Declarations::Class::Super.new(name: RBS::Typename.new(name: super_class.to_sym, namespace: RBS::Namespace.root), args: [], location: nil) : nil,
45
- members: [],
46
- annotations: [],
47
- location: node[:location],
48
- comment: node[:comment]
49
- )
50
- old_parent_scope = @current_scope
51
- @current_scope = class_type
52
-
53
- indented(src) { src.write_ln visit_node(node[:body]) if node[:body] }
54
-
55
- src.write_ln "end"
56
-
57
- @current_scope = old_parent_scope
58
- if @type_checker
59
- @type_checker.top_level_decls[class_type.name.name] = class_type unless @current_scope
60
- end
61
- when "ModuleNode"
62
- module_name = visit_node node[:name]
63
- src.write_ln "module #{module_name}"
64
-
65
- current_namespace = RBS::Namespace.root # RBS::Namespace.new(path: [module_name.to_sym], absolute: false)
66
-
67
- module_type = RBS::AST::Declarations::Module.new(
68
- name: RBS::TypeName.new(
69
- namespace: current_namespace,
70
- name: module_name.to_sym
71
- ),
72
- type_params: RBS::AST::Declarations::ModuleTypeParams.new, # responds to #add to add params
73
- self_types: [],
74
- members: [],
75
- annotations: [],
76
- location: node[:location],
77
- comment: node[:comment]
78
- )
79
- old_parent_scope = @current_scope
80
- @current_scope = module_type
81
-
82
- indented(src) { src.write_ln visit_node(node[:body]) if node[:body] }
83
-
84
- @current_scope = old_parent_scope
85
- if @type_checker
86
- @type_checker.top_level_decls[module_type.name.name] = module_type unless @current_scope
87
- end
88
- src.write_ln "end"
89
- when "DefNode"
90
- args = render_args(node)
91
- src.write_ln "def #{node[:name]}#{args}"
92
-
93
- return_type = if node[:return_type]
94
- RBS::Types::ClassInstance.new(
95
- name: RBS::TypeName.new(
96
- name: eval(visit_node(node[:return_type])).to_s.to_sym,
97
- namespace: RBS::Namespace.root
98
- ),
99
- args: [],
100
- location: nil
101
- )
102
- else
103
- RBS::Types::Bases::Any.new(location: nil)
104
- end
105
-
106
- method_types = [
107
- RBS::MethodType.new(
108
- type_params: [],
109
- type: RBS::Types::Function.new(
110
- required_positionals: [],
111
- optional_positionals: [],
112
- rest_positionals: nil,
113
- trailing_positionals: [],
114
- required_keywords: {},
115
- optional_keywords: {},
116
- rest_keywords: node[:rest_kw_args] ?
117
- RBS::Types::Function::Param.new(
118
- name: visit_node(node[:rest_kw_args]).to_sym,
119
- type: RBS::Types::Bases::Any.new(location: nil)
120
- ) : nil,
121
- return_type: return_type
122
- ),
123
- block: nil,
124
- location: nil
125
- )
126
- ]
127
- method_definition = RBS::AST::Members::MethodDefinition.new(
128
- name: node[:name].to_sym,
129
- kind: :instance,
130
- types: method_types,
131
- annotations: [],
132
- location: node[:location],
133
- comment: node[:comment],
134
- overload: false
135
- )
136
-
137
- if @current_scope
138
- @current_scope.members << method_definition
139
- else
140
- @type_checker.type_env << method_definition if @type_checker # should be new class declaration for Object with method_definition as private method
141
- end
142
-
143
- indented(src) { src.write_ln visit_node(node[:body]) if node[:body] }
144
-
145
- src.write_ln "end"
146
- when "CollectionNode"
147
- src.write(*node[:children].map { |a| visit_node(a, scope) })
148
- when "Call"
149
- obj = node[:object] ? "#{visit_node(node[:object], scope)}." : ""
150
- args = node[:args] || EMPTY_ARRAY
151
- args = if !args.empty? || node[:block_arg]
152
- "(#{args.map { |a| visit_node(a, scope).strip }.reject(&:blank?).join(", ")}#{"&#{visit_node(node[:block_arg]).strip}" if node[:block_arg]})"
153
- else
154
- nil
155
- end
156
- block = node[:block] ? " #{visit_node(node[:block])}" : nil
157
- src.write_ln "#{obj}#{node[:name]}#{args}#{block}"
158
-
159
- when "Block"
160
-
161
- src.write "{ |#{node[:args].map { |a| visit_node a }.join(", ")}|\n"
162
-
163
- indented(src) { src.write visit_node(node[:body]) }
164
-
165
- src.write_ln "}"
166
-
167
- when "RangeLiteral"
168
- dots = node[:exclusive] ? "..." : ".."
169
-
170
- # parentheses help the compatibility with precendence of operators in some situations
171
- # eg. (1..3).cover? 2 vs. 1..3.cover? 2
172
- src.write "(", visit_node(node[:from]), dots, visit_node(node[:to]), ")"
173
-
174
- when "LiteralNode"
175
-
176
- src.write node[:value]
177
-
178
- when "ArrayLiteral"
179
-
180
- src.write("[", *node[:elements].map { |e| visit_node e }.join(", "), "]")
181
- src.write ".freeze" if node[:frozen]
182
-
183
- when "StringInterpolation"
184
-
185
- contents = node[:contents].inject(String.new) do |str, c|
186
- str << case c[:type]
187
- when "LiteralNode"
188
- c[:value][1...-1]
189
- else
190
- "\#{#{visit_node(c).strip}}"
191
- end
192
- end
193
- src.write '"', contents, '"'
194
-
195
- when "Path"
196
-
197
- src.write node[:value]
198
-
199
- when "Require"
200
-
201
- src.write_ln %(require "#{node[:value]}")
202
-
203
- when "Assign", "OpAssign"
204
-
205
- src.write_ln "#{visit_node(node[:target])} #{node[:op]}= #{visit_node(node[:value]).strip}"
206
-
207
- when "Var"
208
-
209
- if @eval_vars
210
- src.write scope[node[:name]]
211
- else
212
- src.write node[:name]
213
- end
214
-
215
- when "InstanceVar"
216
-
217
- src.write node[:name]
218
-
219
- when "GlobalVar"
220
-
221
- src.write node[:name]
222
-
223
- when "Arg"
224
- val = node[:external_name]
225
- if node[:keyword_arg]
226
- val += ":"
227
- val += " #{visit_node(node[:default_value])}" if node[:default_value]
228
- elsif node[:default_value]
229
- val += " = #{visit_node(node[:default_value])}"
230
- end
231
-
232
- src.write val
233
-
234
- when "UnaryExpr"
235
-
236
- src.write "#{node[:op]}#{visit_node(node[:value]).strip}"
237
-
238
- when "BinaryOp"
239
-
240
- src.write visit_node(node[:left]).strip, " #{node[:op]} ", visit_node(node[:right]).strip
241
-
242
- when "HashLiteral"
243
-
244
- contents = node[:elements].map do |k, v|
245
- key = case k
246
- when String
247
- k.to_sym
248
- else
249
- visit_node k
250
- end
251
- value = visit_node v
252
- "#{key.inspect} => #{value}"
253
- end
254
-
255
- src.write "{#{contents.join(",\n")}}"
256
- src.write ".freeze" if node[:frozen]
257
-
258
- when "Enum"
259
- src.write_ln "module #{node[:name]}"
260
- node[:members].each_with_index do |m, i|
261
- indented(src) { src.write_ln(visit_node(m) + (!m[:value] ? " = #{i}" : "")) }
262
- end
263
- src.write_ln "end"
264
- when "If"
265
- src.write_ln "(if #{visit_node(node[:condition]).strip}"
266
-
267
- indented(src) { src.write_ln visit_node(node[:then]) }
268
-
269
- if node[:else]
270
- src.write_ln "else"
271
- indented(src) { src.write_ln visit_node(node[:else]) }
272
- end
273
-
274
- src.write_ln "end)"
275
- when "Case"
276
- src.write "case"
277
- src.write " #{visit_node(node[:condition]).strip}\n" if node[:condition]
278
- indented(src) do
279
- node[:whens].each do |w|
280
- src.write_ln visit_node(w)
281
- end
282
- end
283
- src.write_ln "end"
284
- when "When"
285
- src.write_ln "when #{node[:conditions].map { |n| visit_node(n) }.join(", ")}"
286
-
287
- indented(src) { src.write_ln visit_node(node[:body]) }
288
- when "MacroFor"
289
- vars, expr, body = node[:vars], node[:expr], node[:body]
290
- var_names = vars.map { |v| visit_node v }
291
- @inside_macro = true
292
- indent_level = @indent_level
293
- @indent_level -= 1 unless indent_level.zero?
294
- expanded = eval(visit_node(expr)).map do |*a|
295
- locals = Hash[[var_names.join(%(", "))].zip(a)]
296
- locals.merge!(scope) if @inside_macro
297
- visit_node(body, locals)
298
- end.flatten
299
- @indent_level += 1 unless indent_level.zero?
300
- src.write(*expanded)
301
- @inside_macro = false
302
- when "MacroLiteral"
303
- src.write node[:value]
304
- when "MacroExpression"
305
- if node[:output]
306
- expr = visit_node node[:expr], scope
307
- val = scope[expr]
308
- src.write val
309
- end
310
- when "MacroIf"
311
- if evaluate_macro_condition(node[:condition], scope)
312
- src.write_ln visit_node(node[:then], scope) if node[:then]
313
- else
314
- src.write_ln visit_node(node[:else], scope) if node[:else]
315
- end
316
- when "Return"
317
- val = node[:value] ? " #{visit_node(node[:value]).strip}" : nil
318
- src.write "return#{val}"
319
- when "TypeDeclaration"
320
- src.write_ln "# @type var #{visit_node(node[:var])}: #{visit_node(node[:declared_type])}"
321
- src.write_ln "#{visit_node(node[:var])} = #{visit_node(node[:value])}"
322
- when "ExceptionHandler"
323
- src.write_ln "begin"
324
- src.write_ln visit_node(node[:body])
325
- node[:rescues]&.each do |r|
326
- src.write_ln "rescue #{r[:types].map { |n| visit_node n }.join(", ") if r[:types]}#{" => #{r[:name]}" if r[:name]}"
327
- src.write_ln visit_node(r[:body]) if r[:body]
328
- end
329
- if node[:else]
330
- src.write_ln "else"
331
- src.write_ln visit_node(node[:else])
332
- end
333
- if node[:ensure]
334
- src.write_ln "ensure"
335
- src.write_ln visit_node(node[:ensure])
336
- end
337
- src.write_ln "end"
338
- when "Generic"
339
- src.write "#{node[:name]}[#{node[:args].map { |a| visit_node a }.join(", ")}]"
340
- when "EmptyNode"
341
- # pass
342
- else
343
- raise "Not implemented: #{node[:type]}"
344
- end
345
-
346
- src
347
- end
348
-
349
- private
350
-
351
- def evaluate_macro_condition(condition_node, scope)
352
- @eval_vars = true
353
- eval(visit_node(condition_node, scope))
354
- @eval_vars = false
355
- end
356
-
357
- def indented(src)
358
- increment_indent(src)
359
- yield
360
- decrement_indent(src)
361
- end
362
-
363
- def increment_indent(src)
364
- @indent_level += 1
365
- src.increment_indent
366
- end
367
-
368
- def decrement_indent(src)
369
- @indent_level -= 1
370
- src.decrement_indent
371
- end
372
-
373
- def render_args(node)
374
- rp = node[:rp_args] || EMPTY_ARRAY
375
- op = node[:op_args] || EMPTY_ARRAY
376
- rkw = node[:req_kw_args] || EMPTY_HASH
377
- okw = node[:opt_kw_args] || EMPTY_HASH
378
- rest_p = node[:rest_p_args]
379
- rest_kw = node[:rest_kw_args]
380
- return nil unless [rp, op, rkw, okw, rest_p, rest_kw].any? { |a| !a.nil? || !a.empty? }
381
-
382
- contents = [
383
- rp.map { |a| visit_node(a) },
384
- op.map { |pos| "#{pos.name} = #{value}" },
385
- rkw.map { |name, _| "#{name}:" },
386
- okw.map { |name, _| "#{name}: #{value}" },
387
- rest_p ? "*#{rest_p}" : "",
388
- rest_kw ? "**#{visit_node(rest_kw)}" : ""
389
- ].reject(&:empty?).flatten.join(", ")
390
- "(#{contents})"
391
- end
392
- end
393
- end