gloo 7.1.2 → 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 (54) 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/iterators.md +22 -5
  6. data/docs/language_objects.md +20 -15
  7. data/docs/language_scripting.md +6 -2
  8. data/docs/plugins.md +1 -1
  9. data/docs/testing.md +4 -7
  10. data/docs/verbs.md +23 -3
  11. data/lib/VERSION +1 -1
  12. data/lib/VERSION_NOTES +13 -0
  13. data/lib/dependencies.rb +4 -1
  14. data/lib/gloo/app/engine.rb +18 -6
  15. data/lib/gloo/core/obj.rb +0 -23
  16. data/lib/gloo/exec/script.rb +12 -2
  17. data/lib/gloo/objs/basic/script.rb +5 -28
  18. data/lib/gloo/objs/ctrl/each.rb +4 -3
  19. data/lib/gloo/objs/ctrl/repeat.rb +4 -3
  20. data/lib/gloo/persist/declaration_ledger.rb +9 -9
  21. data/lib/gloo/persist/declaration_writer.rb +208 -0
  22. data/lib/gloo/persist/disc_mech.rb +70 -39
  23. data/lib/gloo/persist/file_loader.rb +125 -66
  24. data/lib/gloo/persist/file_saver.rb +130 -198
  25. data/lib/gloo/persist/file_storage.rb +15 -4
  26. data/lib/gloo/persist/file_style.rb +89 -0
  27. data/lib/gloo/persist/line_splitter.rb +16 -1
  28. data/lib/gloo/persist/persist_man.rb +112 -30
  29. data/lib/gloo/persist/save_index.rb +211 -0
  30. data/lib/gloo/persist/script_body_collector.rb +16 -21
  31. data/lib/gloo/persist/source/blank_node.rb +10 -3
  32. data/lib/gloo/persist/source/comment_node.rb +10 -2
  33. data/lib/gloo/persist/source/directive_node.rb +10 -2
  34. data/lib/gloo/persist/source/obj_node.rb +174 -9
  35. data/lib/gloo/persist/source/source_doc.rb +54 -0
  36. data/lib/gloo/persist/subtree_extractor.rb +9 -37
  37. data/lib/gloo/persist/trivia_buffer.rb +7 -7
  38. data/lib/gloo/verbs/break.rb +99 -10
  39. data/lib/gloo/verbs/load.rb +27 -21
  40. data/lib/gloo/verbs/move.rb +42 -3
  41. data/lib/gloo/verbs/save.rb +26 -4
  42. data/test.gloo/app/exit_status.test.gloo +34 -0
  43. data/test.gloo/app/fixtures/exit_after_error.gloo +9 -0
  44. data/test.gloo/lang/declaration.test.gloo +36 -0
  45. data/test.gloo/lang/fixtures/mixed_indent.gloo +5 -0
  46. data/test.gloo/lang/naming.test.gloo +44 -6
  47. data/test.gloo/verbs/break.test.gloo +104 -11
  48. data/test.gloo/verbs/fixtures/load_beside.gloo +6 -0
  49. data/test.gloo/verbs/fixtures/lossless_text.gloo +7 -0
  50. data/test.gloo/verbs/fixtures/lossless_text_saved.gloo +9 -0
  51. data/test.gloo/verbs/load.test.gloo +18 -0
  52. data/test.gloo/verbs/move.test.gloo +39 -0
  53. data/test.gloo/verbs/save.test.gloo +239 -0
  54. metadata +10 -1
@@ -14,6 +14,7 @@ module Gloo
14
14
 
15
15
  PATH_EXISTS_ERR = 'Will not overwrite a file not already saved there: '.freeze
16
16
  RELOAD_DIRTY_WARNING = 'Reloading will discard unsaved changes in: '.freeze
17
+ NOT_SAVED_WARNING = 'Not saved (no file yet): '.freeze
17
18
 
18
19
  #
19
20
  # Constructor for the persistence manager.
@@ -33,10 +34,25 @@ module Gloo
33
34
  end
