gloo 7.1.3 → 7.2

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +23 -0
  3. data/docs/application.md +47 -2
  4. data/docs/development.md +10 -0
  5. data/docs/language_objects.md +20 -15
  6. data/docs/language_scripting.md +6 -2
  7. data/docs/testing.md +4 -7
  8. data/docs/verbs.md +23 -3
  9. data/lib/VERSION +1 -1
  10. data/lib/VERSION_NOTES +8 -0
  11. data/lib/dependencies.rb +4 -1
  12. data/lib/gloo/app/engine.rb +18 -6
  13. data/lib/gloo/core/obj.rb +0 -23
  14. data/lib/gloo/objs/basic/script.rb +0 -23
  15. data/lib/gloo/persist/declaration_ledger.rb +9 -9
  16. data/lib/gloo/persist/declaration_writer.rb +208 -0
  17. data/lib/gloo/persist/disc_mech.rb +70 -39
  18. data/lib/gloo/persist/file_loader.rb +125 -66
  19. data/lib/gloo/persist/file_saver.rb +130 -198
  20. data/lib/gloo/persist/file_storage.rb +15 -4
  21. data/lib/gloo/persist/file_style.rb +89 -0
  22. data/lib/gloo/persist/line_splitter.rb +16 -1
  23. data/lib/gloo/persist/persist_man.rb +112 -30
  24. data/lib/gloo/persist/save_index.rb +211 -0
  25. data/lib/gloo/persist/script_body_collector.rb +16 -21
  26. data/lib/gloo/persist/source/blank_node.rb +10 -3
  27. data/lib/gloo/persist/source/comment_node.rb +10 -2
  28. data/lib/gloo/persist/source/directive_node.rb +10 -2
  29. data/lib/gloo/persist/source/obj_node.rb +174 -9
  30. data/lib/gloo/persist/source/source_doc.rb +54 -0
  31. data/lib/gloo/persist/subtree_extractor.rb +9 -37
  32. data/lib/gloo/persist/trivia_buffer.rb +7 -7
  33. data/lib/gloo/verbs/load.rb +27 -21
  34. data/lib/gloo/verbs/move.rb +42 -3
  35. data/lib/gloo/verbs/save.rb +26 -4
  36. data/test.gloo/app/exit_status.test.gloo +34 -0
  37. data/test.gloo/app/fixtures/exit_after_error.gloo +9 -0
  38. data/test.gloo/lang/declaration.test.gloo +36 -0
  39. data/test.gloo/lang/fixtures/mixed_indent.gloo +5 -0
  40. data/test.gloo/lang/naming.test.gloo +44 -6
  41. data/test.gloo/verbs/fixtures/load_beside.gloo +6 -0
  42. data/test.gloo/verbs/fixtures/lossless_text.gloo +7 -0
  43. data/test.gloo/verbs/fixtures/lossless_text_saved.gloo +9 -0
  44. data/test.gloo/verbs/load.test.gloo +18 -0
  45. data/test.gloo/verbs/move.test.gloo +39 -0
  46. data/test.gloo/verbs/save.test.gloo +239 -0
  47. metadata +10 -1
@@ -4,16 +4,20 @@
4
4
  # Helper class used to save an object to a file.
5
5
  #
6
6
  # Given a Source::SourceDoc (captured at load time), this is a
7
- # rewriter, not a regenerator: trivia (comments, blank lines,
8
- # directives) is emitted verbatim, and a declaration's raw text is
9
- # kept as-is unless the live object's value has actually changed --
10
- # only then is the value re-rendered, via the object's own
11
- # serialize_value. Deleted objects drop out; objects created since
12
- # load are rendered fresh and appended.
7
+ # rewriter, not a regenerator. Comments, blank lines and directives are
8
+ # written back exactly as read. A declaration whose object is still in
9
+ # its place is written back exactly too, unless it holds the object's
10
+ # value and the value changed: then its head is kept and the value is
11
+ # written again. A declaration whose object was deleted or moved away
12
+ # is dropped. A new object, or one moved in, is written after the last
13
+ # child of its parent's first declaration, in the file's style.
14
+ #
15
+ # The save builds a new source document for what it writes, so the
16
+ # file can be saved again (and checked for changes) the same way.
13
17
  #
14
18
  # With no SourceDoc at all (the object was never loaded from a file),
15
- # this falls back to plain regeneration from the heap, so saving a
16
- # brand-new in-memory object still works.
19
+ # the file is written from the heap, so saving a brand-new in-memory
20
+ # object still works.
17
21
  #
