gloo 6.3.1 → 6.5.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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/CLAUDE.md +13 -0
  3. data/docs/application.md +9 -0
  4. data/docs/language_objects.md +78 -0
  5. data/docs/language_scripting.md +41 -0
  6. data/docs/objects.md +1 -1
  7. data/docs/verbs.md +36 -2
  8. data/lib/VERSION +1 -1
  9. data/lib/VERSION_NOTES +13 -0
  10. data/lib/gloo/app/engine.rb +5 -1
  11. data/lib/gloo/app/log.rb +5 -5
  12. data/lib/gloo/app/settings.rb +4 -1
  13. data/lib/gloo/core/obj.rb +60 -1
  14. data/lib/gloo/core/parser.rb +30 -1
  15. data/lib/gloo/core/tokens.rb +44 -18
  16. data/lib/gloo/expr/l_string.rb +8 -4
  17. data/lib/gloo/objs/basic/script.rb +28 -2
  18. data/lib/gloo/objs/web/json.rb +4 -1
  19. data/lib/gloo/persist/comment_buffer.rb +58 -0
  20. data/lib/gloo/persist/declaration_ledger.rb +61 -0
  21. data/lib/gloo/persist/disc_mech.rb +15 -2
  22. data/lib/gloo/persist/file_loader.rb +208 -102
  23. data/lib/gloo/persist/file_saver.rb +251 -6
  24. data/lib/gloo/persist/file_storage.rb +10 -4
  25. data/lib/gloo/persist/indent_stack.rb +90 -0
  26. data/lib/gloo/persist/line_splitter.rb +18 -3
  27. data/lib/gloo/persist/persist_man.rb +193 -26
  28. data/lib/gloo/persist/script_body_collector.rb +102 -0
  29. data/lib/gloo/persist/shorthand_expander.rb +51 -0
  30. data/lib/gloo/persist/source/blank_node.rb +27 -0
  31. data/lib/gloo/persist/source/comment_node.rb +27 -0
  32. data/lib/gloo/persist/source/directive_node.rb +26 -0
  33. data/lib/gloo/persist/source/obj_node.rb +91 -0
  34. data/lib/gloo/persist/source/source_doc.rb +41 -0
  35. data/lib/gloo/verbs/list.rb +24 -2
  36. data/lib/gloo/verbs/load.rb +9 -1
  37. data/lib/gloo/verbs/reload.rb +3 -0
  38. data/lib/gloo/verbs/save.rb +57 -10
  39. data/test.gloo/ctrl/each.test.gloo +28 -30
  40. data/test.gloo/dt/date.test.gloo +69 -71
  41. data/test.gloo/dt/datetime.test.gloo +55 -57
  42. data/test.gloo/dt/time.test.gloo +70 -72
  43. data/test.gloo/lang/continuation.test.gloo +25 -27
  44. data/test.gloo/lang/convert.test.gloo +50 -52
  45. data/test.gloo/lang/dt_comparisons.test.gloo +87 -89
  46. data/test.gloo/lang/exceptions.test.gloo +42 -44
  47. data/test.gloo/lang/gloo_sys.test.gloo +100 -102
  48. data/test.gloo/lang/here.test.gloo +32 -34
  49. data/test.gloo/lang/it.test.gloo +27 -29
  50. data/test.gloo/lang/literal.test.gloo +55 -57
  51. data/test.gloo/lang/load_lib_directive.test.gloo +18 -20
  52. data/test.gloo/lang/naming.test.gloo +28 -30
  53. data/test.gloo/lang/ops.test.gloo +53 -55
  54. data/test.gloo/lang/shorthand.test.gloo +27 -0
  55. data/test.gloo/math/add.test.gloo +40 -42
  56. data/test.gloo/math/div.test.gloo +23 -25
  57. data/test.gloo/math/mult.test.gloo +23 -25
  58. data/test.gloo/math/sub.test.gloo +23 -25
  59. data/test.gloo/objs/alias.test.gloo +15 -17
  60. data/test.gloo/objs/bool.test.gloo +54 -56
  61. data/test.gloo/objs/can.test.gloo +33 -35
  62. data/test.gloo/objs/cipher.test.gloo +39 -41
  63. data/test.gloo/objs/decimal.test.gloo +27 -29
  64. data/test.gloo/objs/erb.test.gloo +47 -49
  65. data/test.gloo/objs/file.test.gloo +68 -70
  66. data/test.gloo/objs/function.test.gloo +37 -39
  67. data/test.gloo/objs/int.test.gloo +54 -56
  68. data/test.gloo/objs/json.test.gloo +57 -50
  69. data/test.gloo/objs/obj.test.gloo +68 -48
  70. data/test.gloo/objs/outline.test.gloo +48 -50
  71. data/test.gloo/objs/password.test.gloo +34 -36
  72. data/test.gloo/objs/repeat.test.gloo +42 -44
  73. data/test.gloo/objs/script.test.gloo +13 -15
  74. data/test.gloo/objs/string.test.gloo +82 -84
  75. data/test.gloo/objs/text.test.gloo +90 -92
  76. data/test.gloo/objs/untyped.test.gloo +36 -38
  77. data/test.gloo/objs/uri.test.gloo +37 -39
  78. data/test.gloo/string/str.test.gloo +15 -0
  79. data/test.gloo/verbs/break.test.gloo +12 -14
  80. data/test.gloo/verbs/check.test.gloo +22 -24
  81. data/test.gloo/verbs/context.test.gloo +11 -13
  82. data/test.gloo/verbs/create.test.gloo +7 -9
  83. data/test.gloo/verbs/eval.test.gloo +10 -12
  84. data/test.gloo/verbs/exists.test.gloo +43 -45
  85. data/test.gloo/verbs/if.test.gloo +8 -10
  86. data/test.gloo/verbs/invoke.test.gloo +73 -75
  87. data/test.gloo/verbs/list.test.gloo +21 -23
  88. data/test.gloo/verbs/load.test.gloo +27 -21
  89. data/test.gloo/verbs/log.test.gloo +7 -9
  90. data/test.gloo/verbs/move.test.gloo +12 -14
  91. data/test.gloo/verbs/put.test.gloo +10 -12
  92. data/test.gloo/verbs/reload.test.gloo +55 -0
  93. data/test.gloo/verbs/run.test.gloo +8 -10
  94. data/test.gloo/verbs/save.test.gloo +111 -0
  95. data/test.gloo/verbs/show.test.gloo +26 -28
  96. data/test.gloo/verbs/tell.test.gloo +8 -10
  97. data/test.gloo/verbs/throw.test.gloo +31 -33
  98. data/test.gloo/verbs/unless.test.gloo +13 -15
  99. data/test.gloo/verbs/unload.test.gloo +10 -12
  100. metadata +14 -1
