gloo 7.1.3 → 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 (47) 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/language_objects.md +20 -15
  6. data/docs/language_scripting.md +6 -2
  7. data/docs/testing.md +4 -7
  8. data/docs/verbs.md +23 -3
  9. data/lib/VERSION +1 -1
  10. data/lib/VERSION_NOTES +8 -0
  11. data/lib/dependencies.rb +4 -1
  12. data/lib/gloo/app/engine.rb +18 -6
  13. data/lib/gloo/core/obj.rb +0 -23
  14. data/lib/gloo/objs/basic/script.rb +0 -23
  15. data/lib/gloo/persist/declaration_ledger.rb +9 -9
  16. data/lib/gloo/persist/declaration_writer.rb +208 -0
  17. data/lib/gloo/persist/disc_mech.rb +70 -39
  18. data/lib/gloo/persist/file_loader.rb +125 -66
  19. data/lib/gloo/persist/file_saver.rb +130 -198
  20. data/lib/gloo/persist/file_storage.rb +15 -4
  21. data/lib/gloo/persist/file_style.rb +89 -0
  22. data/lib/gloo/persist/line_splitter.rb +16 -1
  23. data/lib/gloo/persist/persist_man.rb +112 -30
  24. data/lib/gloo/persist/save_index.rb +211 -0
  25. data/lib/gloo/persist/script_body_collector.rb +16 -21
  26. data/lib/gloo/persist/source/blank_node.rb +10 -3
  27. data/lib/gloo/persist/source/comment_node.rb +10 -2
  28. data/lib/gloo/persist/source/directive_node.rb +10 -2
  29. data/lib/gloo/persist/source/obj_node.rb +174 -9
  30. data/lib/gloo/persist/source/source_doc.rb +54 -0
  31. data/lib/gloo/persist/subtree_extractor.rb +9 -37
  32. data/lib/gloo/persist/trivia_buffer.rb +7 -7
  33. data/lib/gloo/verbs/load.rb +27 -21
  34. data/lib/gloo/verbs/move.rb +42 -3
  35. data/lib/gloo/verbs/save.rb +26 -4
  36. data/test.gloo/app/exit_status.test.gloo +34 -0
  37. data/test.gloo/app/fixtures/exit_after_error.gloo +9 -0
  38. data/test.gloo/lang/declaration.test.gloo +36 -0
  39. data/test.gloo/lang/fixtures/mixed_indent.gloo +5 -0
  40. data/test.gloo/lang/naming.test.gloo +44 -6
  41. data/test.gloo/verbs/fixtures/load_beside.gloo +6 -0
  42. data/test.gloo/verbs/fixtures/lossless_text.gloo +7 -0
  43. data/test.gloo/verbs/fixtures/lossless_text_saved.gloo +9 -0
  44. data/test.gloo/verbs/load.test.gloo +18 -0
  45. data/test.gloo/verbs/move.test.gloo +39 -0
  46. data/test.gloo/verbs/save.test.gloo +239 -0
  47. metadata +10 -1
@@ -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