gloo 6.3.0 → 6.4.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CLAUDE.md +13 -0
  3. data/docs/language_objects.md +38 -0
  4. data/docs/language_scripting.md +41 -0
  5. data/docs/verbs.md +27 -2
  6. data/lib/VERSION +1 -1
  7. data/lib/VERSION_NOTES +10 -0
  8. data/lib/gloo/core/obj.rb +40 -1
  9. data/lib/gloo/core/parser.rb +30 -1
  10. data/lib/gloo/core/tokens.rb +44 -18
  11. data/lib/gloo/expr/l_string.rb +8 -4
  12. data/lib/gloo/objs/basic/script.rb +28 -2
  13. data/lib/gloo/objs/web/json.rb +4 -1
  14. data/lib/gloo/persist/comment_buffer.rb +58 -0
  15. data/lib/gloo/persist/declaration_ledger.rb +61 -0
  16. data/lib/gloo/persist/disc_mech.rb +15 -2
  17. data/lib/gloo/persist/file_loader.rb +201 -100
  18. data/lib/gloo/persist/file_saver.rb +251 -6
  19. data/lib/gloo/persist/file_storage.rb +10 -4
  20. data/lib/gloo/persist/indent_stack.rb +90 -0
  21. data/lib/gloo/persist/line_splitter.rb +18 -3
  22. data/lib/gloo/persist/persist_man.rb +159 -24
  23. data/lib/gloo/persist/script_body_collector.rb +102 -0
  24. data/lib/gloo/persist/shorthand_expander.rb +51 -0
  25. data/lib/gloo/persist/source/blank_node.rb +27 -0
  26. data/lib/gloo/persist/source/comment_node.rb +27 -0
  27. data/lib/gloo/persist/source/directive_node.rb +26 -0
  28. data/lib/gloo/persist/source/obj_node.rb +50 -0
  29. data/lib/gloo/persist/source/source_doc.rb +41 -0
  30. data/lib/gloo/verbs/load.rb +5 -0
  31. data/lib/gloo/verbs/reload.rb +3 -0
  32. data/lib/gloo/verbs/save.rb +57 -10
  33. data/test.gloo/lang/shorthand.test.gloo +29 -0
  34. data/test.gloo/objs/json.test.gloo +9 -0
  35. data/test.gloo/objs/script.test.gloo +1 -1
  36. data/test.gloo/objs/text.test.gloo +22 -1
  37. data/test.gloo/string/str.test.gloo +15 -0
  38. data/test.gloo/verbs/reload.test.gloo +57 -0
  39. data/test.gloo/verbs/save.test.gloo +115 -0
  40. metadata +14 -1
@@ -1,7 +1,18 @@
1
1
  # Author:: Eric Crane (mailto:eric.crane@mac.com)
2
2
  # Copyright:: Copyright (c) 2019 Eric Crane. All rights reserved.
3
3
  #
4
- # Helper class used to load a file and create objects in the heap.
4
+ # Helper class used to load a file, create objects in the heap, and
5
+ # build a parallel Source::SourceDoc capturing everything about the
6
+ # file that the heap alone can't represent (comments, blank lines,
7
+ # raw formatting) -- so a later save can rewrite the file instead of
8
+ # regenerating it from scratch.
9
+ #
10
+ # Work is delegated to CommentBuffer (comment buffering),
11
+ # ScriptBodyCollector (script bodies), IndentStack (nesting shared by
12
+ # the heap and source trees), ShorthandExpander (nested-container
13
+ # shorthand), and DeclarationLedger (this file's roots + cross-file
14
+ # clash warnings) -- this class is the orchestrator: per-line dispatch
15
+ # and BEGIN/END handling.
5
16
  #
6
17
 
7
18
  module Gloo
@@ -20,7 +31,7 @@ module Gloo
20
31
  # afterward.
21
32
  LIB_DIRECTIVE = /\A(?:load|ld)\s+(?:lib|ext)\s+\S+\s*\z/i.freeze
22
33
 
23
- attr_reader :obj
34
+ attr_reader :obj, :source_doc
24
35
 
25
36
  #
26
37
  # Set up a file storage for an object.
@@ -29,16 +40,25 @@ module Gloo
29
40
  @engine = engine
30
41
  @mech = @engine.platform.get_file_mech( @engine )
31
42
  @pn = pn
32
- @tabs = 0
33
43
  @obj = nil
