gloo 7.1.2 → 7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +23 -0
  3. data/docs/application.md +47 -2
  4. data/docs/development.md +10 -0
  5. data/docs/iterators.md +22 -5
  6. data/docs/language_objects.md +20 -15
  7. data/docs/language_scripting.md +6 -2
  8. data/docs/plugins.md +1 -1
  9. data/docs/testing.md +4 -7
  10. data/docs/verbs.md +23 -3
  11. data/lib/VERSION +1 -1
  12. data/lib/VERSION_NOTES +13 -0
  13. data/lib/dependencies.rb +4 -1
  14. data/lib/gloo/app/engine.rb +18 -6
  15. data/lib/gloo/core/obj.rb +0 -23
  16. data/lib/gloo/exec/script.rb +12 -2
  17. data/lib/gloo/objs/basic/script.rb +5 -28
  18. data/lib/gloo/objs/ctrl/each.rb +4 -3
  19. data/lib/gloo/objs/ctrl/repeat.rb +4 -3
  20. data/lib/gloo/persist/declaration_ledger.rb +9 -9
  21. data/lib/gloo/persist/declaration_writer.rb +208 -0
  22. data/lib/gloo/persist/disc_mech.rb +70 -39
  23. data/lib/gloo/persist/file_loader.rb +125 -66
  24. data/lib/gloo/persist/file_saver.rb +130 -198
  25. data/lib/gloo/persist/file_storage.rb +15 -4
  26. data/lib/gloo/persist/file_style.rb +89 -0
  27. data/lib/gloo/persist/line_splitter.rb +16 -1
  28. data/lib/gloo/persist/persist_man.rb +112 -30
  29. data/lib/gloo/persist/save_index.rb +211 -0
  30. data/lib/gloo/persist/script_body_collector.rb +16 -21
  31. data/lib/gloo/persist/source/blank_node.rb +10 -3
  32. data/lib/gloo/persist/source/comment_node.rb +10 -2
  33. data/lib/gloo/persist/source/directive_node.rb +10 -2
  34. data/lib/gloo/persist/source/obj_node.rb +174 -9
  35. data/lib/gloo/persist/source/source_doc.rb +54 -0
  36. data/lib/gloo/persist/subtree_extractor.rb +9 -37
  37. data/lib/gloo/persist/trivia_buffer.rb +7 -7
  38. data/lib/gloo/verbs/break.rb +99 -10
  39. data/lib/gloo/verbs/load.rb +27 -21
  40. data/lib/gloo/verbs/move.rb +42 -3
  41. data/lib/gloo/verbs/save.rb +26 -4
  42. data/test.gloo/app/exit_status.test.gloo +34 -0
  43. data/test.gloo/app/fixtures/exit_after_error.gloo +9 -0
  44. data/test.gloo/lang/declaration.test.gloo +36 -0
  45. data/test.gloo/lang/fixtures/mixed_indent.gloo +5 -0
  46. data/test.gloo/lang/naming.test.gloo +44 -6
  47. data/test.gloo/verbs/break.test.gloo +104 -11
  48. data/test.gloo/verbs/fixtures/load_beside.gloo +6 -0
  49. data/test.gloo/verbs/fixtures/lossless_text.gloo +7 -0
  50. data/test.gloo/verbs/fixtures/lossless_text_saved.gloo +9 -0
  51. data/test.gloo/verbs/load.test.gloo +18 -0
  52. data/test.gloo/verbs/move.test.gloo +39 -0
  53. data/test.gloo/verbs/save.test.gloo +239 -0
  54. metadata +10 -1
@@ -37,15 +37,16 @@ module Gloo
37
37
  end
38
38
 
39
39
  #
40
- # Run the do script once. Returns true if a break in it ended the
41
- # run, so the loop should stop.
40
+ # Run the do script once. Returns true if a break loop in it
41
+ # ended the run, so the loop should stop. A plain break ends only
42
+ # the do script, and the loop goes on.
42
43
  #
43
44
  def run_do
44
45
  o = find_child DO
45
46
  return false unless o
46
47
 
47
48
  Gloo::Exec::Dispatch.message( @engine, 'run', o )
48
- return o.respond_to?( :broke_out? ) && o.broke_out?
49
+ return o.respond_to?( :broke_loop? ) && o.broke_loop?
49
50
  end