18
22
 
19
23
  module Gloo
@@ -22,17 +26,10 @@ module Gloo
22
26
 
23
27
  #
24
28
  # Set up a file saver. source_doc is optional -- when given, the
25
- # save rewrites it in place; otherwise the file is regenerated
26
- # from the heap.
27
- #
28
- # For a multi-file namespace to round-trip, batch carries two sets
29
- # (PersistMan#save_batch builds them):
30
- # - :others -- objects declared by *other* loaded files'
31
- # SourceDocs; this saver leaves them out even though they're
32
- # live children of a container it shares.
33
- # - :claimed -- a set shared across the whole save batch; a
34
- # brand-new object no file owns is written by the first saver to
35
- # reach it, which adds it here so the rest skip it.
29
+ # save rewrites it; otherwise the file is written from the heap.
30
+ # batch, when given, carries the SaveIndex shared by every file in
31
+ # one save (PersistMan#save_batch builds it); without one, the
32
+ # saver indexes every loaded file itself.
36
33
  #
37
34
  def initialize( engine, pn, obj, source_doc = nil, batch = nil )
38
35
  @engine = engine
@@ -40,256 +37,191 @@ module Gloo
40
37
  @pn = pn
41
38
  @obj = obj
42
39
  @source_doc = source_doc
43
- @others = batch && batch[ :others ]
44
- @claimed = batch && batch[ :claimed ]
40
+ @index = batch && batch[ :index ]
45
41
  end
46
42
 
47
43
  #
48
- # Save the object to the file.
44
+ # Save the object to the file. Returns the source document for
45
+ # what was written.
49
46
  #
50
47
  def save
51
- data = @source_doc ? rewrite : regenerate
52
- @mech.write( @pn, data )
53
- end
54
-
55
- #
56
- # Get string of tabs for indentation.
57
- #
58
- def tabs( indent = 0 )
59
- return "\t" * indent
48
+ doc = render
49
+ @mech.write( @pn, doc.text )
50
+ return doc
60
51
  end
61
52
 
62
53
  #
63
- # Would saving now actually change anything on disk? True if any
64
- # known declaration's value no longer matches its raw source, or
65
- # its object was deleted. (Doesn't detect a brand-new object with
66
- # no source node of its own -- an approximation, not a guarantee,
67
- # good enough to warn before a reload discards changes.)
54
+ # Would saving now actually change anything on disk?
68
55
  #
69
56
  def dirty?
70
57
  return false unless @source_doc
71
58
 
72
- return any_dirty?( @source_doc.children, @engine.heap.root )
59
+ return render.text != @source_doc.text
73
60
  end
74
61
 
75
62
  #
76
- # Convert an object to textual representation from scratch, with
77
- # no source text to preserve. This is a recursive function, and
78
- # is also reused by the rewriter for any object created since
79
- # load (which has no source node of its own to rewrite).
63
+ # The text for an object and everything under it, written from
64
+ # scratch in the default style (indent is the depth of its
65
+ # declaration).
80
66
  #
81
67
  def get_obj( obj, indent = 0 )
82
- t = tabs( indent )
83
- str = "#{t}#{obj.name} [#{obj.type_display}]#{obj.serialize_value( indent )}\n"
84
- obj.children.each do |child|
85
- str << get_obj( child, indent + 1 )
86
- end
87
- return str
68
+ @index = SaveIndex.new( @engine, [] )
69
+ @writer = DeclarationWriter.new( @engine, FileStyle.new( @engine ) )
70
+ @unit = FileStyle::DEFAULT_UNIT
71
+ return unplaced( obj, FileStyle::DEFAULT_UNIT * indent ).text
88
72
  end
89
73
 
90
- private
91
-
92
74
  #
93
- # Regenerate the whole file from the heap (the pre-Source::SourceDoc
94
- # behavior), used when there's no source text to rewrite.
75
+ # A source node for an object and everything under it, written
76
+ # from scratch in the default style, at the top of a file, with
77
+ # the given name (a dotted path when it's written into a file of
78
+ # its own).
95
79
  #
96
- def regenerate
97
- return get_obj( @obj )
80
+ def new_node( obj, name = obj.name )
81
+ @index = SaveIndex.new( @engine, [] )
82
+ @writer = DeclarationWriter.new( @engine, FileStyle.new( @engine ) )
83
+ @unit = FileStyle::DEFAULT_UNIT
84
+ @index.claim( obj )
85
+ return unplaced( obj, '', name )
98
86
  end
