gloo 6.5.1 → 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: 19bd707a24eb9bbd3e2759cdb2ae244d2dafc28f40c0f4046c856152e343261c
4
- data.tar.gz: fd3fd34b664abb32a464d3745de0ede24e136f2b2a7f375809170312bbec51a1
3
+ metadata.gz: 7bec0a2a7c9eb15015734a7610359cc6b7c18abbc9abbb239131f9fad9927fcf
4
+ data.tar.gz: a9d8810c5581416608f4913d1cd7f1b24e385fb4b1d47b74eb5e1dcd8aaafd47
5
5
  SHA512:
6
- metadata.gz: f21efa7866b7eeb455fb4e7a8c64465bcd41a498d6129f04220c69eb814178f92775aeb96ef0e2a972f6df481d4603acf6e3de7c728e6508262347012cfb20cc
7
- data.tar.gz: 956306465a135039e185104bb81cf504af379265814b5a02ba4637c3a6412463ce655aa48bae66e1f3315fd8c90925899c2ca0afda9fbf6a795c66b35e339967
6
+ metadata.gz: 17694e6f6a9019c0071248eb4c59ab80ffba8c352b7aa5341eeca150d0503e5176eabc0e00a0214d8a99c24e98fa045138dfb7783bb55c1c30198f0704235cec
7
+ data.tar.gz: 6cff2396f6bdc2debed6ab6f03a43e5932a1fa4c5a54a07c40b0e5d18f3686721ca9ae90e05d60ea26fc416c93f66e1acb05de9141e9777afec78406c17181a6
data/docs/application.md CHANGED
@@ -88,6 +88,8 @@ gloo:
88
88
  #
89
89
  # Run this script when starting up gloo.
90
90
  # (Only if a script file is not specified.)
91
+ # A bare filename (no path) is looked up in the config directory
92
+ # above. Either way, the .gloo extension is assumed if left off.
91
93
  #
92
94
  start_with:
93
95
 
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.5.1
1
+ 6.7.0
data/lib/VERSION_NOTES CHANGED
@@ -1,3 +1,15 @@
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
+
7
+ 6.6.0 - 2026.09.12
8
+ - Allow shortcut reference for start file in configuration directory.
9
+ - Adds word wrap message for strings and text.
10
+ - Adds line wrapping for object comments and help documentation.
11
+
12
+
1
13
  6.5.1 - 2026.09.11
2
14
  - Adds doc to the load verb for recent changes.
3
15
 
@@ -207,7 +207,9 @@ module Gloo
207
207
  end
208
208
 
209
209
  name = @settings.start_with
210
- @persist_man.load( name ) if name
210
+ return if name.blank?
211
+
212
+ @persist_man.load( resolve_start_with( name ) )
211
213
  end
212
214
 
213
215
  #
@@ -382,6 +384,20 @@ module Gloo
382
384
 
383
385
  private
384
386
 
387
+ #
388
+ # Resolve the start_with setting to a loadable name/path.
389
+ # A bare filename (no directory separator) is resolved against
390
+ # the config directory rather than the project path, and the
391
+ # .gloo extension is assumed when not given.
392
+ #
393
+ def resolve_start_with( name )
394
+ ext = @persist_man.file_ext
395
+ name = "#{name}#{ext}" unless name.end_with?( ext )
396
+ return name if name.include?( File::SEPARATOR )
397
+
398
+ return File.join( @settings.config_path, name )
399
+ end
400
+
385
401
  #
386
402
  # Get the stack trace as a string, truncating the middle if it's long.
387
403
  #
@@ -189,13 +189,53 @@ module Gloo
189
189
  def get_default_settings
190
190
  projects = File.join( @user_root, 'projects' )
191
191
  str = <<~TEXT
192
+ #
193
+ # Gloo configuration
194
+ #
192
195
  gloo:
196
+
197
+ #
198
+ # Root directory for projects.
199
+ # Update this with the directory with your gloo projects.
200
+ #
193
201
  project_path: #{projects}
