gloo 6.6.0 → 6.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 24b089e57c145daea09ee59a1967bc38ec4482d0a9a137f9e3a32e8b71fb4ece
4
- data.tar.gz: 625b347a0ff732f2fe81008c9e7e6d56de92f84ef846ccd37ebeda10948117ab
3
+ metadata.gz: 7bec0a2a7c9eb15015734a7610359cc6b7c18abbc9abbb239131f9fad9927fcf
4
+ data.tar.gz: a9d8810c5581416608f4913d1cd7f1b24e385fb4b1d47b74eb5e1dcd8aaafd47
5
5
  SHA512:
6
- metadata.gz: 3080060d8fea38b00cc1870d9ae07d8149db13395834d72a20c9f18f7b9c43d97b6af97177d653b7eaeeffa49b04f9005a8318f7aaaa56edf65619645dde8016
7
- data.tar.gz: f137b40bf30a08e7af993fdd67041084a7f6aefbaf461934f1b4b8bd370e399cf3044537388d1a09be6dd954d73c3487ceddf6c977493287e3aa5ec6894f2b13
6
+ metadata.gz: 17694e6f6a9019c0071248eb4c59ab80ffba8c352b7aa5341eeca150d0503e5176eabc0e00a0214d8a99c24e98fa045138dfb7783bb55c1c30198f0704235cec
7
+ data.tar.gz: 6cff2396f6bdc2debed6ab6f03a43e5932a1fa4c5a54a07c40b0e5d18f3686721ca9ae90e05d60ea26fc416c93f66e1acb05de9141e9777afec78406c17181a6
data/docs/verbs.md CHANGED
@@ -87,16 +87,20 @@ put {expression} into {dst.path}
87
87
 
88
88
  If the name can't be resolved to a file, `load` reports `File not found: {name}` — it fires `on_error` and, when the file was named on the `gloo` command line, prints the message to stderr and exits non-zero. A bad or missing file never fails silently.
89
89
 
90
- `save` writes loaded objects back to their files. With no argument it saves every open file; with an object it saves the file (or files) that object's tree came from; with `to {path}` it saves to a new file and remembers the mapping.
90
+ `save` writes loaded objects back to their files. With no argument it saves every open file; with an object it saves the file (or files) that object's tree came from; with `to {path}` it **extracts** moves the object (and its descendants) out of whichever file currently owns them, into a new file, under their full dotted path.
91
91
 
92
92
  ```gloo
93
93
  > save # every open file
94
94
  > save config # just config's file
95
- > save config to backups/config
95
+ > save app.core.settings to config/settings
96
96
  ```
97
97
 
98
98
  A save is a **rewrite, not a regeneration**: comments, blank lines, and the original spacing are kept, and only the values you actually changed are re-written. A declaration you never touched comes back byte-for-byte.
99
99
 
100
+ `save {obj} to {path}` doesn't just write a copy — `obj` stops being declared in its old file, wherever that was. Given `app.core.settings` declared inside `app.gloo`, `save app.core.settings to config/settings` leaves `app.gloo` without a `settings` subtree and writes `config/settings.gloo` with `app.core.settings [can] : ...` at the top level (the `app`/`core` prefix is implied via nested-container shorthand, not repeated as nested declarations) plus everything `settings` owns underneath, comments included. An object with no file of its own yet (brand new, or nested under an object that's never been saved) is simply written fresh — nothing to move.
101
+
102
+ If `obj`'s own subtree already has declarations spread across more than one file (the namespace-merge pattern below, applied *inside* the subtree being extracted, not just above it), extraction refuses rather than guessing which file's slice is authoritative — save each contributing file on its own first.
103
+
100
104
  Several files can contribute to one container — declare `app [container] :` in each and add different children. They merge in the heap, and each file's save only rewrites its own declarations. If two files declare the *same* object with different values, the first one loaded wins and `load` logs a warning.
101
105
 
102
106
  An object can also save itself: `tell config to save`.
data/lib/VERSION CHANGED
@@ -1 +1 @@
1
- 6.6.0
1
+ 6.7.0
data/lib/VERSION_NOTES CHANGED
@@ -1,3 +1,9 @@
1
+ 6.7.0 - 2026.09.13
2
+ - Adds alternative secondary keywords for conditional verbs.
3
+ - Adds option to save an object subtree into its own file.
4
+ - Fixes issue with load and save comments and blank lines.
5
+
6
+
1
7
  6.6.0 - 2026.09.12
2
8
  - Allow shortcut reference for start file in configuration directory.
3
9
  - Adds word wrap message for strings and text.
@@ -88,12 +88,18 @@ module Gloo
88
88
  end
89
89
 
90
90
  #
91
- # Get the index of the given token.
91
+ # Get the index of the given token. token may also be an array
92
+ # of candidate keywords -- e.g. [ 'then', 'do' ] -- in which
93
+ # case the index of whichever one occurs earliest in the token
94
+ # stream wins (not just whichever is present). This lets a
95
+ # caller treat several spellings of the same separator as
96
+ # interchangeable while still keying the split on the first one
97
+ # actually used (see If/Unless's 'then'/'do').
92
98
  #
93
99
  def index_of( token )
94
100
  return nil unless @tokens
95
101
 
96
- return @tokens.find_index { |o| o.casecmp( token ).zero? }
102
+ return Array( token ).filter_map { |t| @tokens.find_index { |o| o.casecmp( t ).zero? } }.min
97
103
  end
98
104
 
99
105
  #
@@ -7,7 +7,7 @@
7
7
  # raw formatting) -- so a later save can rewrite the file instead of