34
35
 
35
36
  #
36
- # Save every open file.
37
+ # Save every open file. A new top-level object has no file to go
38
+ # in, so it isn't saved; warn, naming each one.
37
39
  #
38
40
  def save_all
39
41
  save_batch( @maps )
42
+ warn_unsaved_roots
43
+ end
44
+
45
+ #
46
+ # Warn about top-level objects no file declares (made since load),
47
+ # which a bare save leaves out.
48
+ #
49
+ def warn_unsaved_roots
50
+ index = Gloo::Persist::SaveIndex.new( @engine, @maps )
51
+ names = @engine.heap.root.children.reject { |o| index.placed?( o ) }.map( &:name )
52
+ return if names.empty?
53
+
54
+ how = names.size == 1 ? "save #{names.first}" : 'save {name} for each'
55
+ @engine.warn "#{NOT_SAVED_WARNING}#{names.join( ', ' )}. Use #{how}."
40
56
  end
41
57
 
42
58
  #
@@ -50,7 +66,7 @@ module Gloo
50
66
  return unless obj
51
67
 
52
68
  fs_list = find_file_storages( obj )
53
- return save_new( obj, @mech.resolve_save_path( root_of( obj ).name ) ) if fs_list.empty?
69
+ return save_new( obj, @mech.resolve_save_path( root_of( obj ).name, save_folder ) ) if fs_list.empty?
54
70
 
55
71
  @engine.event_manager.on_save obj
56
72
  save_batch( fs_list )
@@ -72,7 +88,7 @@ module Gloo
72
88
  obj = resolve_for_save( name )
73
89
  return unless obj
74
90
 
75
- pn = @mech.resolve_save_path( path )
91
+ pn = @mech.resolve_save_path( path, save_folder )
76
92
  mapped = @maps.find { |fs| fs.pn == pn }
77
93
  return save_mapped( obj, mapped, pn ) if mapped
78
94
  return @engine.err( "#{PATH_EXISTS_ERR}#{pn}" ) if @mech.exist?( pn )
@@ -87,8 +103,13 @@ module Gloo
87
103
  # a missing/unresolvable file is reported via engine.err rather
88
104
  # than failing silently.
89
105
  #
90
- def load( name )
91
- pns = get_full_path_names name
106
+ # A relative name is looked for in the given folders, in order
107
+ # (the load verb passes search_folders). Without them (a file
108
+ # named on the command line, the app's start file) it's looked
109
+ # for as given, then in the project folder and gloo home.
110
+ #
111
+ def load( name, folders = nil )
112
+ pns = get_full_path_names( name, folders )
92
113
  if pns.nil? || pns.empty?
93
114
  @engine.err( Gloo::Core::NotFound.file( name ) ) unless name.to_s.strip.empty?
94
115
  return false
@@ -212,18 +233,63 @@ module Gloo
212
233
 
213
234
 
214
235
  #
215
- # Get the full path and name of the file.
236
+ # Get the full path and name of the file, looking for a relative
237
+ # name in the given folders (see load).
216
238
  #
217
- def get_full_path_names( name )
239
+ def get_full_path_names( name, folders = nil )
218
240
  return nil if name.strip.empty?
219
241
 
220
242
  if name.strip[ -1 ] == '*'
221
- return @mech.get_all_files_in( name[ 0..-2 ] )
243
+ return @mech.get_all_files_in( name[ 0..-2 ], folders )
222
244
  else
223
- return @mech.expand( name )
245
+ return @mech.expand( name, folders )
224
246
  end
225
247
  end
226
248
 