202
+
203
+ #
204
+ # Run this script when starting up gloo.
205
+ # (Only if a script file is not specified.)
206
+ # A bare filename (no path) is looked up in the config directory
207
+ # above. Either way, the .gloo extension is assumed if left off.
208
+ #
194
209
  start_with:
210
+
211
+ #
212
+ # Indentation (spaces) when showing an object outline.
213
+ #
195
214
  list_indent: 2
215
+
216
+ #
217
+ # Show listing the object tree,
218
+ # how many levels of children will be shown?
219
+ # Children at deeper levels will be hidden.
220
+ #
196
221
  list_levels: 3
222
+
223
+ #
224
+ # When listing the object tree, also show each object's doc (the
225
+ # comment declared immediately above it in its source file), if it
226
+ # has one.
227
+ #
197
228
  list_docs: false
229
+
230
+ #
231
+ # Show debug statements in the log?
232
+ #
198
233
  debug: false
234
+
235
+ #
236
+ # Color theme for console output: dark or light.
237
+ # Match this to your terminal's background.
238
+ #
199
239
  theme: #{DEFAULT_THEME}
200
240
  TEXT
201
241
  return str
@@ -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
  #
@@ -24,6 +24,10 @@ module Gloo
24
24
  README_GLOB = 'README*'.freeze
25
25
  NO_README_YET = 'No README found for library'.freeze
26
26
 
27
+ # Matches a (possibly indented) markdown bullet line, capturing
28
+ # its leading whitespace and the text after '- '.
29
+ BULLET_RE = /\A(\s*)-\s+(.*)\z/.freeze
30
+
27
31
  #
28
32
  # Initialize the help shell for the given engine.
29
33
  #
@@ -249,10 +253,51 @@ module Gloo
249
253
  #
250
254
  def page_markdown( md )
251
255
  rule = '-' * @engine.platform.cols
252
- bracketed = "#{rule}\n#{md.strip}\n#{rule}\n"
256
+ wrapped = wrap_markdown_for_terminal( md.strip )
257
+ bracketed = "#{rule}\n#{wrapped}\n#{rule}\n"
253
258
  @engine.platform.page( Gloo::Docs::MarkdownRenderer.colorize( bracketed, @engine.theme ) )
254
259
  end
255
260
 
261
+ #
262
+ # Word-wrap plain (pre-color) markdown to the terminal width.
263
+ # Headings and fenced code blocks are left untouched (code is
264
+ # never rewrapped); a bullet line's continuation lines are
265
+ # indented to line up under its text, not under the '-' itself
266
+ # -- same convention as List#show_doc's doc-line wrapping.
267
+ #
268
+ def wrap_markdown_for_terminal( md )
269
+ width = Gloo::App::Settings.cols( @engine )
270
+ in_code_fence = false
271
+ lines = md.split( "\n", -1 ).flat_map do |line|
272
+ if line.strip.start_with?( '```' )
273
+ in_code_fence = !in_code_fence
274
+ next [ line ]
275
+ end
276
+ next [ line ] if in_code_fence || line.start_with?( '#' ) || line.strip.empty?
277
+
278
+ wrap_markdown_line( line, width )
279
+ end
280
+ return lines.join( "\n" )
281
+ end
282
+
283
+ #
284
+ # Wrap one prose or bullet markdown line to width. A bullet's
285
+ # continuation lines get a blank-space prefix the same length as
286
+ # its '{indent}- ', so wrapped text lines up under the bullet's
287
+ # own text.
288
+ #
289
+ def wrap_markdown_line( line, width )
290
+ match = BULLET_RE.match( line )
291
+ prefix = match ? "#{match[1]}- " : ''
292
+ text = match ? match[2] : line
293
+ cont_indent = ' ' * prefix.length
294
+
295
+ wrapped = Gloo::Objs::WordWrap.wrap( text, width - prefix.length )
296
+ # "".split( "\n", -1 ) is [], not [ '' ] -- keep a blank line as one piece.
297
+ pieces = wrapped.empty? ? [ '' ] : wrapped.split( "\n", -1 )
298
+ return pieces.each_with_index.map { |piece, i| "#{i.zero? ? prefix : cont_indent}#{piece}" }
299
+ end
300
+
256
301
  #