8
8
  # regenerating it from scratch.
9
9
  #
10
- # Work is delegated to CommentBuffer (comment buffering),
10
+ # Work is delegated to TriviaBuffer (comment/blank-line buffering),
11
11
  # ScriptBodyCollector (script bodies), IndentStack (nesting shared by
12
12
  # the heap and source trees), ShorthandExpander (nested-container
13
13
  # shorthand), and DeclarationLedger (this file's roots + cross-file
@@ -42,7 +42,7 @@ module Gloo
42
42
  @pn = pn
43
43
  @obj = nil
44
44
  @source_doc = Gloo::Persist::Source::SourceDoc.new
45
- @comments = Gloo::Persist::CommentBuffer.new
45
+ @trivia = Gloo::Persist::TriviaBuffer.new
46
46
  @body = Gloo::Persist::ScriptBodyCollector.new
47
47
  @shorthand = Gloo::Persist::ShorthandExpander.new( engine )
48
48
  @ledger = Gloo::Persist::DeclarationLedger.new( engine, pn )
@@ -126,7 +126,7 @@ module Gloo
126
126
  #
127
127
  def finish
128
128
  @body.finish
129
- @comments.flush_into( @indent_stack.node.children )
129
+ @trivia.flush_into( @indent_stack.node.children )
130
130
  end
131
131
 
132
132
  # ---------------------------------------------------------------------
@@ -134,18 +134,22 @@ module Gloo
134
134
  # ---------------------------------------------------------------------
135
135
 
136
136
  #
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.
137
+ # A comment or blank line, outside of any block/body. Both are
138
+ # buffered, not placed immediately: a container's own declaration
139
+ # line doesn't push it as the current node (see IndentStack) until
140
+ # a genuinely deeper line is seen, so a comment/blank sitting
141
+ # between the container and its first child has to wait for that
142
+ # push before it's resolved against the *correct* node -- placing
143
+ # it against whatever's current right now would land it one level
144
+ # too shallow. TriviaBuffer#take_leading_doc/#flush_into do that
145
+ # resolving once the right moment comes (the next declaration, or
146
+ # end of file).
142
147
  #
143
148
  def handle_trivia_line( line )
144
149
  if line.strip.empty?
145
- @comments.flush_into( @indent_stack.node.children )
146
- @indent_stack.node.children << Source::BlankNode.new( chomped( line ) )
150
+ @trivia.push_blank( chomped( line ) )
147
151
  else
148
- @comments.push( chomped( line ), tab_count( line ) )
152
+ @trivia.push_comment( chomped( line ), tab_count( line ) )
149
153
  end
150
154
  end
151
155
 
@@ -225,7 +229,7 @@ module Gloo
225
229
  @ledger.root( @last ) if parent == @engine.heap.root
226
230
 
227
231
  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 )
232
+ node.leading_doc = @trivia.take_leading_doc( line_tabs, @indent_stack.node.children )
229
233
  # First non-empty doc wins, same as "first value wins" for a
230
234
  # name re-declared across files -- @last is the same object
231
235
  # across re-declarations (the factory returns the existing
@@ -11,14 +11,18 @@ module Gloo
11
11
  attr_reader :obj, :pn, :roots, :source_doc
12
12
 
13
13
  #
14
- # Set up a file storage for an object.
14
+ # Set up a file storage for an object. source_doc is optional --
15
+ # pass one already built (eg. a subtree just extracted into this
16
+ # file by save {obj} to {path}) so this file's saves rewrite it
17
+ # in place instead of falling back to plain regeneration; a
18
+ # bare/never-loaded FileStorage leaves it nil.
15
19
  #
16
- def initialize( engine, pn, obj = nil )
20
+ def initialize( engine, pn, obj = nil, source_doc = nil )
17
21
  @engine = engine
18
22
  @obj = obj
19
23
  @pn = pn
20
24
  @roots = obj ? [ obj ] : []
21
- @source_doc = nil
25
+ @source_doc = source_doc
22
26
  end
23
27
 
24
28
  #
@@ -31,6 +35,19 @@ module Gloo
31
35
  fs.save
32
36
  end
33
37
 
38
+ #
39
+ # This file no longer owns obj as one of its roots -- eg. it was
40
+ # just extracted into a different file via save {obj} to {path}.
41
+ # Drops it from roots, and re-points the file's primary obj at
42
+ # whatever root remains (nil if none left), so a later
43
+ # single-object reload/save doesn't act on a root that's moved
44
+ # elsewhere.
45
+ #
46
+ def drop_root( obj )
47
+ @roots.delete( obj )
48
+ @obj = @roots.first if @obj&.equal?( obj )
49
+ end
50
+
34
51
  #
35
52
  # Load the object from the file.
36
53
  #
@@ -63,6 +63,13 @@ module Gloo
63
63
  # mapping so a future bare save includes it. Refuses to overwrite
64
64
  # a file that exists but isn't already mapped to this object.
65
65
  #
66
+ # Unlike a bare save, this extracts: obj's own declaration (and
67
+ # its descendants) move out of whichever file currently owns
68
+ # them into the new file -- they don't stay declared in both
69
+ # places. An object with no file of its own yet (brand new, or
70
+ # already mapped as part of some root shared with unrelated
71
+ # objects) is simply written fresh; nothing to remove.
72
+ #
66
73
  def save_to( name, path )
67
74
  obj = resolve_for_save( name )
68
75
  return unless obj
@@ -72,7 +79,7 @@ module Gloo
72
79
  return save_mapped( obj, mapped, pn ) if mapped