@@ -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
@@ -8,7 +8,7 @@ module Gloo
8
8
  module Persist
9
9
  class FileStorage
10
10
 
11
- attr_reader :obj, :pn
11
+ attr_reader :obj, :pn, :roots, :source_doc
12
12
 
13
13
  #
14
14
  # Set up a file storage for an object.
@@ -17,13 +17,17 @@ module Gloo
17
17
  @engine = engine
18
18
  @obj = obj
19
19
  @pn = pn
20
+ @roots = obj ? [ obj ] : []
21
+ @source_doc = nil
20
22
  end
21
23
 
22
24
  #
23
- # Save the object to the file.
25
+ # Save the object to the file. batch, when given, lets a
26
+ # multi-file namespace round-trip -- see PersistMan#save_batch and
27
+ # FileSaver.
24
28
  #
25
- def save
26
- fs = FileSaver.new( @engine, @pn, @obj )
29
+ def save( batch = nil )
30
+ fs = FileSaver.new( @engine, @pn, @obj, @source_doc, batch )
27
31
  fs.save
28
32
  end
29
33
 
@@ -34,6 +38,8 @@ module Gloo
34
38
  fl = FileLoader.new( @engine, @pn )
35
39
  fl.load
36
40
  @obj = fl.obj
41
+ @roots = fl.roots
42
+ @source_doc = fl.source_doc
37
43
  if @obj
38
44
  @engine.log.debug "Loaded object: #{@obj.name}"
39
45
  else
