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
@@ -12,6 +12,11 @@ module Gloo
12
12
 
13
13
  attr_reader :maps, :mech
14
14
 
15
+ OBJ_NOT_FOUND_ERR = 'Could not resolve object to save: '.freeze
16
+ PATH_EXISTS_ERR = 'Will not overwrite a file not already saved there: '.freeze
17
+ RELOAD_DIRTY_WARNING = 'Reloading will discard unsaved changes in: '.freeze
18
+ FILE_NOT_FOUND_ERR = 'File not found: '.freeze
19
+
15
20
  #
16
21
  # Constructor for the persistence manager.
17
22
  #
@@ -22,51 +27,84 @@ module Gloo
22
27
  end
23
28
 
24
29
  #
25
- # Save one object to the file.
30
+ # Save one object to the file, or every open file if no name is
31
+ # given.
26
32
  #
27
33
  def save( name = '' )
28
34
  name.blank? ? save_all : save_one( name )
29
35
  end
30
36
 
31
37
  #
32
- # Save one object to the file.
38
+ # Save every open file.
33
39
  #
34
40
  def save_all
35
- @maps.each( &:save )
41
+ save_batch( @maps )
36
42
  end
37
43
 
38
44
  #
39
- # Save one object to the file.
45
+ # Save the given object: every file that owns a declaration in
46
+ # its root's tree (usually one -- more when the root is a
47
+ # multi-file namespace). If it isn't mapped to any file yet, it's
48
+ # saved fresh to a default path derived from its root's name.
40
49
  #
41
50
  def save_one( name )
42
- ref = Gloo::Core::Pn.new( @engine, name )
43
- obj = ref.resolve
51
+ obj = resolve_for_save( name )
52
+ return unless obj
53
+
54
+ fs_list = find_file_storages( obj )
55
+ return save_new( obj, @mech.resolve_save_path( root_of( obj ).name ) ) if fs_list.empty?
44
56
 
45
- # Send an on_save event
46
57
  @engine.event_manager.on_save obj
58
+ save_batch( fs_list )
59
+ end
47
60
 
48
- fs = find_file_storage( obj )
49
- fs.save
61
+ #
62
+ # Save the given object to an explicit path. Registers the
63
+ # mapping so a future bare save includes it. Refuses to overwrite
64
+ # a file that exists but isn't already mapped to this object.
65
+ #
66
+ def save_to( name, path )
67
+ obj = resolve_for_save( name )
68
+ return unless obj
69
+
70
+ pn = @mech.resolve_save_path( path )
71
+ mapped = @maps.find { |fs| fs.pn == pn }
72
+ return save_mapped( obj, mapped, pn ) if mapped
73
+ return @engine.err( "#{PATH_EXISTS_ERR}#{pn}" ) if @mech.exist?( pn )
74
+
75
+ save_new( obj, pn )
50
76
  end
51
77
 
52
78
  #
53
- # Load the object from the file.
79
+ # Load the object(s) from the file (or files, for a wildcard).
80
+ # Returns true if at least one object was loaded, false if the
81
+ # name couldn't be resolved to any file or nothing loaded --
82
+ # a missing/unresolvable file is reported via engine.err rather
83
+ # than failing silently.
54
84
  #
55
85
  def load( name )
56
86
  pns = get_full_path_names name
57
- return unless pns
87
+ if pns.nil? || pns.empty?
88
+ @engine.err( "#{FILE_NOT_FOUND_ERR}#{name}" ) unless name.to_s.strip.empty?
89
+ return false
90
+ end
58
91
 
92
+ loaded = false
59
93
  pns.each do |pn|
60
94
  @engine.log.debug "Load file(s) at: #{pn}"
61
95
  begin
62
96
  fs = Gloo::Persist::FileStorage.new( @engine, pn )
63
97
  fs.load
98
+ next unless fs.obj # a failed load -- don't map it
99
+
64
100
  @maps << fs
65
101
  @engine.event_manager.on_load fs.obj