73
80
  return @engine.err( "#{PATH_EXISTS_ERR}#{pn}" ) if @mech.exist?( pn )
74
81
 
75
- save_new( obj, pn )
82
+ extract_to( obj, pn )
76
83
  end
77
84
 
78
85
  #
@@ -333,6 +340,29 @@ module Gloo
333
340
  @maps << fs
334
341
  end
335
342
 
343
+ #
344
+ # Extract obj's declaration into a brand-new file at pn: pulled
345
+ # out of whichever file currently owns it (that file loses it and
346
+ # gets re-saved), or written fresh if it had no file of its own.
347
+ # Both saves go through the same save_batch so each correctly
348
+ # treats the other's declarations as "owned elsewhere" rather
349
+ # than orphaned/new.
350
+ #
351
+ def extract_to( obj, pn )
352
+ node, source_fs = Gloo::Persist::SubtreeExtractor.new( @engine ).extract( obj, @maps )
353
+ return unless node
354
+
355
+ @engine.event_manager.on_save obj
356
+ source_doc = Gloo::Persist::Source::SourceDoc.new
357
+ source_doc.children << node
358
+
359
+ target_fs = Gloo::Persist::FileStorage.new( @engine, pn, obj, source_doc )
360
+ @maps << target_fs
361
+ # New file first: if writing it fails, the old file (source_fs)
362
+ # hasn't been touched on disk yet, so nothing is lost.
363
+ save_batch( [ target_fs, source_fs ].compact )
364
+ end
365
+
336
366
  #
337
367
  # Warn (without blocking) if reloading fs would discard changes
338
368
  # that haven't been saved -- any object whose value no longer
@@ -35,6 +35,42 @@ module Gloo
35
35
  return @children.select { |n| n.is_a?( Source::ObjNode ) }
36
36
  end
37
37
 
38
+ #
39
+ # Find and remove the node for the given heap object, searching
40
+ # this document's whole tree (not just the top level) -- used to
41
+ # move a subtree into a different file (see save {obj} to
42
+ # {path}). Returns the removed node, still carrying its own
43
+ # children/leading_doc, or nil if this document has no node for
44
+ # that object (it may be owned by a different file, or never
45
+ # have had a declaration of its own -- eg. a nested-container
46
+ # shorthand's auto-created intermediate).
47
+ #
48
+ def extract( obj )
49
+ return remove_matching( @children, obj )
50
+ end
51
+
52
+ private
53
+
54
+ #
55
+ # Depth-first search of nodes (and their children) for the one
56
+ # linked to obj; removes it from whichever children array it's
57
+ # actually in and returns it.
58
+ #
59
+ def remove_matching( nodes, obj )
60
+ nodes.each do |node|
61
+ next unless node.is_a?( Source::ObjNode )
62
+
63
+ if node.obj&.equal?( obj )
64
+ nodes.delete( node )
65
+ return node
66
+ end
67
+
68
+ found = remove_matching( node.children, obj )
69
+ return found if found
70
+ end
71
+ return nil
72
+ end
73
+
38
74
  end
39
75
  end
40
76
  end