249
+ #
250
+ # Where the load verb looks for a relative name, in order.
251
+ # In App mode the app folder comes first, so an app's names
252
+ # mean the same thing in every one of its files. Otherwise the
253
+ # folder next to the loading file comes first, then the projects
254
+ # folder (the project_path setting). The gloo home is last.
255
+ #
256
+ def search_folders
257
+ near = near_folder
258
+ project = @engine.settings.project_path
259
+ home = @engine.settings.user_root
260
+ folders = if @engine.mode == Gloo::App::Mode::APP
261
+ [ project, near, home ]
262
+ else
263
+ [ near, project, home ]
264
+ end
265
+ return folders.compact.uniq
266
+ end
267
+
268
+ #
269
+ # Where a new file saved with a relative name goes: the first
270
+ # place a relative load looks (see search_folders). That's the
271
+ # app folder in App mode; otherwise next to the saving script's
272
+ # file, or the working folder for a command typed at the prompt,
273
+ # or the projects folder for a script that didn't come from a file.
274
+ #
275
+ def save_folder
276
+ return search_folders.first
277
+ end
278
+
279
+ #
280
+ # The folder "next to" the line now running: the folder of the
281
+ # file the running script came from (the same folder source_dir
282
+ # gives). A command typed at the prompt has no file, so it's the
283
+ # working folder. A script that didn't come from a file has none.
284
+ #
285
+ def near_folder
286
+ script = @engine.exec_env.running_script
287
+ return Dir.pwd unless script
288
+
289
+ path = source_file_of( script.obj )
290
+ return path ? File.dirname( path ) : nil
291
+ end
292
+
227
293
  #
228
294
  # Check to see if a given path name refers to a gloo object file.
229
295
  #
@@ -271,39 +337,55 @@ module Gloo
271
337
  end
272
338
 
273
339
  #
274
- # Save a set of files as one batch so a multi-file namespace
275
- # round-trips: each file only rewrites the declarations its own
276
- # SourceDoc holds and leaves other files' declarations alone; a
277
- # brand-new object no file owns is written once, by the first file
278
- # in the batch to reach it.
340
+ # Save a set of files as one batch, with one index of what every
341
+ # loaded file declares: each file writes back its own declarations
342
+ # and leaves other files' alone, and a new or moved object is
343
+ # written once, by the file its parent's first declaration is in.
344
+ # A file an object moved into is saved too, so the object isn't
345
+ # dropped from one file without being written to another. Each
346
+ # file then keeps a source document for what was written.
279
347
  #
280
348
  def save_batch( files )
281
- claimed = {}.compare_by_identity
349
+ index = Gloo::Persist::SaveIndex.new( @engine, @maps )
350
+ files = with_move_targets( files, index )
351
+ saved = files.map { |fs| [ fs, fs.save( :index => index ) ] }
352
+ saved.each { |fs, doc| fs.saved( doc ) }
353
+ end
354
+
355
+ #
356
+ # The files to save, plus any file an object declared in one of
357
+ # them has moved into.
358
+ #
359
+ def with_move_targets( files, index )
360
+ all = files.dup
282
361
  files.each do |fs|
283
- fs.save( :others => owned_elsewhere( fs ), :claimed => claimed )
362
+ next unless fs.source_doc
363
+
364
+ fs.source_doc.obj_nodes.each do |node|
365
+ next if index.standing?( node ) || !attached?( node.obj )
366
+
367
+ target, = index.writer_for( node.obj.parent )
368
+ all << target if target && !all.include?( target )
369
+ end
284
370
  end
371
+ return all
285
372
  end
286
373
 
287
374
  #
288
- # Every heap object fs's SourceDoc declares.
375
+ # Is the object attached to a parent (it was moved, not deleted)?
289
376
  #
290
- def declared_by( fs )
291
- owned = {}.compare_by_identity
292
- collect_owned( fs.source_doc&.children, owned )
293
- return owned
377
+ def attached?( obj )
378
+ return false unless obj&.parent
379
+
380
+ return obj.parent.children.any? { |c| c.equal?( obj ) }
294
381
  end
295
382
 
296
383
  #
297
- # Every heap object declared by some loaded file's SourceDoc
298
- # other than fs.
384
+ # Every heap object fs's SourceDoc declares.
299
385
  #
300
- def owned_elsewhere( fs )
386
+ def declared_by( fs )
301
387
  owned = {}.compare_by_identity