99
87
 
100
88
  #
101
- # Does any node in the given list represent a change from what's
102
- # on disk -- its object gone from live_parent's children (an
103
- # untracked deletion counts too), or its value changed?
89
+ # The source document for what a save would write.
104
90
  #
105
- def any_dirty?( nodes, live_parent )
106
- nodes.any? do |node|
107
- next false unless node.is_a?( Source::ObjNode )
108
-
109
- _top, leaf = locate( node, live_parent )
110
- next true unless leaf
111
- next true unless value_unchanged?( node )
91
+ def render
92
+ return regenerate unless @source_doc
112
93
 
113
- any_dirty?( node.children, leaf )
114
- end
94
+ @index ||= SaveIndex.new( @engine, @engine.persist_man.maps )
95
+ style = FileStyle.new( @engine, @source_doc )
96
+ @writer = DeclarationWriter.new( @engine, style )
97
+ @unit = style.unit
98
+ doc = Source::SourceDoc.new
99
+ doc.no_final_newline = @source_doc.no_final_newline
100
+ render_nodes( @source_doc.children, doc.children )
101
+ return doc
115
102
  end
116
103
 
117
- # ---------------------------------------------------------------------
118
- # Rewrite (Source::SourceDoc-driven)
119
- # ---------------------------------------------------------------------
104
+ private
120
105
 
121
106
  #
122
- # Rewrite the file from its source document: known declarations
123
- # are patched in place, deleted ones dropped, and anything new
124
- # appended.
107
+ # A document for a file written from scratch: the object and
108
+ # everything under it.
125
109
  #
126
- def rewrite
127
- return render_children( @source_doc.children, @engine.heap.root, 0 )
110
+ def regenerate
111
+ doc = Source::SourceDoc.new
112
+ doc.children << new_node( @obj )
113
+ return doc
128
114
  end
129
115
 
130
116
  #
131
- # Render one level of source nodes against the live children of
132
- # live_parent: trivia passes through untouched; a declaration
133
- # whose object is still present is patched in place; one whose
134
- # object is gone is dropped; anything in live_parent.children
135
- # with no matching source node is new and gets rendered fresh
136
- # (unless another file owns it -- see #owned).
117
+ # Write one level of source nodes into out: comments, blank lines
118
+ # and directives as they are; each standing declaration (with
119
+ # anything new under it); and after a dotted declaration, any new
120
+ # children of the containers it names.
137
121
  #
138
- def render_children( nodes, live_parent, indent )
139
- str = ''
140
- seen = []
122
+ def render_nodes( nodes, out )
141
123
  nodes.each do |node|
142
- if node.is_a?( Source::ObjNode )
143
- top, leaf = locate( node, live_parent )
144
- next unless leaf
145
-
146
- seen << top
147
- str << render_obj_node( node, indent )
148
- else
149
- str << render_trivia( node )
124
+ unless node.is_a?( Source::ObjNode )
125
+ out << node
126
+ next
150
127
  end
151
- end
128
+ next unless @index.standing?( node )
152
129
 
153
- ( live_parent.children - seen ).each do |child|
154
- next unless write_new_child?( child )
155
-
156
- str << get_obj( child, indent )
130
+ out << render_obj_node( node )
131
+ @index.shorthand_targets( node ).each do |parent, prefix|
132
+ @index.unplaced_children( parent ).each do |child|
133
+ out << unplaced( child, node.raw_indent, "#{prefix}.#{child.name}" )
134
+ end
135
+ end
157
136
  end
158
- return str
159
137
  end
160
138
 
161
139
  #
162
- # A live child with no source node at this level. Write it unless
163
- # another file owns it, or another file in this batch already
164
- # wrote it as a brand-new object. A genuinely new object is
165
- # written here and claimed.
140
+ # One standing declaration: written as it was unless it holds a
141
+ # value that changed; its children the same way; then, if this is
142
+ # where its object's new children go, those.
166
143
  #
167
- def write_new_child?( child )
168
- return false if @others&.key?( child )
169
- return false if @claimed&.key?( child )
144
+ def render_obj_node( node )
145
+ copy = node.detached( own_text( node ) )
146
+ render_nodes( node.children, copy.children )
147
+ return copy unless node.obj && writes_children_of?( node )
170
148
 