@@ -0,0 +1,184 @@
1
+ # Author:: Eric Crane (mailto:eric.crane@mac.com)
2
+ # Copyright:: Copyright (c) 2026 Eric Crane. All rights reserved.
3
+ #
4
+ # Resolves 'save {obj} to {path}' into the Source::ObjNode that should
5
+ # be moved into the target file: pulled out of whichever file already
6
+ # owns it, or built fresh when obj has no file of its own yet. Does
7
+ # not touch disk -- PersistMan wires the result into a new
8
+ # FileStorage and drives the actual save (including re-saving the
9
+ # file the node was pulled out of).
10
+ #
11
+
12
+ module Gloo
13
+ module Persist
14
+ class SubtreeExtractor
15
+
16
+ SPANS_MULTIPLE_FILES_ERR =
17
+ "Can't extract: this object's subtree has declarations in more than one file. " \
18
+ 'Save each contributing file on its own first.'.freeze
19
+ NO_OWN_DECLARATION_ERR =
20
+ "Can't extract: this object has no declaration of its own to move " \
21
+ '(it may be an auto-created intermediate container from nested-container ' \
22
+ 'shorthand) -- extract a child that does, or a shallower ancestor that does.'.freeze
23
+
24
+ #
25
+ # Set up an extractor for the given engine.
26
+ #
27
+ def initialize( engine )
28
+ @engine = engine
29
+ end
30
+
31
+ #
32
+ # Resolve obj to the Source::ObjNode that should be written to
33
+ # the new file, with its name set to obj's full dotted path (so
34
+ # the target file recreates any prefix container chain via
35
+ # nested-container shorthand) and its indentation recomputed for
36
+ # life as a fresh top-level declaration.
37
+ #
38
+ # Returns [ node, source_fs ]: source_fs is the FileStorage obj
39
+ # was pulled out of (already updated in memory -- its own save is
40
+ # still the caller's job), or nil when obj had no file of its own
41
+ # to remove from. Returns [ nil, nil ] (having already called
42
+ # engine.err) if extraction isn't possible.
43
+ #
44
+ def extract( obj, maps )
45
+ owners = owning_file_storages( obj, maps )
46
+ return err( SPANS_MULTIPLE_FILES_ERR ) if owners.count > 1
47
+
48
+ source_fs = owners.first
49
+ node = source_fs ? remove_node( obj, source_fs ) : fresh_node( obj )
50
+ return err( NO_OWN_DECLARATION_ERR ) unless node
51
+
52
+ node.name = dotted_name( obj )
53
+ reindent( node, 0 )
54
+ return [ node, source_fs ]
55
+ end
56
+
57
+ private
58
+
59
+ #
60
+ # Report the error and return [ nil, nil ], for a one-line call site.
61
+ #
62
+ def err( message )
63
+ @engine.err( message )
64
+ return [ nil, nil ]
65
+ end
66
+
67
+ #
68
+ # Every FileStorage whose SourceDoc has a node for obj itself, or
69
+ # for any of its descendants (a namespace pattern applied inside
70
+ # obj's own subtree, not just above it).
71
+ #
72
+ def owning_file_storages( obj, maps )
73
+ subtree = subtree_objects( obj )
74
+ return maps.select { |fs| fs.source_doc && any_node_for?( fs.source_doc.children, subtree ) }
75
+ end
76
+
77
+ #
78
+ # obj and every object beneath it in the live heap, as a
79
+ # (compare-by-identity) set membership check.
80
+ #
81
+ def subtree_objects( obj )
82
+ set = {}.compare_by_identity
83
+ set[ obj ] = true
84
+ collect_descendants( obj, set )
85
+ return set
86
+ end
87
+
88
+ #
89
+ # Recursively add obj's live children to the set.
90
+ #
91
+ def collect_descendants( obj, set )
92
+ obj.children.each do |child|
93
+ set[ child ] = true
94
+ collect_descendants( child, set )
95
+ end
96
+ end
97
+
98
+ #
99
+ # Does any node in this list (or their descendants) belong to an
100
+ # object in the given set?
101
+ #
102
+ def any_node_for?( nodes, set )
103
+ return nodes.any? do |node|
104
+ next false unless node.is_a?( Source::ObjNode )
105
+ next true if node.obj && set.key?( node.obj )
106
+
107
+ any_node_for?( node.children, set )
108
+ end
109
+ end
110
+
111
+ #
112
+ # obj has no file of its own (never loaded, or created at
113
+ # runtime since) -- build a plain node with no raw text of its
114
+ # own. FileSaver renders it fresh from the live object, the same
115
+ # way it already does for any brand-new child.
116
+ #
117
+ def fresh_node( obj )
118
+ node = Source::ObjNode.new( :name => obj.name, :raw_type => obj.type_display )
119
+ node.obj = obj
120
+ return node
121
+ end
122
+
123
+ #
124
+ # Pull obj's node out of the file that owns it. Returns nil if
125
+ # that file turns out to have no node of obj's own to remove (eg.
126
+ # obj is an intermediate container ShorthandExpander created,
127
+ # which never gets a node of its own).
128
+ #
129
+ def remove_node( obj, fs )
130
+ node = fs.source_doc.extract( obj )
131
+ return nil unless node
132
+
133
+ fs.drop_root( obj )
134
+ return node
135
+ end
136
+
137
+ #
138
+ # obj's full dotted path from its file-owning root down to
139
+ # itself, eg. "app.core.settings" -- how the target file
140
+ # recreates the prefix chain via nested-container shorthand. A
141
+ # plain top-level object's path is just its own name.
142
+ #
143
+ def dotted_name( obj )
144
+ names = [ obj.name ]
145
+ o = obj.parent
146
+ until o.nil? || o.root?
147
+ names.unshift( o.name )
148
+ o = o.parent
149
+ end
150
+ return names.join( '.' )
151
+ end
152
+
153
+ #
154
+ # Recompute raw_indent (and, for a BEGIN/END node, raw_end_indent
155
+ # to match) from scratch for the moved node and every descendant
156
+ # ObjNode. Their old indentation depth described nesting inside
157
+ # the file they came from; here obj itself is depth 0, in a
158
+ # brand-new file. Floating trivia (a blank line or comment not
159
+ # attached as some declaration's leading_doc) travels along
160
+ # unindented-adjusted -- purely cosmetic, since indentation never
161
+ # affects how a comment/blank line is recognized on reload.
162
+ #
163
+ def reindent( node, depth )
164
+ new_indent = "\t" * depth
165
+ node.raw_indent = new_indent
166
+ node.raw_end_indent = new_indent if node.block_style == :begin_end
167
+ node.leading_doc = reindent_doc( node.leading_doc, new_indent ) if node.leading_doc
168
+ node.children.each do |child|
169
+ reindent( child, depth + 1 ) if child.is_a?( Source::ObjNode )
170
+ end
171
+ end
172
+
173
+ #
174
+ # Re-indent a raw leading_doc's lines to new_indent, replacing
175
+ # whatever leading whitespace each '#'-prefixed line had in the
176
+ # file it came from.
177
+ #
178
+ def reindent_doc( raw, new_indent )
179
+ return raw.split( "\n" ).map { |line| "#{new_indent}#{line.lstrip}" }.join( "\n" )
180
+ end
181
+
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,120 @@
1
+ # Author:: Eric Crane (mailto:eric.crane@mac.com)
2
+ # Copyright:: Copyright (c) 2026 Eric Crane. All rights reserved.
3
+ #
4
+ # Buffers a run of comment and blank lines while the loader decides
5
+ # where they actually belong -- both what becomes a following
6
+ # declaration's leading_doc, and which source node's children array
7
+ # floating trivia lands in.
8
+ #
9
+ # Both kinds are held here (not placed immediately) because a
10
+ # declaration only becomes a parent -- and its Source::ObjNode only
11
+ # gets pushed as the current node -- once IndentStack#place sees a
12
+ # genuinely deeper line follow it (see IndentStack). A blank or
13
+ # comment line between a container's declaration and its first child
14
+ # arrives *before* that push happens; placing it immediately would
15
+ # attach it one level too shallow. Buffering defers the decision until
16
+ # take_leading_doc/flush_into is called against the *correct*,
17
+ # already-placed node.
18
+ #
19
+
20
+ module Gloo
21
+ module Persist
22
+ class TriviaBuffer
23
+
24
+ #
25
+ # Set up an empty buffer.
26
+ #
27
+ def initialize
28
+ @pending = []
29
+ end
30
+
31
+ #
32
+ # Buffer one comment line. raw is the full source line (minus
33
+ # its trailing newline); tabs is its indentation level.
34
+ #
35
+ def push_comment( raw, tabs )
36
+ @pending << { :kind => :comment, :raw => raw, :tabs => tabs }
37
+ end
38
+
39
+ #
40
+ # Buffer one blank line. raw is its exact source text (may be
41
+ # non-empty if it had trailing whitespace) -- a blank line always
42
+ # breaks a comment run's association with whatever declaration
43
+ # follows, the same as it always has.
44
+ #
45
+ def push_blank( raw )
46
+ @pending << { :kind => :blank, :raw => raw }
47
+ end
48
+
49
+ #
50
+ # Detach the whole buffered run -- comments and blanks, in
51
+ # original order -- as floating nodes appended to the given
52
+ # children array.
53
+ #
54
+ def flush_into( children )
55
+ return if @pending.empty?
56
+
57
+ flush_list( @pending, children )
58
+ @pending = []
59
+ end
60
+
61
+ #
62
+ # If the run since the last blank (or the whole run, if it has no
63
+ # blank in it) is one or more comment lines all at the same
64
+ # indent as line_tabs, claim it as a leading_doc (raw,
65
+ # newline-joined). Anything before that -- an earlier blank, and
66
+ # whatever preceded it -- floats into children first, in order,
67
+ # since a blank always breaks the association. If the trailing
68
+ # run doesn't qualify (wrong indent, or empty), it floats into
69
+ # children too and this returns nil. Either way the buffer is
70
+ # empty afterward.
71
+ #
72
+ def take_leading_doc( line_tabs, children )
73
+ before, after = split_at_last_blank
74
+ flush_list( before, children )
75
+
76
+ if after.any? && after.last[ :tabs ] == line_tabs
77
+ doc = after.map { |t| t[ :raw ] }.join( "\n" )
78
+ @pending = []
79
+ return doc
80
+ end
81
+
82
+ flush_list( after, children )
83
+ @pending = []
84
+ return nil
85
+ end
86
+
87
+ private
88
+
89
+ #
90
+ # Split the pending run at its last blank line: everything up to
91
+ # and including it, and everything after (always comments only,
92
+ # by construction -- there's no later blank to have split on).
93
+ # With no blank at all, everything is "after".
94
+ #
95
+ def split_at_last_blank
96
+ idx = @pending.rindex { |t| t[ :kind ] == :blank }
97
+ return [ [], @pending ] unless idx
98
+
99
+ return [ @pending[ 0..idx ], @pending[ idx + 1.. ] ]
100
+ end
101
+
102
+ #
103
+ # Append each pending entry's node, in order, to children.
104
+ #
105
+ def flush_list( list, children )
106
+ list.each { |t| children << node_for( t ) }
107
+ end
108
+
109
+ #
110
+ # The source node one pending entry becomes when it floats.
111
+ #
112
+ def node_for( t )
113
+ return Source::BlankNode.new( t[ :raw ] ) if t[ :kind ] == :blank
114
+
115
+ return Source::CommentNode.new( t[ :raw ] )
116
+ end
117
+
118
+ end
119
+ end
120
+ end
data/lib/gloo/verbs/if.rb CHANGED
@@ -11,7 +11,11 @@ module Gloo
11
11
  KEYWORD = 'if'.freeze