302
- @maps.each do |other|
303
- next if other.equal?( fs )
304
-
305
- collect_owned( other.source_doc&.children, owned )
306
- end
388
+ collect_owned( fs.source_doc&.children, owned )
307
389
  return owned
308
390
  end
309
391
 
@@ -362,7 +444,7 @@ module Gloo
362
444
  def save_new( obj, pn )
363
445
  @engine.event_manager.on_save obj
364
446
  fs = Gloo::Persist::FileStorage.new( @engine, pn, root_of( obj ) )
365
- fs.save
447
+ fs.saved( fs.save )
366
448
  @maps << fs
367
449
  end
368
450
 
@@ -0,0 +1,211 @@
1
+ # Author:: Eric Crane (mailto:eric.crane@mac.com)
2
+ # Copyright:: Copyright (c) 2026 Eric Crane. All rights reserved.
3
+ #
4
+ # What every loaded file declares, for one save: which declarations
5
+ # name each object (one object can be declared on several lines, in
6
+ # one file or several), which of them still stand, and where an object
7
+ # no declaration places (new, or moved) should be written.
8
+ #
9
+ # A declaration stands if its object is still under the parent it was
10
+ # loaded under, and the declaration it sits under stands too. The
11
+ # first declaration of an object (in load order) holds its value.
12
+ #
13
+
14
+ module Gloo
15
+ module Persist
16
+ class SaveIndex
17
+
18
+ #
19
+ # Index the source documents of the given file storages, in load
20
+ # order.
21
+ #
22
+ def initialize( engine, storages )
23
+ @engine = engine
24
+ @entries = {}.compare_by_identity
25
+ @standing = {}.compare_by_identity
26
+ @claimed = {}.compare_by_identity
27
+ @targets = {}.compare_by_identity
28
+ @in_order = []
29
+ @implied = {}.compare_by_identity
30
+ @members = {}.compare_by_identity
31
+ storages.each do |fs|
32
+ walk( fs, fs.source_doc.children, true, @engine.heap.root ) if fs.source_doc
33
+ end
34
+ find_shorthand_targets
35
+ end
36
+
37
+ #
38
+ # Does the declaration still stand (it's written back in its
39
+ # file)?
40
+ #
41
+ def standing?( node )
42
+ return @standing.fetch( node, false )
43
+ end
44
+
45
+ #
46
+ # Does the declaration hold its object's value (it's the first one
47
+ # that names the object)?
48
+ #
49
+ def holder?( node )
50
+ first = @entries[ node.obj ]&.first
51
+ return first ? first[ 1 ].equal?( node ) : true
52
+ end
53
+
54
+ #
55
+ # The declarations of an object, in load order, as [ fs, node ].
56
+ #
57
+ def entries_for( obj )
58
+ return @entries.fetch( obj, [] )
59
+ end
60
+
61
+ #
62
+ # Does some standing declaration place the object: its own, or a
63
+ # dotted one that names it on the way to its object?
64
+ #
65
+ def placed?( obj )
66
+ return declared?( obj ) || @implied.key?( obj )
67
+ end
68
+
69
+ #
70
+ # Does a standing declaration of the object's own place it?
71
+ #
72
+ def declared?( obj )
73
+ return entries_for( obj ).any? { |_, node| standing?( node ) }
74
+ end
75
+
76
+ #
77
+ # Where a new or moved child of parent is written:
78
+ # [ fs, node, nil ] -- under that declaration of parent, after its
79
+ # last child; [ fs, node, prefix ] -- as a shorthand line after
80
+ # that dotted declaration, named prefix.child; or nil when no file
81
+ # takes it (a new root-level object isn't added to a file).
82
+ #
83
+ def writer_for( parent )
84
+ return nil if parent.nil? || parent.root?
85
+
86
+ fs, node = entries_for( parent ).find { |_, n| standing?( n ) }
87
+ return [ fs, node, nil ] if node
88
+
89
+ return @targets[ parent ]
90
+ end
91
+
92
+ #
93
+ # The intermediate containers a dotted declaration writes new
94
+ # children for, as [ parent, prefix ].
95
+ #
96
+ def shorthand_targets( node )
97
+ return @targets.select { |_, t| t[ 1 ].equal?( node ) }.map { |parent, t| [ parent, t[ 2 ] ] }
98
+ end
99
+
100
+ #
101
+ # Claim an object for writing in this save; false if a file
102
+ # already wrote it.
103
+ #
104
+ def claim( obj )
105
+ return false if @claimed.key?( obj )
106
+
107
+ @claimed[ obj ] = true
108
+ return true
109
+ end
110
+
111
+ #
112
+ # The children of parent that no standing declaration places (new,
113
+ # or moved in), claimed for writing.
114
+ #
115
+ def unplaced_children( parent )
116
+ return parent.children.select { |c| !placed?( c ) && claim( c ) }
117
+ end
118
+
119
+ private
120
+
121
+ #
122
+ # Record the declarations in a list of source nodes. container is
123
+ # the object the list's declarations are under (the heap root at
124
+ # the top of a file).
125
+ #
126
+ def walk( fs, nodes, parent_standing, container )
127
+ nodes.each do |node|
128
+ next unless node.is_a?( Source::ObjNode )
129
+
130
+ stands = parent_standing && stands_alone?( node, container )
131
+ @standing[ node ] = stands
132
+ @in_order << [ fs, node ]
133
+ ( @entries[ node.obj ] ||= [] ) << [ fs, node ] if node.obj
134
+ walk( fs, node.children, stands, node.obj || container )
135
+ end
136
+ end
137
+
138
+ #
139
+ # Is a declaration's object where the declaration puts it: under
140
+ # its parent as loaded, and -- for a dotted (shorthand) name --
141
+ # reached from container by that path? A line gloo couldn't make
142
+ # an object for is always kept.
143
+ #
144
+ def stands_alone?( node, container )
145
+ return true unless node.obj
146
+ return false unless node.obj.parent.equal?( node.home ) && child_of?( node.obj, node.home )
147
+
148
+ segments = node.name.split( '.' )
149
+ return true if segments.size == 1
150
+
151
+ o = node.obj.parent
152
+ segments[ 0..-2 ].reverse.each do |seg|
153
+ return false unless o && o.name.casecmp?( seg ) && child_of?( o, o.parent )
154
+
155
+ o = o.parent
156
+ end
157
+ return o.equal?( container )
158
+ end
159
+
160
+ #
161
+ # Is child one of parent's children (by identity)? Each parent's
162
+ # children are gathered once, so a big container stays quick.
163
+ #
164
+ def child_of?( child, parent )
165
+ return false unless parent
166
+
167
+ unless @members.key?( parent )
168
+ set = {}.compare_by_identity
169
+ parent.children.each { |c| set[ c ] = true }
170
+ @members[ parent ] = set
171
+ end
172
+ return @members[ parent ].key?( child )
173
+ end
174
+
175
+ #
176
+ # For each container that only exists through dotted names (it has
177
+ # no declaration of its own), the shorthand line new children go
178
+ # after: the last one through it, in the first file that has one.
179
+ #
180
+ def find_shorthand_targets
181
+ @in_order.each do |fs, node|
182
+ next unless node.obj && standing?( node ) && node.name.include?( '.' )
183
+
184
+ intermediates( node ).each do |parent, prefix|
185
+ next if declared?( parent )
186
+
187
+ @implied[ parent ] = true
188
+ target = @targets[ parent ]
189
+ @targets[ parent ] = [ fs, node, prefix ] if target.nil? || target[ 0 ].equal?( fs )
190
+ end
191
+ end
192
+ end
193
+
194
+ #
195
+ # The containers a dotted declaration names on its way to its
196
+ # object, as [ container, dotted path to it ].
197
+ #
198
+ def intermediates( node )
199
+ segments = node.name.split( '.' )
200
+ found = []
201
+ o = node.obj.parent
202
+ ( segments.size - 1 ).downto( 1 ) do |i|
203
+ found.unshift( [ o, segments[ 0, i ].join( '.' ) ] )
204
+ o = o.parent
205
+ end
206
+ return found
207
+ end
208
+
209
+ end
210
+ end
211
+ end
@@ -36,15 +36,16 @@ module Gloo
36
36
  end