102
+ loaded = true
66
103
  rescue => ex
67
104
  @engine.handle_exception( ex )
68
105
  end
69
106
  end
107
+ return loaded
70
108
  end
71
109
 
72
110
  #
@@ -74,8 +112,8 @@ module Gloo
74
112
  # The engine is reset to a clean state.
75
113
  #
76
114
  def unload_all
77
- objs = self.maps.map { |fs| fs.obj }
78
- objs.each { |o| o.msg_unload }
115
+ objs = self.maps.map( &:obj ).compact
116
+ objs.each( &:msg_unload )
79
117
  @engine.reset_state
80
118
  end
81
119
 
@@ -88,22 +126,21 @@ module Gloo
88
126
  def unload( obj )
89
127
  @engine.event_manager.on_unload obj
90
128
  @engine.heap.unload obj
91
- @maps.each_with_index do |o, i|
92
- if o.obj.pn === obj.pn
93
- @maps.delete_at( i )
94
- return
95
- end
96
- end
129
+ # Drop every mapping for this object -- more than one when the
130
+ # root came from several files (the namespace pattern) -- and
131
+ # sweep out any mapping whose object is already gone.
132
+ @maps.reject! { |o| o.obj.nil? || ( o.obj.pn === obj.pn ) }
97
133
  end
98
134
 
99
- #
135
+ #
100
136
  # Reload all objects.
101
137
  # First send a message to each object to let it know it is being reloaded.
102
138
  # Then let the engine restart to reload the files.
103
- #
139
+ #
104
140
  def reload_all
105
141
  return unless @maps
106
-
142
+
143
+ @maps.each { |fs| warn_if_dirty( fs ) }
107
144
  @maps.each do |fs|
108
145
  @engine.event_manager.on_reload fs.obj
109
146
  end
@@ -112,23 +149,24 @@ module Gloo
112
149
  @engine.restart
113
150
  end
114
151
 
115
- #
152
+ #
116
153
  # Re-load the given object from file.
117
154
  # This is used to reload a single object and comes from a
118
155
  # message sent to the object.
119
- #
156
+ #
120
157
  def reload( obj )
121
158
  fs = find_file_storage( obj )
122
159
  return unless fs
123
160
 
161
+ warn_if_dirty( fs )
124
162
  @engine.event_manager.on_reload obj
125
163
  @engine.heap.unload obj
126
164
  fs.load
127
165
  end
128
166
 
129
- #
167
+ #
130
168
  # Find the objects FileStorage in the list.
131
- #
169
+ #
132
170
  def find_file_storage( obj )
133
171
  @maps.each do |o|
134
172
  return o if ( o.obj.pn === obj.pn )
@@ -138,6 +176,16 @@ module Gloo
138
176
  return nil
139
177
  end
140
178
 
179
+ #
180
+ # Find every FileStorage that owns a declaration in the given
181
+ # object's root -- usually one, more when the root is shared
182
+ # across files (the multi-file namespace pattern).
183
+ #
184
+ def find_file_storages( obj )
185
+ root = root_of( obj )
186
+ return @maps.select { |fs| fs.roots.include?( root ) }
187
+ end
188
+
141
189
 
142
190
  #
143
191
  # Get the full path and name of the file.
@@ -176,6 +224,125 @@ module Gloo
176
224
  end
177
225
  end
178
226
 