34
- @in_multiline = false
35
- @exiting_multiline = false
44
+ @source_doc = Gloo::Persist::Source::SourceDoc.new
45
+ @comments = Gloo::Persist::CommentBuffer.new
46
+ @body = Gloo::Persist::ScriptBodyCollector.new
47
+ @shorthand = Gloo::Persist::ShorthandExpander.new( engine )
48
+ @ledger = Gloo::Persist::DeclarationLedger.new( engine, pn )
36
49
  @in_block = false
37
50
  @block_value = ''
38
51
  @body_started = false
39
52
  @debug = false
40
53
  end
41
54
 
55
+ #
56
+ # The top-level objects this file declared (for save resolution).
57
+ #
58
+ def roots
59
+ return @ledger.roots
60
+ end
61
+
42
62
  #
43
63
  # Load the objects from the file.
44
64
  #
@@ -49,25 +69,12 @@ module Gloo
49
69
  end
50
70
 
51
71
  @engine.log.debug "Loading file '#{@pn}'"
52
- @tabs = 0
53
- @parent_stack = []
54
- @parent = @engine.heap.root
55
- @parent_stack.push @parent
72
+ @indent_stack = IndentStack.new( @engine.heap.root, @source_doc )
56
73
  f = @mech.read( @pn )
57
74
 
58
75
  f = join_continuations( f )
59
-
60
- f.each_line do |line|
61
- next if skip_line? line
62
-
63
- if !@body_started && line =~ LIB_DIRECTIVE
64
- run_lib_directive line
65
- next
66
- end
67
- @body_started = true
68
-
69
- handle_one_line line
70
- end
76
+ f.each_line { |line| dispatch_line( line ) }
77
+ finish
71
78
  end
72
79
 
73
80
  #
@@ -88,115 +95,195 @@ module Gloo
88
95
  return data.gsub( "\\\n", '' )
89
96
  end
90
97
 
98
+ # ---------------------------------------------------------------------
99
+ # Per-line dispatch
100
+ # ---------------------------------------------------------------------
101
+
91
102
  #
92
- # Process one one of the file we're loading.
103
+ # Route one line of the file to whichever mode we're currently in:
104
+ # collecting a script body, collecting a BEGIN/END block, trivia
105
+ # (comment/blank), a top-of-file directive, or a normal
106
+ # declaration.
93
107
  #
94
- def handle_one_line( line )
95
- if line.strip.end_with? BEGIN_BLOCK
96
- @in_block = true
97
- @save_line = line
98
- elsif @in_block
99
- if line.strip == END_BLOCK
100
- @in_block = false
101
- determine_indent @save_line
102
- process_line @save_line
103
- else
104
- @block_value << line
105
- end
106
- else
107
- determine_indent line
108
- process_line line
108
+ def dispatch_line( line )
109
+ return handle_body_line( line ) if @body.active
110
+ return handle_block_line( line ) if @in_block
111
+ return handle_trivia_line( line ) if skip_line?( line )
112
+
113
+ if !@body_started && line =~ LIB_DIRECTIVE
114
+ @indent_stack.node.children << Source::DirectiveNode.new( line.strip )
115
+ run_lib_directive line
116
+ return
109
117
  end
118
+ @body_started = true
119
+
120
+ handle_declaration_line( line )
110
121
  end
111
122
 
112
123
  #
113
- # Is this line a comment or a blank line?
114
- # If so we'll skip it.
124
+ # End of file: close out anything still open so its content
125
+ # isn't silently dropped.
115
126
  #
116
- def skip_line?( line )
117
- line = line.strip
118
- return true if line.empty?
119
- return true if line[ 0 ] == '#'
120
-
121
- return false
127
+ def finish
128
+ @body.finish
129
+ @comments.flush_into( @indent_stack.node.children )
122
130
  end
123
131
 
132
+ # ---------------------------------------------------------------------
133
+ # Trivia (comments and blank lines)
134
+ # ---------------------------------------------------------------------
135
+
124
136
  #
125
- # Determine the relative indent level for the line.
137
+ # A comment or blank line, outside of any block/body. A comment
138
+ # is buffered -- it may turn out to be the leading_doc for the
139
+ # declaration that follows. A blank line always breaks that
140
+ # association (detaches any buffered comments as floating nodes)
141
+ # and is itself kept, not discarded.
126
142
  #