37
37
 
38
38
  #
39
- # One line while a body might be open. line_tabs is its
39
+ # One line while a body might be open. line is the line as read
40
+ # (continuations joined), raw its exact source text, line_tabs its
40
41
  # 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.
42
+ # Returns true if the line was consumed as body content, or false
43
+ # if it closed the body and still needs to be dispatched normally
44
+ # by the caller.
44
45
  #
45
- def handle_line( line, line_tabs )
46
+ def handle_line( line, raw, line_tabs )
46
47
  if line.strip.empty?
47
- @lines << chomped( line )
48
+ keep( raw )
48
49
  return true
49
50
  end
50
51
 
@@ -53,7 +54,8 @@ module Gloo
53
54
  return false
54
55
  end
55
56
 
56
- add_line( line )
57
+ keep( raw )
58
+ @obj.add_line( line ) unless line.strip.start_with?( '#' )
57
59
  return true
58
60
  end
59
61
 
@@ -69,14 +71,14 @@ module Gloo
69
71
  private
70
72
 
71
73
  #
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).
74
+ # Keep one body line's exact text: on the node's own text, and in
75
+ # the body's raw value. A comment line is kept but isn't added as a
76
+ # command (a comment inside a script body is documentation, not
77
+ # something to execute).
76
78
  #