227
+ private
228
+
229
+ #
230
+ # Resolve a name to an object for saving, reporting an error and
231
+ # returning nil if it can't be found.
232
+ #
233
+ def resolve_for_save( name )
234
+ obj = Gloo::Core::Pn.new( @engine, name ).resolve
235
+ @engine.err( "#{OBJ_NOT_FOUND_ERR}#{name}" ) unless obj
236
+ return obj
237
+ end
238
+
239
+ #
240
+ # Walk up to the top-level declared object that contains obj --
241
+ # its parent is the heap root, not another declared object.
242
+ #
243
+ def root_of( obj )
244
+ o = obj
245
+ o = o.parent until o.parent.nil? || o.parent.root?
246
+ return o
247
+ end
248
+
249
+ #
250
+ # Save a set of files as one batch so a multi-file namespace
251
+ # round-trips: each file only rewrites the declarations its own
252
+ # SourceDoc holds and leaves other files' declarations alone; a
253
+ # brand-new object no file owns is written once, by the first file
254
+ # in the batch to reach it.
255
+ #
256
+ def save_batch( files )
257
+ claimed = {}.compare_by_identity
258
+ files.each do |fs|
259
+ fs.save( :others => owned_elsewhere( fs ), :claimed => claimed )
260
+ end
261
+ end
262
+
263
+ #
264
+ # Every heap object declared by some loaded file's SourceDoc
265
+ # other than fs.
266
+ #
267
+ def owned_elsewhere( fs )
268
+ owned = {}.compare_by_identity
269
+ @maps.each do |other|
270
+ next if other.equal?( fs )
271
+
272
+ collect_owned( other.source_doc&.children, owned )
273
+ end
274
+ return owned
275
+ end
276
+
277
+ #
278
+ # Recursively collect the objects declared by the given source
279
+ # nodes into owned.
280
+ #
281
+ def collect_owned( nodes, owned )
282
+ return unless nodes
283
+
284
+ nodes.each do |node|
285
+ next unless node.is_a?( Gloo::Persist::Source::ObjNode )
286
+
287
+ if node.obj
288
+ owned[ node.obj ] = true
289
+ mark_shorthand_ancestors( node, owned )
290
+ end
291
+ collect_owned( node.children, owned )
292
+ end
293
+ end
294
+
295
+ #
296
+ # A nested-container shorthand declaration (a.b.c [type] :) only
297
+ # records a source node for the leaf; the intermediate containers
298
+ # (a, a.b) are real heap objects with no node of their own. Mark
299
+ # them owned too, walking up from the leaf, so another file's save
300
+ # doesn't see an intermediate as an orphan and adopt its whole
301
+ # subtree.
302
+ #
303
+ def mark_shorthand_ancestors( node, owned )
304
+ return unless node.name.include?( '.' )
305
+
306
+ o = node.obj.parent
307
+ while o && !o.root?
308
+ owned[ o ] = true
309
+ o = o.parent
310
+ end
311
+ end
312
+
313
+ #
314
+ # Save to a path that's already mapped to some file. If that
315
+ # mapping is for a different root, it's a real collision -- the
316
+ # path belongs to something else.
317
+ #
318
+ def save_mapped( obj, mapped, pn )
319
+ return @engine.err( "#{PATH_EXISTS_ERR}#{pn}" ) unless mapped.roots.include?( root_of( obj ) )
320
+
321
+ @engine.event_manager.on_save obj
322
+ save_batch( [ mapped ] )
323
+ end
324
+
325
+ #
326
+ # Save an object's root fresh to a new path, and register the
327
+ # resulting mapping.
328
+ #
329
+ def save_new( obj, pn )
330
+ @engine.event_manager.on_save obj
331
+ fs = Gloo::Persist::FileStorage.new( @engine, pn, root_of( obj ) )
332
+ fs.save
333
+ @maps << fs
334
+ end
335
+
336
+ #
337
+ # Warn (without blocking) if reloading fs would discard changes
338
+ # that haven't been saved -- any object whose value no longer
339
+ # round-trips to what's on disk.
340
+ #
341
+ def warn_if_dirty( fs )
342
+ saver = Gloo::Persist::FileSaver.new( @engine, fs.pn, fs.obj, fs.source_doc )
343
+ @engine.log.warn "#{RELOAD_DIRTY_WARNING}#{fs.pn}" if saver.dirty?
344
+ end
345
+
179
346
  end
180
347
  end
181
348
  end