127
- def determine_indent( line )
128
- tabs = tab_count( line )
129
- @indent = 0 # same level as prior line
130
- if tabs > @tabs # indent
131
- # TODO: What if indent is more than one more level?
132
- @tabs = tabs
133
- @indent = 1
134
- elsif tabs < @tabs # outdent
135
- diff = @tabs - tabs
136
- @tabs -= diff
137
- @indent -= diff
143
+ def handle_trivia_line( line )
144
+ if line.strip.empty?
145
+ @comments.flush_into( @indent_stack.node.children )
146
+ @indent_stack.node.children << Source::BlankNode.new( chomped( line ) )
147
+ else
148
+ @comments.push( chomped( line ), tab_count( line ) )
138
149
  end
139
- puts "tabs: #{@tabs}, indent: #{@indent}, line: #{line}" if @debug
140
150
  end
141
151
 
152
+ # ---------------------------------------------------------------------
153
+ # BEGIN / END blocks
154
+ # ---------------------------------------------------------------------
155
+
142
156
  #
143
- # Process one line and add objects.
157
+ # Inside a BEGIN/END block every line is literal content -- kept
158
+ # exactly as-is, comments and blank lines included.
144
159
  #
145
- def process_line( line )
146
- # reset multiline unless we're actually indented
147
- if @in_multiline && @multi_indent > @indent
148
- puts "Done multiline mi: #{@multi_indent}, i: #{@indent}" if @debug
149
- @in_multiline = false
150
- @exiting_multiline = true
151
- end
152
-
153
- if @in_multiline
154
- @last.add_line line
160
+ def handle_block_line( line )
161
+ if line.strip == END_BLOCK
162
+ @in_block = false
163
+ @block_end_indent = leading_ws( line )
164
+ finalize_declaration( @save_line )
155
165
  else
156
- setup_process_obj_line
157
- process_obj_line line
166
+ @block_value << line
158
167
  end
159
168
  end
160
169
 
170
+ # ---------------------------------------------------------------------
171
+ # Script bodies (indented lines, no BEGIN/END)
172
+ # ---------------------------------------------------------------------
173
+
161
174
  #
162
- # Setup and get ready to process an object line.
175
+ # One line while a script body might be open. Re-dispatches the
176
+ # line normally if it turns out to have closed the body.
163
177
  #
164
- def setup_process_obj_line
165
- if @exiting_multiline
166
- @exiting_multiline = false
167
- @indent += 1
168
- end
178
+ def handle_body_line( line )
179
+ consumed = @body.handle_line( line, tab_count( line ) )
180
+ dispatch_line( line ) unless consumed
181
+ end
169
182
 
170
- if @indent.positive?
171
- @parent = @last
172
- @parent_stack.push @parent
173
- elsif @indent.negative?
174
- @indent.abs.times do
175
- @parent_stack.pop
176
- @parent = @parent_stack.last
177
- end
183
+ # ---------------------------------------------------------------------
184
+ # Object declarations
185
+ # ---------------------------------------------------------------------
186
+
187
+ #
188
+ # A line that starts (or is) an object declaration.
189
+ #
190
+ def handle_declaration_line( line )
191
+ if line.strip.end_with?( BEGIN_BLOCK )
192
+ @in_block = true
193
+ @save_line = line
194
+ return
178
195
  end
196
+
197
+ finalize_declaration( line )
179
198
  end
180
199
 
181
200
  #
182
- # Process one line and add objects.
201
+ # Place the declaration at its indentation level and create the
202
+ # object (and its Source::ObjNode).
183
203
  #
184
- def process_obj_line( line )
185
- name, type, value = split_line( line )
186
- unless @block_value == ''
187
- value = @block_value
188
- @block_value = ''
189
- end
190
- params = { name: name, type: type, value: value, parent: @parent }
204
+ def finalize_declaration( line )
205
+ line_tabs = tab_count( line )
206
+ @indent_stack.place( line_tabs, @last, @last_node )
207
+ create_declared_obj( line, line_tabs )
208
+ end
209
+
210
+ #
211
+ # Create the heap object and its source node for one declaration
212
+ # line, and start body collection if its type calls for one.
213
+ #
214
+ def create_declared_obj( line, line_tabs )
215
+ name, type, value, style = split_declaration( line )
216
+ leaf, parent, roots, created = @shorthand.expand( name, @indent_stack.parent )
217
+ roots.each { |r| @ledger.root( r ) }
218
+ created.each { |c| @ledger.created( c ) }
219
+
220
+ prior = parent.find_child( leaf )
221
+ params = { :name => leaf, :type => type, :value => value, :parent => parent }
191
222
  @last = @engine.factory.create( params )
223
+ @ledger.clash( prior, leaf, value ) if prior
224
+ @ledger.created( @last )
225
+ @ledger.root( @last ) if parent == @engine.heap.root
192
226
 