77
- def add_line( line )
78
- @lines << chomped( line )
79
- @obj.add_line( line ) unless line.strip.start_with?( '#' )
79
+ def keep( raw )
80
+ @node.raw << raw
81
+ @lines << raw.chomp( "\n" )
80
82
  end
81
83
 
82
84
  #
@@ -90,13 +92,6 @@ module Gloo
90
92
  @obj = nil
91
93
  end
92
94
 
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
95
  end
101
96
  end
102
97
  end
@@ -13,14 +13,21 @@ module Gloo
13
13
  attr_reader :raw
14
14
 
15
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).
16
+ # Set up a blank line node. raw is the full source line with its
17
+ # line ending (none on a file's last line if the file doesn't end
18
+ # with one); any trailing whitespace on the blank line is kept.
19
19
  #
20
20
  def initialize( raw = '' )
21
21
  @raw = raw
22
22
  end
23
23
 
24
+ #
25
+ # The node's exact source text.
26
+ #
27
+ def text
28
+ return @raw
29
+ end
30
+
24
31
  end
25
32
  end
26
33
  end
@@ -14,13 +14,21 @@ module Gloo
14
14
  attr_reader :raw
15
15
 
16
16
  #
17
- # Set up a comment node. raw is the full source line, minus
18
- # its trailing newline.
17
+ # Set up a comment node. raw is the full source line, with
18
+ # its line ending (none on a file's last line if the file doesn't
19
+ # end with one).
19
20
  #
20
21
  def initialize( raw )
21
22
  @raw = raw
22
23
  end
23
24
 
25
+ #
26
+ # The node's exact source text.
27
+ #
28
+ def text
29
+ return @raw
30
+ end
31
+
24
32
  end
25
33
  end
26
34
  end
@@ -13,13 +13,21 @@ module Gloo
13
13
  attr_reader :raw
14
14
 
15
15
  #
16
- # Set up a directive node. raw is the full source line, minus
17
- # its trailing newline.
16
+ # Set up a directive node. raw is the full source line, with
17
+ # its line ending (none on a file's last line if the file doesn't
18
+ # end with one).
18
19
  #
19
20
  def initialize( raw )
20
21
  @raw = raw
21
22
  end
22
23
 
24
+ #
25
+ # The node's exact source text.
26
+ #
27
+ def text
28
+ return @raw
29
+ end
30
+
23
31
  end
24
32
  end
25
33
  end