@@ -0,0 +1,102 @@
1
+ # Author:: Eric Crane (mailto:eric.crane@mac.com)
2
+ # Copyright:: Copyright (c) 2026 Eric Crane. All rights reserved.
3
+ #
4
+ # Collects an indented, unparsed script body (no BEGIN/END) for the
5
+ # object that was just declared. We don't yet know whether a body
6
+ # actually follows -- that's decided by the next line's indentation,
7
+ # so this may close immediately with nothing collected (an empty
8
+ # script).
9
+ #
10
+
11
+ module Gloo
12
+ module Persist
13
+ class ScriptBodyCollector
14
+
15
+ attr_reader :active
16
+
17
+ #
18
+ # Set up an inactive collector.
19
+ #
20
+ def initialize
21
+ @active = false
22
+ end
23
+
24
+ #
25
+ # Start tentatively collecting a body for the object just
26
+ # declared. node is its Source::ObjNode, heap_obj the object
27
+ # itself, base_tabs the indentation of the declaration line
28
+ # (body lines must be indented deeper than this).
29
+ #
30
+ def start( node, heap_obj, base_tabs )
31
+ @active = true
32
+ @node = node
33
+ @obj = heap_obj
34
+ @base_tabs = base_tabs
35
+ @lines = []
36
+ end
37
+
38
+ #
39
+ # One line while a body might be open. line_tabs is its
40
+ # indentation (ignored for a blank line, which is always kept).
41
+ # Returns true if the line was consumed as body content, or
42
+ # false if it closed the body and still needs to be dispatched
43
+ # normally by the caller.
44
+ #
45
+ def handle_line( line, line_tabs )
46
+ if line.strip.empty?
47
+ @lines << chomped( line )
48
+ return true
49
+ end
50
+
51
+ if line_tabs <= @base_tabs
52
+ close
53
+ return false
54
+ end
55
+
56
+ add_line( line )
57
+ return true
58
+ end
59
+
60
+ #
61
+ # End of file with a body still open (eg. the last object in the
62
+ # file is an empty or in-progress script) -- close it so its
63
+ # content isn't silently dropped.
64
+ #
65
+ def finish
66
+ close if @active
67
+ end
68
+
69
+ private
70
+
71
+ #
72
+ # Record one real body line: kept verbatim for the source model;
73
+ # added as a runnable command only if it isn't a comment (a
74
+ # comment inside a script body is documentation, not something
75
+ # to execute).
76
+ #
77
+ def add_line( line )
78
+ @lines << chomped( line )
79
+ @obj.add_line( line ) unless line.strip.start_with?( '#' )
80
+ end
81
+
82
+ #
83
+ # Close out the body, recording its raw content on the node.
84
+ #
85
+ def close
86
+ @active = false
87
+ @node.raw_value = @lines.join( "\n" )
88
+ @node.block_style = :body unless @lines.empty?
89
+ @node = nil
90
+ @obj = nil
91
+ end
92
+
93
+ #
94
+ # A line's raw text with its trailing newline removed.
95
+ #
96
+ def chomped( line )
97
+ return line.chomp( "\n" )
98
+ end
99
+
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,51 @@
1
+ # Author:: Eric Crane (mailto:eric.crane@mac.com)
2
+ # Copyright:: Copyright (c) 2026 Eric Crane. All rights reserved.
3
+ #
4
+ # Nested container shorthand: a dotted name in a declaration
5
+ # (page.core.users.list [type] :) means "find or create a container
6
+ # for each prefix segment, then declare the real object under the
7
+ # last one". A segment that already names some other kind of object
8
+ # is used as-is, not forced to a container.
9
+ #
10
+
11
+ module Gloo
12
+ module Persist
13
+ class ShorthandExpander
14
+
15
+ #
16
+ # Set up an expander for the given engine.
17
+ #
18
+ def initialize( engine )
19
+ @engine = engine
20
+ end
21
+
22
+ #
23
+ # Split a possibly-dotted name against the given parent. Returns
24
+ # [leaf_name, parent_for_the_leaf, roots_touched, created]:
25
+ # - roots_touched: containers created OR reused directly under
26
+ # the heap root, for the loader to register as this file's roots
27
+ # - created: containers this call actually created (any level),
28
+ # for the loader to remember as its own
29
+ #
30
+ def expand( name, parent )
31
+ return [ name, parent, [], [] ] unless name.include?( '.' )
32
+
33
+ segments = name.split( '.' )
34
+ leaf = segments.pop
35
+ roots = []
36
+ created = []
37
+ segments.each do |seg|
38
+ child = parent.find_child( seg )
39
+ unless child
40
+ child = @engine.factory.create_can( seg, parent )
41
+ created << child
42
+ end
43
+ roots << child if parent == @engine.heap.root
44
+ parent = child
45
+ end
46
+ return [ leaf, parent, roots, created ]
47
+ end
48
+
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,27 @@
1
+ # Author:: Eric Crane (mailto:eric.crane@mac.com)
2
+ # Copyright:: Copyright (c) 2026 Eric Crane. All rights reserved.
3
+ #
4
+ # A blank line between declarations. Kept (not discarded) so a save
5
+ # can reproduce the file's original spacing.
6
+ #
7
+
8
+ module Gloo
9
+ module Persist
10
+ module Source
11
+ class BlankNode
12
+
13
+ attr_reader :raw
14
+
15
+ #
16
+ # Set up a blank line node. raw is the full source line, minus
17
+ # its trailing newline (may be non-empty if the blank line had
18
+ # trailing whitespace -- preserved rather than normalized).
19
+ #
20
+ def initialize( raw = '' )
21
+ @raw = raw
22
+ end
23
+
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,27 @@
1
+ # Author:: Eric Crane (mailto:eric.crane@mac.com)
2
+ # Copyright:: Copyright (c) 2026 Eric Crane. All rights reserved.
3
+ #
4
+ # A whole-line comment that is not attached to any object declaration
5
+ # as its leading_doc -- either because a blank line separated it from
6
+ # the declaration that follows, or nothing follows it at all.
7
+ #
8
+
9
+ module Gloo
10
+ module Persist
11
+ module Source
12
+ class CommentNode
13
+
14
+ attr_reader :raw
15
+
16
+ #
17
+ # Set up a comment node. raw is the full source line, minus
18
+ # its trailing newline.
19
+ #
20
+ def initialize( raw )
21
+ @raw = raw
22
+ end
23
+
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,26 @@
1
+ # Author:: Eric Crane (mailto:eric.crane@mac.com)
2
+ # Copyright:: Copyright (c) 2026 Eric Crane. All rights reserved.
3
+ #
4
+ # A top-of-file 'load lib {name}' (or 'load ext {name}') directive,
5
+ # run by the loader itself before the declarations that follow it.
6
+ #
7
+
8
+ module Gloo
9
+ module Persist
10
+ module Source
11
+ class DirectiveNode
12
+
13
+ attr_reader :raw
14
+
15
+ #
16
+ # Set up a directive node. raw is the full source line, minus
17
+ # its trailing newline.
18
+ #
19
+ def initialize( raw )
20
+ @raw = raw
21
+ end
22
+
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,91 @@
1
+ # Author:: Eric Crane (mailto:eric.crane@mac.com)
2
+ # Copyright:: Copyright (c) 2026 Eric Crane. All rights reserved.
3
+ #
4
+ # One object declaration in a source file, eg.
5
+ # name [type] : value
6
+ # Carries the raw text needed to reproduce the declaration unchanged,
7
+ # plus a back-reference to the heap object it created (or updated),
8
+ # so a save can tell whether the live value still matches what's on
9
+ # disk and, if so, leave the raw text alone.
10
+ #
11
+
12
+ module Gloo
13
+ module Persist
14
+ module Source
15
+ class ObjNode
16
+
17
+ # block_style is one of:
18
+ # :inline -- name [type] : value, all on one line
19
+ # :begin_end -- name [type] : BEGIN ... END
20
+ # :body -- name [type] :, followed by an indented,
21
+ # unparsed body (script commands)
22
+ attr_accessor :name, :raw_type, :raw_indent, :raw_tail, :block_style,
23
+ :raw_value, :raw_end_indent, :leading_doc, :trailing_comment, :obj
24
+ attr_reader :children
25
+
26
+ #
27
+ # Set up an object declaration node. Everything but the name
28
+ # and raw type is optional and commonly set afterward via the
29
+ # accessors above -- keeping the initializer small.
30
+ #
31
+ def initialize( name:, raw_type: )
32
+ @name = name
33
+ @raw_type = raw_type
34
+ @raw_indent = ''
35
+ @raw_tail = ''
36
+ @block_style = :inline
37
+ @raw_value = nil
38
+ # a begin_end block's closing END is not necessarily indented
39
+ # to match its declaration -- kept separately, raw.
40
+ @raw_end_indent = ''
41
+ @leading_doc = nil
42
+ @trailing_comment = nil
43
+ @obj = nil
44
+ @children = []
45
+ end
46
+
47
+ #
48
+ # The cleaned-up leading_doc: each line's leading whitespace and
49
+ # its '#' marker (plus one following space, if any) stripped,
50
+ # then the result dedented to its shallowest line. The full
51
+ # block is kept as written, blank '#' lines at the top/bottom
52
+ # included -- it's a faithful reproduction of the comment, not
53
+ # a trimmed summary. '' when there's no leading_doc at all.
54
+ #
55
+ def doc
56
+ return '' unless @leading_doc
57
+
58
+ lines = @leading_doc.split( "\n" ).map { |l| strip_marker( l ) }
59
+ return dedent( lines ).join( "\n" )
60
+ end
61
+
62
+ private
63
+
64
+ #
65
+ # One raw comment line -> its text past the '#': leading
66
+ # whitespace dropped, then the '#' and at most one space after
67
+ # it, then trailing whitespace dropped.
68
+ #
69
+ def strip_marker( raw )
70
+ return raw.lstrip.sub( /\A#\x20?/, '' ).rstrip
71
+ end
72
+
73
+ #
74
+ # Remove the common leading whitespace shared by every non-blank
75
+ # line, so a comment block indented for readability (eg. a
76
+ # bullet list within it) keeps its relative indentation.
77
+ #
78
+ def dedent( lines )
79
+ non_blank = lines.reject( &:empty? )
80
+ return lines if non_blank.empty?
81
+
82
+ n = non_blank.map { |l| l[ /\A */ ].length }.min
83
+ return lines if n.zero?
84
+
85
+ return lines.map { |l| l.empty? ? l : l[ n.. ] }
86
+ end
87
+
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,41 @@
1
+ # Author:: Eric Crane (mailto:eric.crane@mac.com)
2
+ # Copyright:: Copyright (c) 2026 Eric Crane. All rights reserved.
3
+ #
4
+ # The concrete source model for one loaded file: an ordered list of
5
+ # nodes (comments, blank lines, directives, and object declarations)
6
+ # that mirrors what the loader actually read, so a save can rewrite
7
+ # the file instead of regenerating it from the heap.
8
+ #
9
+ # The top level plays the same role as an Source::ObjNode's children
10
+ # list -- there is no special "root" wrapper, just the file's own
11
+ # ordered content, which may include more than one top-level object
12
+ # declaration (a file may legitimately declare several roots).
13
+ #
14
+
15
+ module Gloo
16
+ module Persist
17
+ module Source
18
+ class SourceDoc
19
+
20
+ attr_reader :children
21
+
22
+ #
23
+ # Set up an empty source document.
24
+ #
25
+ def initialize
26
+ @children = []
27
+ end
28
+
29
+ #
30
+ # The top-level object declarations in this file (usually one,
31
+ # per the "a file owns its declarations" model -- more than one
32
+ # only when the file legitimately declares several roots).
33
+ #
34
+ def roots
35
+ return @children.select { |n| n.is_a?( Source::ObjNode ) }
36
+ end
37
+
38
+ end
39
+ end
40
+ end
41
+ end