193
- if value&.empty? && @last&.multiline_value?
194
- @multi_indent = 0
195
- @in_multiline = true
196
- puts "*** Start multiline. multi_indent: #{@multi_indent}" if @debug
197
- end
227
+ node = build_obj_node( leading_ws( line ), name, type, value, style )
228
+ node.leading_doc = @comments.take_leading_doc( line_tabs, @indent_stack.node.children )
229
+ @indent_stack.node.children << node
230
+ @last_node = node
231
+ @obj ||= @ledger.roots.last || @last
232
+
233
+ # A whitespace-only inline value (trailing spaces after the ':'
234
+ # on a `script :` line) counts as no value -- start the body.
235
+ no_value = value.nil? || value.strip.empty?
236
+ @body.start( node, @last, @indent_stack.tabs ) if no_value && @last&.multiline_value?
237
+ end
238
+
239
+ #
240
+ # Split a declaration line into name/type/value, plus a style
241
+ # hash (:block_style, :raw_tail), folding in any BEGIN/END block
242
+ # value collected just before it. raw_tail is the exact source
243
+ # text that followed the type bracket on this line -- eg.
244
+ # " : BEGIN" or " :" -- kept for a save to reproduce the
245
+ # declaration's original spacing when unchanged.
246
+ #
247
+ def split_declaration( line )
248
+ splitter = LineSplitter.new( line, @indent_stack&.tabs || 0 )
249
+ name, type, value = splitter.split
250
+ style = { :raw_tail => splitter.raw_tail, :block_style => :inline }
251
+ return name, type, value, style if @block_value == ''
252
+
253
+ value = @block_value
254
+ @block_value = ''
255
+ style[ :block_style ] = :begin_end
256
+ style[ :raw_end_indent ] = @block_end_indent
257
+ return name, type, value, style
258
+ end
259
+
260
+ #
261
+ # Build the source node for one object declaration. raw_value is
262
+ # kept byte-exact with whatever was handed to the heap object's
263
+ # set_value (trailing newline included, for a BEGIN/END block) so
264
+ # a save can compare the two directly.
265
+ #
266
+ def build_obj_node( raw_indent, name, type, value, style )
267
+ node = Source::ObjNode.new( :name => name, :raw_type => type )
268
+ node.raw_indent = raw_indent
269
+ node.raw_tail = style[ :raw_tail ]
270
+ node.block_style = style[ :block_style ]
271
+ node.raw_value = value
272
+ node.raw_end_indent = style[ :raw_end_indent ] || ''
273
+ node.obj = @last
274
+ return node
275
+ end
276
+
277
+ #
278
+ # Is this line a comment or a blank line?
279
+ # If so we'll skip it.
280
+ #
281
+ def skip_line?( line )
282
+ line = line.strip
283
+ return true if line.empty?
284
+ return true if line[ 0 ] == '#'
198
285
 
199
- @obj = @last if @obj.nil?
286
+ return false
200
287
  end
201
288
 
202
289
  #
@@ -220,10 +307,24 @@ module Gloo
220
307
  # Split the line into 3 parts.
221
308
  #
222
309
  def split_line( line )
223
- o = LineSplitter.new( line, @tabs )
310
+ o = LineSplitter.new( line, @indent_stack&.tabs || 0 )
224
311
  return o.split
225
312
  end
226
313
 
314
+ #
315
+ # The line's leading whitespace, exactly as written.
316
+ #
317
+ def leading_ws( line )
318
+ return line[ /\A[ \t]*/ ]
319
+ end
320
+
321
+ #
322
+ # A line's raw text with its trailing newline removed.
323
+ #
324
+ def chomped( line )
325
+ return line.chomp( "\n" )
326
+ end
327
+
227
328
  end
228
329
  end
229
330
  end
@@ -3,26 +3,52 @@
3
3
  #
4
4
  # Helper class used to save an object to a file.
5
5
  #
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.
13
+ #
14
+ # 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.
17
+ #
6
18
 
7
19
  module Gloo
8
20
  module Persist
9
21
  class FileSaver
10
22
 
11
23
  #
12
- # Set up a file storage for an object.
24
+ # 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.
13
36
  #
14
- def initialize( engine, pn, obj )
37
+ def initialize( engine, pn, obj, source_doc = nil, batch = nil )
15
38
  @engine = engine
16
39
  @mech = @engine.platform.get_file_mech( @engine )
17
40
  @pn = pn
18
41
  @obj = obj
