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 +4 -4
- data/docs/verbs.md +6 -2
- data/lib/VERSION +1 -1
- data/lib/VERSION_NOTES +6 -0
- data/lib/gloo/core/tokens.rb +8 -2
- data/lib/gloo/persist/file_loader.rb +16 -12
- data/lib/gloo/persist/file_storage.rb +20 -3
- data/lib/gloo/persist/persist_man.rb +31 -1
- data/lib/gloo/persist/source/source_doc.rb +36 -0
- data/lib/gloo/persist/subtree_extractor.rb +184 -0
- data/lib/gloo/persist/trivia_buffer.rb +120 -0
- data/lib/gloo/verbs/if.rb +18 -8
- data/lib/gloo/verbs/save.rb +24 -4
- data/lib/gloo/verbs/unless.rb +13 -4
- data/test.gloo/verbs/if.test.gloo +21 -0
- data/test.gloo/verbs/save.test.gloo +86 -0
- data/test.gloo/verbs/unless.test.gloo +21 -0
- metadata +4 -3
- data/lib/gloo/persist/comment_buffer.rb +0 -58
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7bec0a2a7c9eb15015734a7610359cc6b7c18abbc9abbb239131f9fad9927fcf
|
|
4
|
+
data.tar.gz: a9d8810c5581416608f4913d1cd7f1b24e385fb4b1d47b74eb5e1dcd8aaafd47
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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.
|
|
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.
|
data/lib/gloo/core/tokens.rb
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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.
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
# and
|
|
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
|
-
@
|
|
146
|
-
@indent_stack.node.children << Source::BlankNode.new( chomped( line ) )
|
|
150
|
+
@trivia.push_blank( chomped( line ) )
|
|
147
151
|
else
|
|
148
|
-
@
|
|
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 = @
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 {
|
|
119
|
-
'if {true}
|
|
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
|
-
'{
|
|
124
|
-
'{
|
|
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
|
-
|
|
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
|
data/lib/gloo/verbs/save.rb
CHANGED
|
@@ -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
|
data/lib/gloo/verbs/unless.rb
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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
|