171
- @claimed[ child ] = true if @claimed
172
- return true
173
- end
174
-
175
- #
176
- # Resolve a source node against the live tree under live_parent.
177
- # A plain name is a direct child; a dotted name (nested container
178
- # shorthand, page.core.users.list) walks the chain. Returns
179
- # [top-level object to mark as handled, the node's own object],
180
- # or [nil, nil] if the node's object is no longer there.
181
- #
182
- def locate( node, live_parent )
183
- return [ nil, nil ] unless node.obj
184
-
185
- segments = node.name.split( '.' )
186
- if segments.length == 1
187
- return live_parent.children.include?( node.obj ) ? [ node.obj, node.obj ] : [ nil, nil ]
149
+ indent = child_indent( copy )
150
+ at = after_last_declaration( copy.children )
151
+ @index.unplaced_children( node.obj ).each do |child|
152
+ copy.children.insert( at, unplaced( child, indent ) )
153
+ at += 1
188
154
  end
189
-
190
- top = live_parent.find_child( segments.first )
191
- return [ nil, nil ] unless top
192
-
193
- leaf = top
194
- segments[ 1..-1 ].each do |seg|
195
- leaf = leaf.find_child( seg )
196
- return [ nil, nil ] unless leaf
197
- end
198
- return leaf.equal?( node.obj ) ? [ top, leaf ] : [ nil, nil ]
199
- end
200
-
201
- #
202
- # A comment, blank line, or top-of-file directive -- passed
203
- # through exactly as read.
204
- #
205
- def render_trivia( node )
206
- return "#{node.raw}\n"
155
+ return copy
207
156
  end
208
157
 
209
158
  #
210
- # One declaration whose object is still live: its leading_doc (if
211
- # any) and its own line are kept (name/type/indent never change
212
- # once declared), its value is kept raw or re-rendered depending
213
- # on whether it changed, and its children are rewritten the same
214
- # way, one level deeper.
159
+ # Where a new child goes in a list of nodes: after the last
160
+ # declaration, ahead of any comments and blank lines that follow
161
+ # it (they lead into what comes next).
215
162
  #
216
- def render_obj_node( node, indent )
217
- str = node.leading_doc ? "#{node.leading_doc}\n" : ''
218
- str << "#{node.raw_indent}#{node.name} [#{node.raw_type}]#{declaration_tail( node, indent )}\n"
219
- str << render_children( node.children, node.obj, indent + 1 )
220
- return str
163
+ def after_last_declaration( nodes )
164
+ last = nodes.rindex { |n| n.is_a?( Source::ObjNode ) }
165
+ return last ? last + 1 : 0
221
166
  end
222
167
 
223
168
  #
224
- # The text that follows "name [type]" on the declaration line:
225
- # the raw source, unless the live value no longer matches it.
169
+ # The declaration's own text: as read (with any head gloo had to
170
+ # guess at written as understood), or written again if it holds
171
+ # its object's value and the value changed.
226
172
  #
227
- def declaration_tail( node, indent )
228
- return raw_declaration_tail( node ) if value_unchanged?( node )
173
+ def own_text( node )
174
+ fixed = node.repaired
175
+ return fixed.raw unless node.obj && @index.holder?( node ) && node.changed?
229
176
 
230
- return node.obj.serialize_value( indent )
177
+ return @writer.changed_text( fixed )
231
178
  end
232
179
 
233
180
  #
234
- # Reproduce the original declaration's value text exactly as it
235
- # was read. raw_tail is the declaration line's own exact tail
236
- # (eg. " : BEGIN" or " :"); a begin_end/body style has further
237
- # raw content following it.
181
+ # Is this declaration where its object's new children are written?
238
182
  #
239
- def raw_declaration_tail( node )
240
- case node.block_style
241
- when :begin_end
242
- return "#{node.raw_tail}\n#{node.raw_value}#{node.raw_end_indent}END"
243
- when :body
244
- return node.raw_value.to_s.empty? ? node.raw_tail : "#{node.raw_tail}\n#{node.raw_value}"
245
- else
246
- return node.raw_tail
247
- end
183
+ def writes_children_of?( node )
184
+ fs, target, prefix = @index.writer_for( node.obj )
185
+ return fs&.source_doc.equal?( @source_doc ) && target.equal?( node ) && prefix.nil?
248
186
  end
249
187
 
250
- # ---------------------------------------------------------------------
251
- # Has the value actually changed since load?
252
- # ---------------------------------------------------------------------
253
-
254
188
  #