257
302
  # Snapshot the verb, object type, doc page, and loaded library
258
303
  # names for tab-completion.
@@ -55,6 +55,7 @@ module Gloo
55
55
  'splitl ({index}) — Get the substring to the left of index {index} (same as split (0, {index})). A parameter is required. Does not change the value of the string. It will have the substring.',
56
56
  'splitr ({index}) — Get the substring from index {index} to the end of the string (same as split ({index}, size)). A parameter is required. Does not change the value of the string. It will have the substring.',
57
57
  'split_list ({delim} {dst.path}) — Split the string by {delim} and put the parts into children of the container at {dst.path} (or an alias that points to one), one part per child, in order. Existing children are matched by position and have their values set; extra parts get new (untyped) children, numbered from 1; extra existing children are left alone. Both parameters are required. Does not change the value of the string. It will have the number of parts.',
58
+ 'word_wrap ({width}) — Word-wrap the string to {width} columns, breaking on whitespace (never mid-word; an overlong word is left on its own line rather than broken). The {width} parameter is optional; it defaults to the terminal\'s current width. This message changes the value of the string. It will have the wrapped string.',
58
59
  'page — Show the value in a pager (less), for viewing long content a screen at a time.'
59
60
  ]
60
61
  end
@@ -481,6 +482,25 @@ module Gloo
481
482
  return s
482
483
  end
483
484
 
485
+ #
486
+ # Word-wrap the string to the given column width, breaking on
487
+ # whitespace. Defaults to the terminal's current width when no
488
+ # width parameter is given.
489
+ #
490
+ def msg_word_wrap
491
+ width = Gloo::App::Settings.cols( @engine )
492
+ if @params&.token_count&.positive?
493
+ expr = Gloo::Expr::Expression.new( @engine, @params.tokens )
494
+ data = expr.evaluate
495
+ width = data.to_i
496
+ end
497
+
498
+ s = Gloo::Objs::WordWrap.wrap( value, width )
499
+ set_value s
500
+ @engine.heap.it.set_to s
501
+ return s
502
+ end
503
+
484
504
  #
485
505
  # Show the value in a pager, for long content.
486
506
  #
@@ -0,0 +1,58 @@
1
+ # Author:: Eric Crane (mailto:eric.crane@mac.com)
2
+ # Copyright:: Copyright (c) 2026 Eric Crane. All rights reserved.
3
+ #
4
+ # Word-wrapping utility.
5
+ # This is a static class.
6
+ #
7
+
8
+ module Gloo
9
+ module Objs
10
+ class WordWrap
11
+
12
+ # ---------------------------------------------------------------------
13
+ # Wrapping
14
+ # ---------------------------------------------------------------------
15
+
16
+ #
17
+ # Wrap the given text to the given column width.
18
+ # Greedy word-wrap: breaks on whitespace, never mid-word. A single
19
+ # word longer than the width is left on its own line rather than
20
+ # being hard-broken (e.g. a URL stays intact, just overflows).
21
+ # Existing line breaks in the text are preserved -- each line is
22
+ # wrapped independently, so blank lines and paragraph breaks survive.
23
+ #
24
+ def self.wrap( text, width )
25
+ return text.to_s if width.to_i <= 0
26
+
27
+ text.to_s.split( "\n", -1 ).map { |line| wrap_line( line, width ) }.join( "\n" )
28
+ end
29
+
30
+ # ---------------------------------------------------------------------
31
+ # Private
32
+ # ---------------------------------------------------------------------
33
+
34
+ #
35
+ # Wrap a single line (no embedded newlines) to the given width.
36
+ #
37
+ def self.wrap_line( line, width )
38
+ words = line.split( ' ' )
39
+ return line if words.empty?
40
+
41
+ lines = []
42
+ current = words.shift
43
+ words.each do |word|
44
+ if "#{current} #{word}".length > width
45
+ lines << current
46
+ current = word
47
+ else
48
+ current = "#{current} #{word}"
49
+ end
50
+ end
51
+ lines << current
52
+ return lines.join( "\n" )
53
+ end
54
+ private_class_method :wrap_line
55
+
56
+ end
57
+ end
58
+ end
@@ -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