12
12
  KEYWORD_SHORT = 'if'.freeze
13
13
  THEN = 'then'.freeze
14
+ DO = 'do'.freeze
14
15
  ELSE = 'else'.freeze
16
+ # 'then' and 'do' are interchangeable -- whichever appears first
17
+ # in the tokens is the actual separator (see Tokens#index_of).
18
+ SEPARATORS = [ THEN, DO ].freeze
15
19
  MISSING_EXPR_ERR = 'Missing Expression!'.freeze
16
20
 
17
21
  #
@@ -21,7 +25,7 @@ module Gloo
21
25
  value = value_tokens
22
26
  return if value.nil?
23
27
 
24
- @then = @tokens.expr_after( THEN, ELSE )
28
+ @then = @tokens.expr_after( SEPARATORS, ELSE )
25
29
  @else = @tokens.expr_after( ELSE )
26
30
 
27
31
  if evals_true( value )
@@ -56,7 +60,7 @@ module Gloo
56
60
  # of the if command.
57
61
  #
58
62
  def value_tokens
59
- value = @tokens.before_token( THEN )
63
+ value = @tokens.before_token( SEPARATORS )
60
64
  if value && value.count > 1
61
65
  # The first token is the verb, so we drop it.
62
66
  value = value[ 1..-1 ]