255
- # Does the live object's value still match what raw_value would
256
- # produce? A script body is compared line by line (raw comments
257
- # excluded, since they were never run); everything else is
258
- # compared by re-running the raw text through the same set_value
259
- # conversion the loader used, and comparing the result.
189
+ # The indentation for a new child of a declaration: its last
190
+ # child's, or one step in from it.
260
191
  #
261
- def value_unchanged?( node )
262
- return false unless node.obj
263
- return body_value_unchanged?( node ) if node.block_style == :body
264
-
265
- trial = node.obj.class.new( @engine )
266
- # Only a comparison: any bad-value warning was given on load.
267
- @engine.without_warnings { trial.set_value( node.raw_value ) }
268
- return trial.value == node.obj.value
269
- rescue
270
- return false
192
+ def child_indent( node )
193
+ last = node.children.reverse.find { |c| c.is_a?( Source::ObjNode ) }
194
+ return last ? last.raw_indent : "#{node.raw_indent}#{@unit}"
271
195
  end
272
196
 
273
197
  #
274
- # A script body's raw text, minus its comment lines (never
275
- # executed) and with each line stripped, should equal the live
276
- # object's own command lines.
198
+ # A node for an object no standing declaration places, and
199
+ # everything under it: a moved object keeps its first declaration
200
+ # (and doc) at the new indentation; a new one is written from
201
+ # scratch. name is how it's written (a dotted path for a shorthand
202
+ # line).
277
203
  #
278
- def body_value_unchanged?( node )
279
- raw_lines = node.raw_value.to_s.split( "\n" )
280
- executable = raw_lines.reject { |l| l.strip.start_with?( '#' ) }.map( &:strip )
281
- return executable == current_script_lines( node.obj )
204
+ def unplaced( obj, indent, name = obj.name )
205
+ node = moved_node( obj, indent, name ) || @writer.new_node( obj, indent, name )
206
+ obj.children.each do |child|
207
+ next if @index.placed?( child ) || !@index.claim( child )
208
+
209
+ node.children << unplaced( child, "#{indent}#{@unit}" )
210
+ end
211
+ return node
282
212
  end
283
213
 
284
214
  #
285
- # The live object's command lines, whatever form its value is
286
- # currently in.
215
+ # The first declaration of a moved object, at its new place.
287
216
  #
288
- def current_script_lines( obj )
289
- return obj.value if obj.value_is_array?
290
- return [] if obj.value_is_blank?
217
+ def moved_node( obj, indent, name )
218
+ _fs, first = @index.entries_for( obj ).first
219
+ return nil unless first
291
220
 
292
- return [ obj.value.to_s.strip ]
221
+ node = first.detached( first.changed? ? @writer.changed_text( first ) : first.raw )
222
+ node.rename( name ) unless name == node.name
223
+ node.reindent( indent )
224
+ return node
293
225
  end
294
226
 
295
227
  end
@@ -28,13 +28,24 @@ module Gloo
28
28
  end
29
29
 
30
30
  #
31
- # Save the object to the file. batch, when given, lets a
32
- # multi-file namespace round-trip -- see PersistMan#save_batch and
33
- # FileSaver.
31
+ # Save the object to the file. batch, when given, carries the
32
+ # SaveIndex shared by a save of several files -- see
33
+ # PersistMan#save_batch and FileSaver. Returns the source document
34
+ # for what was written.
34
35
  #
35
36
  def save( batch = nil )
36
37
  fs = FileSaver.new( @engine, @pn, @obj, @source_doc, batch )
37
- fs.save
38
+ return fs.save
39
+ end
40
+
41
+ #
42
+ # The file was just saved: keep the source document for what was
43
+ # written, with each declaration remembering its object's place and
44
+ # value as saved.
45
+ #
46
+ def saved( doc )
47
+ @source_doc = doc
48
+ doc.obj_nodes.each( &:remember )
38
49
  end
39
50
 
40
51
  #