50
51
 
51
52
  #
@@ -3,8 +3,8 @@
3
3
  #
4
4
  # Tracks what a single file declares as it loads: its top-level
5
5
  # objects (for save resolution), and every object it created -- so a
6
- # re-declaration of a name an earlier file already used can be
7
- # flagged, since in that case the second value is silently dropped.
6
+ # re-declaration of a name already used, in an earlier file or earlier
7
+ # in this one, can be flagged when its value is ignored.
8
8
  #
9
9
 
10
10
  module Gloo
@@ -39,19 +39,19 @@ module Gloo
39
39
 
40
40
  #
41
41
  # `prior` already existed when this file declared `name` with
42
- # `value`. If another file created it and the values differ, the
43
- # new value is being ignored -- warn so it isn't a surprise. A
44
- # duplicate name inside one file is left alone (a documented
45
- # "first one wins"). The warning's location (this file and line)
46
- # comes from the load.
42
+ # `value`: the line names the same object, and its value is
43
+ # ignored (the first declaration wins). When the ignored value is
44
+ # different, warn so it isn't a surprise -- whether the first
45
+ # declaration was in another file or earlier in this one. The
46
+ # warning's location (this file and line) comes from the load.
47
47
  #
48
48
  def clash( prior, name, value )
49
- return if @mine.key?( prior )
50
49
  return if value.nil? || value.to_s.strip.empty?
51
50
  return if prior.value.to_s == value.to_s
52
51
 
52
+ where = @mine.key?( prior ) ? 'above' : 'in another loaded file'
53
53
  @engine.warn(
54
- "'#{name}' is already declared in another loaded file with a " \
54
+ "'#{name}' is already declared #{where} with a " \
55
55
  "different value (#{prior.value.inspect} kept, #{value.inspect} ignored)."
56
56
  )
57
57
  end