@@ -113,19 +117,22 @@ module Gloo
113
117
  {
114
118
  :name => KEYWORD,
115
119
  :shortcut => KEYWORD_SHORT,
116
- :description => 'If an expression is true then do something.',
120
+ :description => 'If an expression is true then do something. ' \
121
+ "'then' and 'do' are interchangeable separators -- pick " \
122
+ 'whichever reads better.',
117
123
  :syntax => [
118
- 'if {true} then {do}',
119
- 'if {true} then {do} else {do else}'
124
+ 'if {true} then {action}',
125
+ 'if {true} do {action}',
126
+ 'if {true} then {action} else {else action}'
120
127
  ],
121
128
  :parameters => [
122
129
  '{true} — Does the expression evaluate to true?',
123
- '{do} — Execute command if the expression is true.',
124
- '{do else} — The else command is optional. Execute command if the expression is false.'
130
+ '{action} — Execute command if the expression is true.',
131
+ '{else action} — The else command is optional. Execute command if the expression is false.'
125
132
  ],
126
133
  :result => 'Unchanged if the expression is not true. If true, ' \
127
134
  'then the result will be based on the command specified ' \
128
- 'after the then keyword.',
135
+ "after the 'then'/'do' keyword.",
129
136
  :errors => [
130
137
  "#{MISSING_EXPR_ERR} — No expression is provided as parameter to the verb.",
131
138
  'Other errors depend on the command that is run.'
@@ -150,6 +157,9 @@ module Gloo
150
157
  if ^.x \\
151
158
  then show 'T' \\
152
159
  else show 'F'
160
+
161
+ # 'do' works the same as 'then'
162
+ if if.x do show "still true: " + if.true_msg
153
163
  EXAMPLES
154
164
  }
155
165
  end
@@ -73,7 +73,12 @@ module Gloo
73
73
  'a namespace shared across several files); with no object ' \
74
74
  'at all, saves every open file. An object not yet mapped ' \
75
75
  'to a file is saved fresh, either to a given path or to a ' \
76
- "default path built from the object's own name.",
76
+ "default path built from the object's own name. With a " \
77
+ 'path, this extracts: the object (and its descendants) ' \
78
+ "move out of whichever file currently owns them -- they're " \
79
+ 'no longer declared there too -- into the new file, under ' \
80
+ 'their full dotted path (so any parent containers are ' \
81
+ 'recreated there via nested-container shorthand).',
77
82
  :syntax => [
78
83
  'save',
79
84
  'save {path.to.object}',
@@ -88,21 +93,36 @@ module Gloo
88
93
  :result => 'The file(s) are updated with the latest object ' \
89
94
  'state. This is a rewrite, not a regeneration: comments, ' \
90
95
  'blank lines, and the original formatting are preserved, ' \
91
- 'and only values that actually changed are re-written.',
96
+ 'and only values that actually changed are re-written. ' \
97
+ 'With a path, the object also stops being declared in its ' \
98
+ 'old file (if it had one) -- both files are rewritten ' \
99
+ 'together.',
92
100
  :errors => [
93
101
  "#{MISSING_PATH_ERR} — 'to' was given with nothing after it.",
94
102
  'Could not resolve object to save — the object was not found.',
95
103
  'Will not overwrite a file not already saved there — the ' \
96
- 'target path exists but is not mapped to this object.'
104
+ 'target path exists but is not mapped to this object.',
105
+ "Can't extract: this object's subtree has declarations in " \
106
+ 'more than one file — save each contributing file on its ' \
107
+ 'own first, then extract.',
108
+ "Can't extract: this object has no declaration of its own " \
109
+ 'to move — it may be an auto-created intermediate ' \
110
+ 'container from nested-container shorthand; extract a ' \
111
+ 'child that does, or a shallower ancestor that does.'
97
112
  ],
98
113
  :notes => 'An object can also be told to save itself: ' \
99
114
  '`tell my_obj to save`. When several files contribute to ' \
100
115
  'one container (the namespace pattern), each file save only ' \
101
- "rewrites that file's own declarations.",
116
+ "rewrites that file's own declarations. Extraction (save " \
117
+ '... to {path}) only moves a subtree owned by exactly one ' \
118
+ 'file -- one already spread across several files (that ' \
119
+ 'same namespace pattern, applied inside the subtree being ' \
120
+ 'extracted) is refused rather than guessed at.',
102
121
  :examples => <<~EXAMPLES.strip
103
122
  > save
104
123
  > save my_obj
105
124
  > save my_obj to sub/my_obj
125
+ > save app.core.settings to config/settings
106
126
  EXAMPLES
107
127
  }
108
128
  end
@@ -11,7 +11,11 @@ module Gloo
11
11
  KEYWORD = 'unless'.freeze
12
12
  KEYWORD_SHORT = 'if!'.freeze
13
13
  DO = 'do'.freeze
14
+ THEN = 'then'.freeze
14
15
  ELSE = 'else'.freeze
16
+ # 'do' and 'then' are interchangeable -- whichever appears first
17
+ # in the tokens is the actual separator (see Tokens#index_of).
18
+ SEPARATORS = [ DO, THEN ].freeze
15
19
  MISSING_EXPR_ERR = 'Missing Expression!'.freeze
16
20
 
17
21
  #
@@ -21,7 +25,7 @@ module Gloo
21
25
  value = value_tokens
22
26
  return if value.nil?
23
27
 
24
- @do = @tokens.expr_after( DO, ELSE )
28
+ @do = @tokens.expr_after( SEPARATORS, ELSE )
25
29
  @else = @tokens.expr_after( ELSE )
26
30
 
27
31
  if evals_false( value )
@@ -56,7 +60,7 @@ module Gloo
56
60
  # of the unless command.
57
61
  #
58
62
  def value_tokens
59
- value = @tokens.before_token( DO )
63
+ value = @tokens.before_token( SEPARATORS )
60
64
  if value && value.count > 1
61
65
  # The first token is the verb, so we drop it.
62
66
  value = value[ 1..-1 ]
@@ -114,9 +118,11 @@ module Gloo
114
118
  :name => KEYWORD,
115
119
  :shortcut => KEYWORD_SHORT,
116
120
  :description => "Unless an expression is true, do something. " \
117
- "This is the opposite of the if verb.",
121
+ "This is the opposite of the if verb. 'do' and 'then' are " \
122
+ 'interchangeable separators -- pick whichever reads better.',
118
123
  :syntax => [
119
124
  'unless {true} do {command}',
125
+ 'unless {true} then {command}',
120
126
  'unless {true} do {command} else {else command}'
121
127
  ],
122
128
  :parameters => [
@@ -126,7 +132,7 @@ module Gloo
126
132
  ],
127
133
  :result => 'Unchanged if the expression is true. If not true, ' \
128
134
  'then the result will be based on the command specified ' \
129
- 'after the do keyword.',
135
+ "after the 'do'/'then' keyword.",
130
136
  :errors => [
131
137
  "#{MISSING_EXPR_ERR} — No expression is provided as parameter to the verb.",
132
138
  'Other errors depend on the command that is run.'
@@ -148,6 +154,9 @@ module Gloo
148
154
  put false into unless.x
149
155
  unless unless.x do show "second time: " + unless.false_msg
150
156
  unless ^.x do show 'F' else show 'T'
157
+
158
+ # 'then' works the same as 'do'
159
+ unless unless.x then show "still false: " + unless.false_msg
151
160
  EXAMPLES
152
161
  }
153
162
  end
@@ -12,3 +12,24 @@ tests.verbs.if [can] :
12
12
  eval false
13
13
  if ^^.b then eval true
14
14
  assert "expected b to be true"
15
+
16
+ if_do [test] :
17
+ description [string] : 'do' is a synonym for 'then'
18
+ on_test [script] :
19
+ eval false
20
+ if ^^.b do eval true
21
+ assert "expected 'do' to work the same as 'then'"
22
+
23
+ if_then_else [test] :
24
+ description [string] : else runs when the condition is false
25
+ on_test [script] :
26
+ eval false
27
+ if false then eval false else eval true
28
+ assert "expected else to run when the condition is false"
29
+
30
+ if_do_else [test] :
31
+ description [string] : else works the same with the 'do' synonym
32
+ on_test [script] :
33
+ eval false
34
+ if false do eval false else eval true
35
+ assert "expected else to run after a 'do' action"
@@ -32,6 +32,27 @@ demo [container] :
32
32
  other [string] : world
33
33
  END
34
34
 
35
+ #
36
+ # Regression fixture: a blank line between a container's own
37
+ # declaration and its first child (not between two siblings, which
38
+ # already worked) used to land one level too shallow.
39
+ #
40
+ blank_before_first_child_fixture [text] : BEGIN
41
+ #
42
+ # The main app container.
43
+ #
44
+ app [can] :
45
+
46
+ # Core settings live here.
47
+ core [can] :
48
+
49
+ # Some other core thing.
50
+ other [string] : hi
51
+
52
+ # Something unrelated at the app level.
53
+ name [string] : My App
54
+ END
55
+
35
56
  round_trip_preserves_comments_and_untouched_lines [test] :
36
57
  description [string] : load, change one value, save -- comments and the untouched sibling survive.
37
58
  on_test [script] :
@@ -66,6 +87,18 @@ END
66
87
  tell demo to unload
67
88
  tell ^^.f to delete
68
89
 
90
+ blank_before_first_child [test] :
91
+ description [string] : An unchanged save reproduces blank lines before a container's first child too.
92
+ on_test [script] :
93
+ tell ^^.f to write ( ^^.blank_before_first_child_fixture )
94
+ load /tmp/gloo_save_test.gloo
95
+ save app
96
+ tell ^^.f to read ( ^^.out )
97
+ eval ^^.out = ^^.blank_before_first_child_fixture
98
+ assert "an unchanged save should reproduce the original text, blank lines included"
99
+ tell app to unload
100
+ tell ^^.f to delete
101
+
69
102
  deleting_an_object_drops_its_line [test] :
70
103
  description [string] : removing a child and saving drops just that declaration.
71
104
  on_test [script] :
@@ -96,6 +129,59 @@ END
96
129
  tell ^^.f to delete
97
130
  tell ^^.to_target to delete
98
131
 
132
+ extracting_a_subtree_moves_it_to_the_new_file [test] :
133
+ description [string] : save {nested obj} to {path} moves its declaration (and its own comment) out of the old file into the new one.
134
+ on_test [script] :
135
+ tell ^^.f to write ( ^^.fixture )
136
+ load /tmp/gloo_save_test.gloo
137
+ save demo.other to /tmp/gloo_save_test_to
138
+ check ^^.to_target for exists?
139
+ assert "extraction should create the target file"
140
+
141
+ tell ^^.to_target to read ( ^^.out )
142
+ check ^^.out for substring? ( "demo.other [string] : world" )
143
+ assert "the target file should declare the moved object under its full dotted path"
144
+ check ^^.out for substring? ( "# a comment above other" )
145
+ assert "the moved object's own comment should travel with it"
146
+
147
+ tell ^^.f to read ( ^^.out )
148
+ check ^^.out for substring? ( "other [string]" )
149
+ refute "the old file should no longer declare the moved object"
150
+ check ^^.out for substring? ( "msg [string] : hello" )
151
+ assert "the old file should keep everything else"
152
+
153
+ # a later plain save of demo must not resurrect the moved object
154
+ save demo
155
+ tell ^^.f to read ( ^^.out )
156
+ check ^^.out for substring? ( "other" )
157
+ refute "a later save of the old root should not re-add the moved object"
158
+
159
+ tell demo.other to unload
160
+ tell demo to unload
161
+ tell ^^.f to delete
162
+ tell ^^.to_target to delete
163
+
164
+ extracting_a_subtree_spanning_multiple_files_is_an_error [test] :
165
+ description [string] : save {obj} to {path} refuses when obj's own subtree has declarations in more than one file.
166
+ on_test [script] :
167
+ put false into ^^.err
168
+ tell ^^.f to write ( ^^.fixture )
169
+ load /tmp/gloo_save_test.gloo
170
+ # a second file adds its own child under the same 'demo' container --
171
+ # a namespace pattern applied inside the subtree we'll try to extract
172
+ tell ^^.g to write ( "demo [container] :\n extra [string] : too\n" )
173
+ load /tmp/gloo_save_test_2.gloo
174
+
175
+ save demo to /tmp/gloo_save_test_to
176
+ eval ^^.err = true
177
+ assert "extraction should refuse a subtree spanning multiple files"
178
+ check ^^.to_target for exists?
179
+ refute "the target file should not have been created"
180
+
181
+ tell demo to unload
182
+ tell ^^.f to delete
183
+ tell ^^.g to delete
184
+
99
185
  reload_discards_unsaved_changes [test] :
100
186
  description [string] : reload throws away edits that were not saved.
101
187
  on_test [script] :
@@ -18,3 +18,24 @@ tests.verbs.unless [can] :
18
18
  eval false
19
19
  unless true do eval true
20
20
  refute "expected do block to be skipped when condition is true"
21
+
22
+ unless_then [test] :
23
+ description [string] : 'then' is a synonym for 'do'
24
+ on_test [script] :
25
+ eval false
26
+ unless ^^.b then eval true
27
+ assert "expected 'then' to work the same as 'do'"
28
+
29
+ unless_do_else [test] :
30
+ description [string] : else runs when the condition is true
31
+ on_test [script] :
32
+ eval false
33
+ unless true do eval false else eval true
34
+ assert "expected else to run when the condition is true"
35
+
36
+ unless_then_else [test] :
37
+ description [string] : else works the same with the 'then' synonym
38
+ on_test [script] :
39
+ eval false
40
+ unless true then eval false else eval true
41
+ assert "expected else to run after a 'then' action"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gloo
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.6.0
4
+ version: 6.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Crane
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-12 00:00:00.000000000 Z
11
+ date: 2026-09-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -386,7 +386,6 @@ files:
386
386
  - lib/gloo/objs/web/http_post.rb
387
387
  - lib/gloo/objs/web/json.rb
388
388
  - lib/gloo/objs/web/uri.rb
389
- - lib/gloo/persist/comment_buffer.rb
390
389
  - lib/gloo/persist/declaration_ledger.rb
391
390
  - lib/gloo/persist/disc_mech.rb
392
391
  - lib/gloo/persist/file_loader.rb
@@ -402,6 +401,8 @@ files:
402
401
  - lib/gloo/persist/source/directive_node.rb
403
402
  - lib/gloo/persist/source/obj_node.rb
404
403
  - lib/gloo/persist/source/source_doc.rb
404
+ - lib/gloo/persist/subtree_extractor.rb
405
+ - lib/gloo/persist/trivia_buffer.rb
405
406
  - lib/gloo/plugin/base.rb
406
407
  - lib/gloo/plugin/callback.rb
407
408
  - lib/gloo/plugin/ext_manager.rb
@@ -1,58 +0,0 @@
1
- # Author:: Eric Crane (mailto:eric.crane@mac.com)
2
- # Copyright:: Copyright (c) 2026 Eric Crane. All rights reserved.
3
- #
4
- # Buffers a run of consecutive whole-line comments while the loader
5
- # decides whether they become a declaration's leading_doc or float as
6
- # their own comment nodes.
7
- #
8
-
9
- module Gloo
10
- module Persist
11
- class CommentBuffer
12
-
13
- #
14
- # Set up an empty buffer.
15
- #
16
- def initialize
17
- @pending = []
18
- end
19
-
20
- #
21
- # Buffer one comment line. raw is the full source line (minus
22
- # its trailing newline); tabs is its indentation level.
23
- #
24
- def push( raw, tabs )
25
- @pending << { :raw => raw, :tabs => tabs }
26
- end
27
-
28
- #
29
- # Detach the buffered run as floating comment nodes, appended in
30
- # order to the given children array.
31
- #
32
- def flush_into( children )
33
- return if @pending.empty?
34
-
35
- @pending.each { |c| children << Source::CommentNode.new( c[ :raw ] ) }
36
- @pending = []
37
- end
38
-
39
- #
40
- # If the buffered run sits at the same indent as line_tabs, claim
41
- # it as a leading_doc (raw, newline-joined) and clear the buffer.
42
- # Otherwise flush it into children as floating comments first, so
43
- # document order is kept, and return nil.
44
- #
45
- def take_leading_doc( line_tabs, children )
46
- if !@pending.empty? && @pending.last[ :tabs ] == line_tabs
47
- doc = @pending.map { |c| c[ :raw ] }.join( "\n" )
48
- @pending = []
49
- return doc
50
- end
51
-
52
- flush_into( children )
53
- return nil
54
- end
55
-
56
- end
57
- end
58
- end