@@ -0,0 +1,89 @@
1
+ # Author:: Eric Crane (mailto:eric.crane@mac.com)
2
+ # Copyright:: Copyright (c) 2026 Eric Crane. All rights reserved.
3
+ #
4
+ # How a file is written, learned from its source: the indentation it
5
+ # steps in by, and how it spells each type. A save writes new text the
6
+ # same way, so a 2-space file full of [int] and [can] gets 2 spaces,
7
+ # [int] and [can] back. A new file, or one with nothing to learn from,
8
+ # gets 2 spaces and each type's own name.
9
+ #
10
+
11
+ module Gloo
12
+ module Persist
13
+ class FileStyle
14
+
15
+ DEFAULT_UNIT = ' '.freeze
16
+
17
+ attr_reader :unit
18
+
19
+ #
20
+ # Learn the style of a source document (nil for a new file).
21
+ #
22
+ def initialize( engine, source_doc = nil )
23
+ @engine = engine
24
+ @unit = DEFAULT_UNIT
25
+ @spellings = {}
26
+ learn( source_doc ) if source_doc
27
+ end
28
+
29
+ #
30
+ # How to write an object's type: the spelling the file uses most
31
+ # for that type, or the type's own name. nil means the file writes
32
+ # it with no brackets at all (an untyped 'name : value').
33
+ #
34
+ def type_for( obj )
35
+ return @spellings.fetch( obj.class ) { obj.type_display }
36
+ end
37
+
38
+ private
39
+
40
+ #
41
+ # Read the unit and the type spellings from the document.
42
+ #
43
+ def learn( source_doc )
44
+ learn_unit( source_doc.children )
45
+ counts = Hash.new { |h, k| h[ k ] = Hash.new( 0 ) }
46
+ source_doc.obj_nodes.each do |node|
47
+ type = type_class( node )
48
+ counts[ type ][ spelling( node ) ] += 1 if type
49
+ end
50
+ counts.each { |type, spelled| @spellings[ type ] = spelled.max_by { |_, n| n }.first }
51
+ end
52
+
53
+ #
54
+ # The step in from a top-level declaration to its first child.
55
+ #
56
+ def learn_unit( nodes )
57
+ nodes.each do |node|
58
+ next unless node.is_a?( Source::ObjNode )
59
+
60
+ child = node.children.find { |c| c.is_a?( Source::ObjNode ) }
61
+ next unless child
62
+
63
+ step = child.raw_indent.delete_prefix( node.raw_indent )
64
+ @unit = step unless step.empty?
65
+ return
66
+ end
67
+ end
68
+
69
+ #
70
+ # The object type class a declaration was written with (nil for a
71
+ # type gloo doesn't know, which tells us nothing).
72
+ #
73
+ def type_class( node )
74
+ return @engine.dictionary.find_obj( node.raw_type.to_s )
75
+ end
76
+
77
+ #
78
+ # How the declaration spells its type: the text in its brackets,
79
+ # or nil when it has none.
80
+ #
81
+ def spelling( node )
82
+ return nil unless node.head.include?( '[' )
83
+
84
+ return node.raw_type
85
+ end
86
+
87
+ end
88
+ end
89
+ end
@@ -11,6 +11,8 @@ module Gloo
11
11
 
12
12
  BEGIN_BLOCK = 'BEGIN'.freeze
13
13
  END_BLOCK = 'END'.freeze
14
+ # A type in brackets with whitespace inside them: '[ int ]', '[int ]'.
15
+ SPACED_TYPE = /\A\[[ \t]*([^\s\]]+)[ \t]*\]/.freeze
14
16
 
15
17
  attr_reader :obj, :raw_tail, :missing_bracket
16
18
 
@@ -77,7 +79,7 @@ module Gloo
77
79
  # count.
78
80
  #
79
81
  def detect_type
80
- @line = @line[ @idx + 1..-1 ]
82
+ @line = close_up_spaced_type( @line[ @idx + 1..-1 ] )
81
83
  @idx = @line.index( ' ' )
82
84
 
83
85
  if @line[ 0 ] == ':'
@@ -95,6 +97,19 @@ module Gloo
95
97
  @raw_tail = close ? @line[ close + 1..-1 ] : ''
96
98
  end
97
99
 
100
+ #
101
+ # Spaces just inside the brackets ('[ int ]', '[string ]') don't
102
+ # change the type: with one type word and only whitespace around
103
+ # it, read the brackets as if written closed up ('[int]'). The
104
+ # rest of the line is untouched, so raw_tail is exactly as written.
105
+ #
106
+ def close_up_spaced_type( line )
107
+ match = SPACED_TYPE.match( line )
108
+ return line unless match
109
+
110
+ return "[#{match[ 1 ]}]#{match.post_match}"
111
+ end
112
+
98
113
  #
99
114
  # The type word starts with '['. The type ends at the ']' in that
100
115
  # word (which may be followed directly by the ':', as in