@@ -0,0 +1,208 @@
1
+ # Author:: Eric Crane (mailto:eric.crane@mac.com)
2
+ # Copyright:: Copyright (c) 2026 Eric Crane. All rights reserved.
3
+ #
4
+ # Writes the text a save generates: a changed declaration (its head
5
+ # kept as written, only the value written again) or a declaration for
6
+ # a new object, in the file's style (see FileStyle).
7
+ #
8
+ # A value is written so it loads back the same: on the line, as a
9
+ # BEGIN/END block (exactly as it is -- a block's lines are the value),
10
+ # or as a script body. A value with no exact spelling is written as
11
+ # the closest one, with a warning.
12
+ #
13
+
14
+ module Gloo
15
+ module Persist
16
+ class DeclarationWriter
17
+
18
+ BEGIN_BLOCK = 'BEGIN'.freeze
19
+ END_BLOCK = 'END'.freeze
20
+ SEP = ' : '.freeze
21
+ NOT_EXACT = "Could not save '%s' exactly: %s.".freeze
22
+ NO_FINAL_NEWLINE = 'a block value always ends with a newline'.freeze
23
+ END_LINE = 'a line that is just END ends the block early'.freeze
24
+ NOT_UTF8 = 'it is not valid UTF-8'.freeze
25
+
26
+ #
27
+ # Set up a writer for a file with the given style.
28
+ #
29
+ def initialize( engine, style )
30
+ @engine = engine
31
+ @style = style
32
+ end
33
+
34
+ #
35
+ # The new text of a declaration whose object's value changed: its
36
+ # head as written, then the value.
37
+ #
38
+ def changed_text( node )
39
+ obj = node.obj
40
+ return body_text( node.head, node.raw_tail, obj, body_indent( node ), trailing_blanks( node ) ) if body?( obj, node.block_style == :body )
41
+
42
+ return value_text( node.head, separator( node ), obj, node.raw_indent, node.block_style == :begin_end )
43
+ end
44
+
45
+ #
46
+ # A source node for a new object (without its children), at the
47
+ # given indentation, written with the given name (its own, or a
48
+ # dotted path for a shorthand line).
49
+ #
50
+ def new_node( obj, indent, name = obj.name )
51
+ spelled = @style.type_for( obj )
52
+ head = spelled ? "#{indent}#{name} [#{spelled}]" : "#{indent}#{name}"
53
+ node = Source::ObjNode.new( :name => name, :raw_type => spelled || obj.type_display )
54
+ node.raw_indent = indent
55
+ node.head = head
56
+ node.obj = obj
57
+ node.raw = if body?( obj, false )
58
+ body_text( head, ' :', obj, "#{indent}#{@style.unit}", '' )
59
+ else
60
+ value_text( head, SEP, obj, indent, false )
61
+ end
62
+ node.block_style = block_style_of( node.raw )
63
+ node.raw_tail = node.raw.scrub.lines.first.chomp.delete_prefix( head )
64
+ return node
65
+ end
66
+
67
+ private
68
+
69
+ # ---------------------------------------------------------------------
70
+ # Script bodies
71
+ # ---------------------------------------------------------------------
72
+
73
+ #
74
+ # Is the value written as a script body: a multi-line value (a
75
+ # script) whose value is a list of lines, or that was a body?
76
+ #
77
+ def body?( obj, was_body )
78
+ return false unless obj.multiline_value?
79
+
80
+ return was_body || obj.value_is_array?
81
+ end
82
+
83
+ #
84
+ # A script body: the head and tail (eg. ' :'), then each command
85
+ # on its own line at the body's indentation, then any blank lines
86
+ # that followed the body as written.
87
+ #
88
+ def body_text( head, tail, obj, indent, trailing )
89
+ lines = script_lines( obj )
90
+ str = "#{head}#{tail.rstrip}\n"
91
+ lines.each { |line| str << "#{indent}#{line}\n" }
92
+ return "#{str}#{trailing}"
93
+ end
94
+
95
+ #
96
+ # A script's command lines, whatever form its value is in.
97
+ #
98
+ def script_lines( obj )
99
+ return obj.value if obj.value_is_array?
100
+ return [] if obj.value.to_s.strip.empty?
101
+
102
+ return [ obj.value.to_s.strip ]
103
+ end
104
+
105
+ #
106
+ # The indentation of a body as written: its first non-blank line's,
107
+ # or one step in from the declaration.
108
+ #
109
+ def body_indent( node )
110
+ line = node.raw.lines.drop( 1 ).find { |l| !l.strip.empty? }
111
+ return line[ /\A[ \t]*/ ] if line
112
+
113
+ return "#{node.raw_indent}#{@style.unit}"
114
+ end
115
+
116
+ #
117
+ # The blank lines at the end of a body as written, kept when the
118
+ # body is written again.
119
+ #
120
+ def trailing_blanks( node )
121
+ lines = node.raw.lines.drop( 1 )
122
+ blanks = []
123
+ blanks.unshift( lines.pop ) while lines.any? && lines.last.strip.empty?
124
+ return blanks.join
125
+ end
126
+
127
+ # ---------------------------------------------------------------------
128
+ # Values on the line or in a BEGIN/END block
129
+ # ---------------------------------------------------------------------
130
+
131
+ #
132
+ # The declaration with its value: on the line if it can be written
133
+ # there, otherwise as a BEGIN/END block. A value that was a block
134
+ # stays one if it still ends with a newline.
135
+ #
136
+ def value_text( head, sep, obj, indent, was_block )
137
+ value = obj.value_display.to_s
138
+ warn_not_exact( obj, NOT_UTF8 ) unless value.valid_encoding?
139
+ return block_text( head, sep, obj, value, indent ) if block?( value, was_block )
140
+ return "#{head}#{sep.rstrip}\n" if value.empty?
141
+
142
+ return "#{head}#{sep}#{value}\n"
143
+ end
144
+
145
+ #
146
+ # Does the value need (or keep) a BEGIN/END block? A line break
147
+ # can't go on the line, and a value ending in a backslash or in
148
+ # BEGIN would be read as a continuation or a block start.
149
+ #
150
+ def block?( value, was_block )
151
+ return true if was_block && value.end_with?( "\n" )
152
+
153
+ clean = value.scrub
154
+ return clean.include?( "\n" ) || clean.end_with?( '\\' ) || clean.strip.end_with?( BEGIN_BLOCK )
155
+ end
156
+
157
+ #
158
+ # A BEGIN/END block: the value's lines exactly as they are, and END
159
+ # lined up with the declaration.
160
+ #
161
+ def block_text( head, sep, obj, value, indent )
162
+ content = value
163
+ unless content.end_with?( "\n" )
164
+ warn_not_exact( obj, NO_FINAL_NEWLINE )
165
+ content = "#{content}\n"
166
+ end
167
+ warn_not_exact( obj, END_LINE ) if content.scrub.lines.any? { |l| l.strip == END_BLOCK }
168
+
169
+ return "#{head}#{sep}#{BEGIN_BLOCK}\n#{content}#{indent}#{END_BLOCK}\n"
170
+ end
171
+
172
+ #
173
+ # What goes between a declaration's head and its value, as written:
174
+ # usually ' : '.
175
+ #
176
+ def separator( node )
177
+ tail = node.raw_tail.to_s
178
+ return tail.sub( /#{BEGIN_BLOCK}\s*\z/o, '' ) if node.block_style == :begin_end
179
+
180
+ value = node.raw_value.to_s
181
+ return tail[ 0, tail.length - value.length ] if !value.empty? && tail.end_with?( value )
182
+
183
+ trimmed = tail.rstrip
184
+ return trimmed.end_with?( ':' ) ? "#{trimmed} " : SEP
185
+ end
186
+
187
+ #
188
+ # The block style of a declaration written from scratch.
189
+ #
190
+ def block_style_of( text )
191
+ text = text.scrub
192
+ first = text.lines.first.to_s
193
+ return :begin_end if first.rstrip.end_with?( BEGIN_BLOCK ) && text.lines.size > 1
194
+ return :body if text.lines.size > 1
195
+
196
+ return :inline
197
+ end
198
+
199
+ #
200
+ # Warn that a value is saved as the closest thing gloo can write.
201
+ #
202
+ def warn_not_exact( obj, why )
203
+ @engine.warn format( NOT_EXACT, obj.pn, why )
204
+ end
205
+
206
+ end
207
+ end
208
+ end
@@ -30,28 +30,42 @@ module Gloo
30
30
  #
31
31
  # Get all the gloo files in the folder (partial path). The folder
32
32
  # can end with the start of a file name, as a prefix (folder/a).
33
+ # A relative folder is looked for in each of the given folders in
34
+ # turn (see default_folders); the first that has matching files
35
+ # wins.
33
36
  #
34
- def get_all_files_in( folder )
35
- dir = folder_for_files( folder )
36
- return Dir.glob( "#{dir}*.gloo" )
37
+ def get_all_files_in( folder, folders = nil )
38
+ return Dir.glob( "#{absolute_folder( folder )}*.gloo" ) if absolute?( folder )
39
+
40
+ ( folders || default_folders ).each do |base|
41
+ files = Dir.glob( "#{File.join( base, folder )}*.gloo" )
42
+ return files.map { |f| File.expand_path( f ) } unless files.empty?
43
+ end
44
+ return []
37
45
  end
38
46
 
39
47
  #
40
- # Where to look for a folder of files: an absolute or ~ folder as
41
- # given (~ expanded), anything else in the project folder, or if it
42
- # isn't there, in the gloo home.
48
+ # An absolute or ~ folder, with ~ expanded.
43
49
  #
44
- def folder_for_files( folder )
50
+ def absolute_folder( folder )
45
51
  return folder.sub( '~', Dir.home ) if folder.start_with?( '~' )
46
- return folder if File.absolute_path?( folder )
47
52
 
48
- dir = File.join( @engine.settings.project_path, folder )
49
- return dir if Dir.exist?( dir )
53
+ return folder
54
+ end
55
+
56
+ #
57
+ # Is the name a full path, or one starting with ~?
58
+ #
59
+ def absolute?( name )
60
+ return name.start_with?( '~' ) || File.absolute_path?( name )
61
+ end
50
62
 
51
- @log.debug "Folder does not exist in project: #{folder}"
52
- dir = File.join( @engine.settings.user_root, folder )
53
- @log.debug "Looking in gloo home? found == #{Dir.exist?( dir )}"
54
- return dir
63
+ #
64
+ # Where a relative name is looked for when no folders are given:
65
+ # the project folder, then the gloo home.
66
+ #
67
+ def default_folders
68
+ return [ @engine.settings.project_path, @engine.settings.user_root ]
55
69
  end
56
70
 
57
71
  #
@@ -75,59 +89,76 @@ module Gloo
75
89
 
76
90
  #
77
91
  # Expand a single file path. The .gloo extension is optional: it's
78
- # added when the name doesn't end with it, for a full or ~ path
79
- # and for a name in the project or gloo home.
92
+ # added when the name doesn't end with it.
80
93
  #
81
- def expand( name )
94
+ # A full or ~ path is used as given. A relative name is looked for
95
+ # in each of the given folders in turn (the load verb's order, see
96
+ # PersistMan#search_folders). With no folders given (a file named
97
+ # on the command line), it's tried as given, relative to the
98
+ # working folder, and then in the project folder and gloo home.
99
+ #
100
+ def expand( name, folders = nil )
101
+ full_name = name.end_with?( file_ext ) ? name : "#{name}#{file_ext}"
102
+ return expand_in( full_name, folders ) if folders && !absolute?( name )
103
+
82
104
  # Try the name as given: a full path, or relative to the working
83
105
  # folder (eg. gloo hello_world.gloo).
84
106
  ext_path = File.expand_path( name )
85
107
  return [ ext_path ] if self.valid?( ext_path )
86
108
 
87
- full_name = name.end_with?( file_ext ) ? name : "#{name}#{file_ext}"
88
-
89
109
  # A full or ~ path without the extension.
90
- if name.start_with?( '~' ) || File.absolute_path?( name )
110
+ if absolute?( name )
91
111
  ext_path = File.expand_path( full_name )
92
112
  return [ ext_path ] if self.valid?( ext_path )
93
113
  end
94
114
 
95
- # Try in projects
96
- pn = File.join( @engine.settings.project_path, full_name )
97
- return [ pn ] if self.valid?( pn )
98
-
99
- # Try in user root
100
- pn = File.join( @engine.settings.user_root, full_name )
101
- return [ pn ] if self.valid?( pn )
115
+ return expand_in( full_name, default_folders )
116
+ end
102
117
 
118
+ #
119
+ # The file in the first of the folders that has it, as a one-item
120
+ # list; nil if none does.
121
+ #
122
+ def expand_in( full_name, folders )
123
+ folders.each do |base|
124
+ pn = File.expand_path( File.join( base, full_name ) )
125
+ return [ pn ] if self.valid?( pn )
126
+ end
103
127
  return nil
104
128
  end
105
129
 
106
130
  #
107
- # Read in the contents of a single file.
131
+ # Read in the contents of a single file, as UTF-8 whatever the
132
+ # locale: a .gloo file is UTF-8 (under LANG=C, a plain read would
133
+ # take every non-ASCII character as an invalid byte). Bytes that
134
+ # aren't valid UTF-8 are kept as they are; the loader warns about
135
+ # them.
108
136
  #
109
137
  def read( file )
110
- return File.read( file )
138
+ return File.binread( file ).force_encoding( Encoding::UTF_8 )
111
139
  end
112
140
 
113
141
  #
114
142
  # Resolve a path to save a new (not-yet-existing) file to, given
115
- # a name or relative path -- same convention as expand (relative
116
- # to the project root, .gloo appended if missing), but without
117
- # requiring the file to already exist.
143
+ # a name or relative path, with .gloo appended if missing. Unlike
144
+ # expand, the file doesn't have to exist. A full path is used as
145
+ # given and a ~ path is expanded. A relative name goes in the
146
+ # given folder (the first place a load would look, see
147
+ # PersistMan#save_folder), or the project folder if none is given.
118
148
  #
119
- def resolve_save_path( name )
149
+ def resolve_save_path( name, folder = nil )
120
150
  full_name = name.end_with?( file_ext ) ? name : "#{name}#{file_ext}"
121
- return full_name if full_name.start_with?( '/' )
151
+ return File.expand_path( absolute_folder( full_name ) ) if absolute?( full_name )
122
152
 
123
- return File.join( @engine.settings.project_path, full_name )
153
+ return File.expand_path( File.join( folder || @engine.settings.project_path, full_name ) )
124
154
  end
125
155
 
126
- #
127
- # Write data to the file.
128
- #
156
+ #
157
+ # Write data to the file, byte for byte (no conversion for the
158
+ # locale).
159
+ #
129
160
  def write( pn, data )
130
- File.write( pn, data )
161
+ File.binwrite( pn, data )
131
162
  end
132
163
 
133
164
  end