42
+ @source_doc = source_doc
43
+ @others = batch && batch[ :others ]
44
+ @claimed = batch && batch[ :claimed ]
19
45
  end
20
46
 
21
47
  #
22
48
  # Save the object to the file.
23
49
  #
24
50
  def save
25
- data = get_obj( @obj )
51
+ data = @source_doc ? rewrite : regenerate
26
52
  @mech.write( @pn, data )
27
53
  end
28
54
 
@@ -34,18 +60,237 @@ module Gloo
34
60
  end
35
61
 
36
62
  #
37
- # Convert an object to textual representation.
38
- # This is a recursive function.
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.)
68
+ #
69
+ def dirty?
70
+ return false unless @source_doc
71
+
72
+ return any_dirty?( @source_doc.children, @engine.heap.root )
73
+ end
74
+
75
+ #
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).
39
80
  #
40
81
  def get_obj( obj, indent = 0 )
41
82
  t = tabs( indent )
42
- str = "#{t}#{obj.name} [#{obj.type_display}] : #{obj.value_display}\n"
83
+ str = "#{t}#{obj.name} [#{obj.type_display}]#{obj.serialize_value( indent )}\n"
43
84
  obj.children.each do |child|
44
85
  str << get_obj( child, indent + 1 )
45
86
  end
46
87
  return str
47
88
  end
48
89
 
90
+ private
91
+
92
+ #
93
+ # Regenerate the whole file from the heap (the pre-Source::SourceDoc
94
+ # behavior), used when there's no source text to rewrite.
95
+ #
96
+ def regenerate
97
+ return get_obj( @obj )
98
+ end
99
+
100
+ #
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?
104
+ #
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 )
112
+
113
+ any_dirty?( node.children, leaf )
114
+ end
115
+ end
116
+
117
+ # ---------------------------------------------------------------------
118
+ # Rewrite (Source::SourceDoc-driven)
119
+ # ---------------------------------------------------------------------
120
+
121
+ #
122
+ # Rewrite the file from its source document: known declarations
123
+ # are patched in place, deleted ones dropped, and anything new
124
+ # appended.
125
+ #
126
+ def rewrite
127
+ return render_children( @source_doc.children, @engine.heap.root, 0 )
128
+ end
129
+
130
+ #
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).
137
+ #
138
+ def render_children( nodes, live_parent, indent )
139
+ str = ''
140
+ seen = []
141
+ 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 )
150
+ end
151
+ end
152
+
153
+ ( live_parent.children - seen ).each do |child|
154
+ next unless write_new_child?( child )
155
+
156
+ str << get_obj( child, indent )
157
+ end
158
+ return str
159
+ end
160
+
161
+ #
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.
166
+ #
167
+ def write_new_child?( child )
168
+ return false if @others&.key?( child )
169
+ return false if @claimed&.key?( child )
170
+
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 ]
188
+ 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"
207
+ end
208
+
209
+ #
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.
215
+ #
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
221
+ end
222
+
223
+ #
224
+ # The text that follows "name [type]" on the declaration line:
225
+ # the raw source, unless the live value no longer matches it.
226
+ #
227
+ def declaration_tail( node, indent )
228
+ return raw_declaration_tail( node ) if value_unchanged?( node )
229
+
230
+ return node.obj.serialize_value( indent )
231
+ end
232
+
233
+ #
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.
238
+ #
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
248
+ end
249
+
250
+ # ---------------------------------------------------------------------
251
+ # Has the value actually changed since load?
252
+ # ---------------------------------------------------------------------
253
+
254
+ #
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.
260
+ #
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
+ trial.set_value( node.raw_value )
267
+ return trial.value == node.obj.value
268
+ rescue
269
+ return false
270
+ end
271
+
272
+ #
273
+ # A script body's raw text, minus its comment lines (never
274
+ # executed) and with each line stripped, should equal the live
275
+ # object's own command lines.
276
+ #
277
+ def body_value_unchanged?( node )
278
+ raw_lines = node.raw_value.to_s.split( "\n" )
279
+ executable = raw_lines.reject { |l| l.strip.start_with?( '#' ) }.map( &:strip )
280
+ return executable == current_script_lines( node.obj )
281
+ end
282
+
283
+ #
284
+ # The live object's command lines, whatever form its value is
285
+ # currently in.
286
+ #
287
+ def current_script_lines( obj )
288
+ return obj.value if obj.value_is_array?
289
+ return [] if obj.value_is_blank?
290
+
291
+ return [ obj.value.to_s.strip ]
292
+ end
293
+
49
294
  end
50
295
  end
51
296
  end