@@ -0,0 +1,90 @@
1
+ # Author:: Eric Crane (mailto:eric.crane@mac.com)
2
+ # Copyright:: Copyright (c) 2026 Eric Crane. All rights reserved.
3
+ #
4
+ # Tracks the current nesting level shared by the heap tree and the
5
+ # source tree as declaration lines are processed, pushing and popping
6
+ # both stacks in lockstep so their nesting always matches -- an object
7
+ # only becomes a parent once a deeper line actually follows it.
8
+ #
9
+
10
+ module Gloo
11
+ module Persist
12
+ class IndentStack
13
+
14
+ attr_reader :tabs
15
+
16
+ #
17
+ # Set up a stack rooted at the given heap object and source node.
18
+ #
19
+ def initialize( root_obj, root_node )
20
+ @tabs = 0
21
+ @parent_stack = [ root_obj ]
22
+ @node_stack = [ root_node ]
23
+ end
24
+
25
+ #
26
+ # The heap object new children should be created under.
27
+ #
28
+ def parent
29
+ return @parent_stack.last
30
+ end
31
+
32
+ #
33
+ # The source node new declarations/trivia should be appended to.
34
+ #
35
+ def node
36
+ return @node_stack.last
37
+ end
38
+
39
+ #
40
+ # Move the stacks to the right depth for a line at the given
41
+ # indentation, then -- if it turned out to be deeper than the
42
+ # previous line -- push the previous line's object/node as the
43
+ # new parent. last_obj/last_node are whatever was created for the
44
+ # previous declaration line.
45
+ #
46
+ def place( line_tabs, last_obj, last_node )
47
+ indent = indent_delta( line_tabs )
48
+ if indent.positive?
49
+ # last_obj is nil when the previous line failed to make an
50
+ # object (eg. an unknown type in a malformed file) -- keep
51
+ # the current parent rather than pushing nil.
52
+ @parent_stack.push( last_obj || @parent_stack.last )
53
+ @node_stack.push( last_node || @node_stack.last )
54
+ elsif indent.negative?
55
+ indent.abs.times do
56
+ # never pop the root -- a file with erratic (mixed
57
+ # tab/space) indentation can outdent further than it ever
58
+ # indented; clamp instead of emptying the stack.
59
+ break if @parent_stack.length <= 1
60
+
61
+ @parent_stack.pop
62
+ @node_stack.pop
63
+ end
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ #
70
+ # Update @tabs for the new line and return the signed change in
71
+ # nesting depth: positive if it's more indented than the last
72
+ # line placed, negative if less, zero if the same.
73
+ #
74
+ def indent_delta( line_tabs )
75
+ if line_tabs > @tabs
76
+ # TODO: What if indent is more than one more level?
77
+ @tabs = line_tabs
78
+ return 1
79
+ elsif line_tabs < @tabs
80
+ diff = @tabs - line_tabs
81
+ @tabs -= diff
82
+ return -diff
83
+ end
84
+
85
+ return 0
86
+ end
87
+
88
+ end
89
+ end
90
+ end
@@ -12,7 +12,7 @@ module Gloo
12
12
  BEGIN_BLOCK = 'BEGIN'.freeze
13
13
  END_BLOCK = 'END'.freeze
14
14
 
15
- attr_reader :obj
15
+ attr_reader :obj, :raw_tail
16
16
 
17
17
  #
18
18
  # Set up a line splitter
@@ -20,6 +20,7 @@ module Gloo
20
20
  def initialize( line, tabs )
21
21
  @line = line
22
22
  @tabs = tabs
23
+ @raw_tail = ''
23
24
  end
24
25
 
25
26
  #
@@ -36,15 +37,26 @@ module Gloo
36
37
  #
37
38
  # Detect the object name.
38
39
  #
40
+ # Only leading whitespace (the indentation, captured separately by
41
+ # the loader) and the trailing newline are removed here --
42
+ # trailing spaces/tabs are left on the line so they survive into
43
+ # a string value, which can matter when the value is used to
44
+ # build HTML.
45
+ #
39
46
  def detect_name
40
- @line = @line.strip
47
+ @line = @line.lstrip.chomp
41
48
  @idx = @line.index( ' ' )
42
49
  @idx = 0 unless @idx
43
50
  @name = @line[ 0..@idx - 1 ]
44
51
  end
45
52
 
46
53
  #
47
- # Detect the object type.
54
+ # Detect the object type, and capture the exact source text that
55
+ # follows it -- everything from the closing ']' (or, for an
56
+ # untyped declaration with no brackets at all, from right after
57
+ # the name) to the end of the line. Kept byte-exact so a save can
58
+ # reproduce a declaration's original spacing when its value
59
+ # hasn't changed.
48
60
  #
49
61
  def detect_type
50
62
  @line = @line[ @idx + 1..-1 ]
@@ -52,12 +64,15 @@ module Gloo
52
64
 
53
65
  if @line[ 0 ] == ':'
54
66
  @type = 'untyped'
67
+ @raw_tail = @line
55
68
  return
56
69
  end
57
70
 
58
71
  @type = @line[ 0..( @idx ? @idx - 1 : -1 ) ]
59
72
  @type = @type[ 1..-1 ] if @type[ 0 ] == '['
60
73
  @type = @type[ 0..-2 ] if @type[ -1 ] == ']'
74
+ close = @line.index( ']' )
75
+ @raw_tail = close ? @line[ close + 1..-1 ] : ''
61
76
  end
62
77
 
63
78
  #