gloo 6.3.1 → 6.5.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.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/CLAUDE.md +13 -0
  3. data/docs/application.md +9 -0
  4. data/docs/language_objects.md +78 -0
  5. data/docs/language_scripting.md +41 -0
  6. data/docs/objects.md +1 -1
  7. data/docs/verbs.md +36 -2
  8. data/lib/VERSION +1 -1
  9. data/lib/VERSION_NOTES +13 -0
  10. data/lib/gloo/app/engine.rb +5 -1
  11. data/lib/gloo/app/log.rb +5 -5
  12. data/lib/gloo/app/settings.rb +4 -1
  13. data/lib/gloo/core/obj.rb +60 -1
  14. data/lib/gloo/core/parser.rb +30 -1
  15. data/lib/gloo/core/tokens.rb +44 -18
  16. data/lib/gloo/expr/l_string.rb +8 -4
  17. data/lib/gloo/objs/basic/script.rb +28 -2
  18. data/lib/gloo/objs/web/json.rb +4 -1
  19. data/lib/gloo/persist/comment_buffer.rb +58 -0
  20. data/lib/gloo/persist/declaration_ledger.rb +61 -0
  21. data/lib/gloo/persist/disc_mech.rb +15 -2
  22. data/lib/gloo/persist/file_loader.rb +208 -102
  23. data/lib/gloo/persist/file_saver.rb +251 -6
  24. data/lib/gloo/persist/file_storage.rb +10 -4
  25. data/lib/gloo/persist/indent_stack.rb +90 -0
  26. data/lib/gloo/persist/line_splitter.rb +18 -3
  27. data/lib/gloo/persist/persist_man.rb +193 -26
  28. data/lib/gloo/persist/script_body_collector.rb +102 -0
  29. data/lib/gloo/persist/shorthand_expander.rb +51 -0
  30. data/lib/gloo/persist/source/blank_node.rb +27 -0
  31. data/lib/gloo/persist/source/comment_node.rb +27 -0
  32. data/lib/gloo/persist/source/directive_node.rb +26 -0
  33. data/lib/gloo/persist/source/obj_node.rb +91 -0
  34. data/lib/gloo/persist/source/source_doc.rb +41 -0
  35. data/lib/gloo/verbs/list.rb +24 -2
  36. data/lib/gloo/verbs/load.rb +9 -1
  37. data/lib/gloo/verbs/reload.rb +3 -0
  38. data/lib/gloo/verbs/save.rb +57 -10
  39. data/test.gloo/ctrl/each.test.gloo +28 -30
  40. data/test.gloo/dt/date.test.gloo +69 -71
  41. data/test.gloo/dt/datetime.test.gloo +55 -57
  42. data/test.gloo/dt/time.test.gloo +70 -72
  43. data/test.gloo/lang/continuation.test.gloo +25 -27
  44. data/test.gloo/lang/convert.test.gloo +50 -52
  45. data/test.gloo/lang/dt_comparisons.test.gloo +87 -89
  46. data/test.gloo/lang/exceptions.test.gloo +42 -44
  47. data/test.gloo/lang/gloo_sys.test.gloo +100 -102
  48. data/test.gloo/lang/here.test.gloo +32 -34
  49. data/test.gloo/lang/it.test.gloo +27 -29
  50. data/test.gloo/lang/literal.test.gloo +55 -57
  51. data/test.gloo/lang/load_lib_directive.test.gloo +18 -20
  52. data/test.gloo/lang/naming.test.gloo +28 -30
  53. data/test.gloo/lang/ops.test.gloo +53 -55
  54. data/test.gloo/lang/shorthand.test.gloo +27 -0
  55. data/test.gloo/math/add.test.gloo +40 -42
  56. data/test.gloo/math/div.test.gloo +23 -25
  57. data/test.gloo/math/mult.test.gloo +23 -25
  58. data/test.gloo/math/sub.test.gloo +23 -25
  59. data/test.gloo/objs/alias.test.gloo +15 -17
  60. data/test.gloo/objs/bool.test.gloo +54 -56
  61. data/test.gloo/objs/can.test.gloo +33 -35
  62. data/test.gloo/objs/cipher.test.gloo +39 -41
  63. data/test.gloo/objs/decimal.test.gloo +27 -29
  64. data/test.gloo/objs/erb.test.gloo +47 -49
  65. data/test.gloo/objs/file.test.gloo +68 -70
  66. data/test.gloo/objs/function.test.gloo +37 -39
  67. data/test.gloo/objs/int.test.gloo +54 -56
  68. data/test.gloo/objs/json.test.gloo +57 -50
  69. data/test.gloo/objs/obj.test.gloo +68 -48
  70. data/test.gloo/objs/outline.test.gloo +48 -50
  71. data/test.gloo/objs/password.test.gloo +34 -36
  72. data/test.gloo/objs/repeat.test.gloo +42 -44
  73. data/test.gloo/objs/script.test.gloo +13 -15
  74. data/test.gloo/objs/string.test.gloo +82 -84
  75. data/test.gloo/objs/text.test.gloo +90 -92
  76. data/test.gloo/objs/untyped.test.gloo +36 -38
  77. data/test.gloo/objs/uri.test.gloo +37 -39
  78. data/test.gloo/string/str.test.gloo +15 -0
  79. data/test.gloo/verbs/break.test.gloo +12 -14
  80. data/test.gloo/verbs/check.test.gloo +22 -24
  81. data/test.gloo/verbs/context.test.gloo +11 -13
  82. data/test.gloo/verbs/create.test.gloo +7 -9
  83. data/test.gloo/verbs/eval.test.gloo +10 -12
  84. data/test.gloo/verbs/exists.test.gloo +43 -45
  85. data/test.gloo/verbs/if.test.gloo +8 -10
  86. data/test.gloo/verbs/invoke.test.gloo +73 -75
  87. data/test.gloo/verbs/list.test.gloo +21 -23
  88. data/test.gloo/verbs/load.test.gloo +27 -21
  89. data/test.gloo/verbs/log.test.gloo +7 -9
  90. data/test.gloo/verbs/move.test.gloo +12 -14
  91. data/test.gloo/verbs/put.test.gloo +10 -12
  92. data/test.gloo/verbs/reload.test.gloo +55 -0
  93. data/test.gloo/verbs/run.test.gloo +8 -10
  94. data/test.gloo/verbs/save.test.gloo +111 -0
  95. data/test.gloo/verbs/show.test.gloo +26 -28
  96. data/test.gloo/verbs/tell.test.gloo +8 -10
  97. data/test.gloo/verbs/throw.test.gloo +31 -33
  98. data/test.gloo/verbs/unless.test.gloo +13 -15
  99. data/test.gloo/verbs/unload.test.gloo +10 -12
  100. metadata +14 -1
@@ -47,7 +47,7 @@ module Gloo
47
47
  if self.value_string?
48
48
  first = self.value
49
49
  self.set_array_value []
50
- self.value << first unless first.empty?
50
+ self.value << first unless first.strip.empty?
51
51
  elsif self.value_is_blank?
52
52
  self.set_array_value []
53
53
  end
@@ -62,6 +62,29 @@ module Gloo
62
62
  return true
63
63
  end
64
64
 
65
+ #
66
+ # Serialize this object's value for saving to a file.
67
+ # A script's value is an Array of command lines (once add_line
68
+ # has been called) or a single-line String. Render as indented
69
+ # body lines below the declaration, gloo's convention for
70
+ # scripts -- not a BEGIN/END block, which is reserved for a
71
+ # literal multi-line string value.
72
+ #
73
+ def serialize_value( indent )
74
+ lines = if value_is_array?
75
+ value
76
+ elsif value_string? && !value.strip.empty?
77
+ [ value ]
78
+ else
79
+ []
80
+ end
81
+ return ' :' if lines.empty?
82
+
83
+ t = "\t" * ( indent + 1 )
84
+ body = lines.map { |line| "#{t}#{line}" }.join( "\n" )
85
+ return " :\n#{body}"
86
+ end
87
+
65
88
  #
66
89
  # Get the number of lines in this script.
67
90
  #
@@ -105,7 +128,10 @@ module Gloo
105
128
  {
106
129
  :name => KEYWORD,
107
130
  :shortcut => KEYWORD_SHORT,
108
- :description => 'An executable script — a set of commands to be run.',
131
+ :description => 'An executable script — a set of commands to ' \
132
+ 'be run. A command line may end with an inline `# ...` ' \
133
+ 'comment; it is ignored when the line runs but kept when ' \
134
+ 'the file is saved.',
109
135
  :messages => [
110
136
  'run — Run the script. The script can be run by telling the ' \
111
137
  'object to run, or via the run verb.'
@@ -213,7 +213,10 @@ module Gloo
213
213
  {
214
214
  :name => KEYWORD,
215
215
  :shortcut => KEYWORD_SHORT,
216
- :description => 'JSON data in a text string.',
216
+ :description => 'JSON data in a text string. Declare it ' \
217
+ 'inline, in a BEGIN/END block, or assign it at run time: a ' \
218
+ 'single-quoted literal keeps its double quotes, so ' \
219
+ 'putting a JSON string into the object works.',
217
220
  :messages => [
218
221
  'get ({path}) — Get a value from the JSON data. Example: tell myjson to get (\'title\'). The parameter is the path in JSON to the value we want. The value is put into it.',
219
222
  'set ({obj.path}) — Convert an object to an approximate JSON value. Example: tell myjson to set (my.object). The parameter is the path to the object used as the source. Note this is an approximate conversion — a gloo object can have both a simple value and be a container for child objects, which JSON can\'t represent the same way.',
@@ -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
+ # 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
@@ -0,0 +1,61 @@
1
+ # Author:: Eric Crane (mailto:eric.crane@mac.com)
2
+ # Copyright:: Copyright (c) 2026 Eric Crane. All rights reserved.
3
+ #
4
+ # Tracks what a single file declares as it loads: its top-level
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.
8
+ #
9
+
10
+ module Gloo
11
+ module Persist
12
+ class DeclarationLedger
13
+
14
+ attr_reader :roots
15
+
16
+ #
17
+ # Set up a ledger for the file at pn.
18
+ #
19
+ def initialize( engine, pn )
20
+ @engine = engine
21
+ @pn = pn
22
+ @roots = []
23
+ @mine = {}.compare_by_identity
24
+ end
25
+
26
+ #
27
+ # Record a top-level object this file created or reused (shorthand
28
+ # lines can re-introduce the same root, so dedupe).
29
+ #
30
+ def root( obj )
31
+ @roots << obj unless @roots.include?( obj )
32
+ end
33
+
34
+ #
35
+ # Record an object this file created, at any level.
36
+ #
37
+ def created( obj )
38
+ @mine[ obj ] = true
39
+ end
40
+
41
+ #
42
+ # `prior` already existed when this file declared `name` with
43
+ # `value`. If another file created it and the values differ, the
44
+ # new value is being ignored -- warn so it isn't a surprise. A
45
+ # duplicate name inside one file is left alone (a documented
46
+ # "first one wins").
47
+ #
48
+ def clash( prior, name, value )
49
+ return if @mine.key?( prior )
50
+ return if value.nil? || value.to_s.strip.empty?
51
+ return if prior.value.to_s == value.to_s
52
+
53
+ @engine.log.warn(
54
+ "#{@pn}: '#{name}' is already declared in another loaded file with a " \
55
+ "different value (#{prior.value.inspect} kept, #{value.inspect} ignored)."
56
+ )
57
+ end
58
+
59
+ end
60
+ end
61
+ end
@@ -85,13 +85,26 @@ module Gloo
85
85
  return nil
86
86
  end
87
87
 
88
- #
88
+ #
89
89
  # Read in the contents of a single file.
90
- #
90
+ #
91
91
  def read( file )
92
92
  return File.read( file )
93
93
  end
94
94
 
95
+ #
96
+ # Resolve a path to save a new (not-yet-existing) file to, given
97
+ # a name or relative path -- same convention as expand (relative
98
+ # to the project root, .gloo appended if missing), but without
99
+ # requiring the file to already exist.
100
+ #
101
+ def resolve_save_path( name )
102
+ full_name = name.end_with?( file_ext ) ? name : "#{name}#{file_ext}"
103
+ return full_name if full_name.start_with?( '/' )
104
+
105
+ return File.join( @engine.settings.project_path, full_name )
106
+ end
107
+
95
108
  #
96
109
  # Write data to the file.
97
110
  #
@@ -1,7 +1,18 @@
1
1
  # Author:: Eric Crane (mailto:eric.crane@mac.com)
2
2
  # Copyright:: Copyright (c) 2019 Eric Crane. All rights reserved.
3
3
  #
4
- # Helper class used to load a file and create objects in the heap.
4
+ # Helper class used to load a file, create objects in the heap, and
5
+ # build a parallel Source::SourceDoc capturing everything about the
6
+ # file that the heap alone can't represent (comments, blank lines,
7
+ # raw formatting) -- so a later save can rewrite the file instead of
8
+ # regenerating it from scratch.
9
+ #
10
+ # Work is delegated to CommentBuffer (comment buffering),
11
+ # ScriptBodyCollector (script bodies), IndentStack (nesting shared by
12
+ # the heap and source trees), ShorthandExpander (nested-container
13
+ # shorthand), and DeclarationLedger (this file's roots + cross-file
14
+ # clash warnings) -- this class is the orchestrator: per-line dispatch
15
+ # and BEGIN/END handling.
5
16
  #
6
17
 
7
18
  module Gloo
@@ -20,7 +31,7 @@ module Gloo
20
31
  # afterward.
21
32
  LIB_DIRECTIVE = /\A(?:load|ld)\s+(?:lib|ext)\s+\S+\s*\z/i.freeze
22
33
 
23
- attr_reader :obj
34
+ attr_reader :obj, :source_doc
24
35
 
25
36
  #
26
37
  # Set up a file storage for an object.
@@ -29,16 +40,25 @@ module Gloo
29
40
  @engine = engine
30
41
  @mech = @engine.platform.get_file_mech( @engine )
31
42
  @pn = pn
32
- @tabs = 0
33
43
  @obj = nil
34
- @in_multiline = false
35
- @exiting_multiline = false
44
+ @source_doc = Gloo::Persist::Source::SourceDoc.new
45
+ @comments = Gloo::Persist::CommentBuffer.new
46
+ @body = Gloo::Persist::ScriptBodyCollector.new
47
+ @shorthand = Gloo::Persist::ShorthandExpander.new( engine )
48
+ @ledger = Gloo::Persist::DeclarationLedger.new( engine, pn )
36
49
  @in_block = false
37
50
  @block_value = ''
38
51
  @body_started = false
39
52
  @debug = false
40
53
  end
41
54
 
55
+ #
56
+ # The top-level objects this file declared (for save resolution).
57
+ #
58
+ def roots
59
+ return @ledger.roots
60
+ end
61
+
42
62
  #
43
63
  # Load the objects from the file.
44
64
  #
@@ -49,27 +69,12 @@ module Gloo
49
69
  end
50
70
 
51
71
  @engine.log.debug "Loading file '#{@pn}'"
52
- @tabs = 0
53
- @parent_stack = []
54
- @parent = @engine.heap.root
55
- @parent_stack.push @parent
72
+ @indent_stack = IndentStack.new( @engine.heap.root, @source_doc )
56
73
  f = @mech.read( @pn )
57
74
 
58
75
  f = join_continuations( f )
59
-
60
- f.each_line do |line|
61
- # Inside a BEGIN/END block every line is literal content -
62
- # don't skip blank lines or lines that start with '#'.
63
- next if !@in_block && skip_line?( line )
64
-
65
- if !@body_started && line =~ LIB_DIRECTIVE
66
- run_lib_directive line
67
- next
68
- end
69
- @body_started = true
70
-
71
- handle_one_line line
72
- end
76
+ f.each_line { |line| dispatch_line( line ) }
77
+ finish
73
78
  end
74
79
 
75
80
  #
@@ -90,115 +95,202 @@ module Gloo
90
95
  return data.gsub( "\\\n", '' )
91
96
  end
92
97
 
98
+ # ---------------------------------------------------------------------
99
+ # Per-line dispatch
100
+ # ---------------------------------------------------------------------
101
+
93
102
  #
94
- # Process one one of the file we're loading.
103
+ # Route one line of the file to whichever mode we're currently in:
104
+ # collecting a script body, collecting a BEGIN/END block, trivia
105
+ # (comment/blank), a top-of-file directive, or a normal
106
+ # declaration.
95
107
  #
96
- def handle_one_line( line )
97
- if line.strip.end_with? BEGIN_BLOCK
98
- @in_block = true
99
- @save_line = line
100
- elsif @in_block
101
- if line.strip == END_BLOCK
102
- @in_block = false
103
- determine_indent @save_line
104
- process_line @save_line
105
- else
106
- @block_value << line
107
- end
108
- else
109
- determine_indent line
110
- process_line line
108
+ def dispatch_line( line )
109
+ return handle_body_line( line ) if @body.active
110
+ return handle_block_line( line ) if @in_block
111
+ return handle_trivia_line( line ) if skip_line?( line )
112
+
113
+ if !@body_started && line =~ LIB_DIRECTIVE
114
+ @indent_stack.node.children << Source::DirectiveNode.new( line.strip )
115
+ run_lib_directive line
116
+ return
111
117
  end
118
+ @body_started = true
119
+
120
+ handle_declaration_line( line )
112
121
  end
113
122
 
114
123
  #
115
- # Is this line a comment or a blank line?
116
- # If so we'll skip it.
124
+ # End of file: close out anything still open so its content
125
+ # isn't silently dropped.
117
126
  #
118
- def skip_line?( line )
119
- line = line.strip
120
- return true if line.empty?
121
- return true if line[ 0 ] == '#'
122
-
123
- return false
127
+ def finish
128
+ @body.finish
129
+ @comments.flush_into( @indent_stack.node.children )
124
130
  end
125
131
 
132
+ # ---------------------------------------------------------------------
133
+ # Trivia (comments and blank lines)
134
+ # ---------------------------------------------------------------------
135
+
126
136
  #
127
- # Determine the relative indent level for the line.
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.
128
142
  #
129
- def determine_indent( line )
130
- tabs = tab_count( line )
131
- @indent = 0 # same level as prior line
132
- if tabs > @tabs # indent
133
- # TODO: What if indent is more than one more level?
134
- @tabs = tabs
135
- @indent = 1
136
- elsif tabs < @tabs # outdent
137
- diff = @tabs - tabs
138
- @tabs -= diff
139
- @indent -= diff
143
+ def handle_trivia_line( line )
144
+ if line.strip.empty?
145
+ @comments.flush_into( @indent_stack.node.children )
146
+ @indent_stack.node.children << Source::BlankNode.new( chomped( line ) )
147
+ else
148
+ @comments.push( chomped( line ), tab_count( line ) )
140
149
  end
141
- puts "tabs: #{@tabs}, indent: #{@indent}, line: #{line}" if @debug
142
150
  end
143
151
 
152
+ # ---------------------------------------------------------------------
153
+ # BEGIN / END blocks
154
+ # ---------------------------------------------------------------------
155
+
144
156
  #
145
- # Process one line and add objects.
157
+ # Inside a BEGIN/END block every line is literal content -- kept
158
+ # exactly as-is, comments and blank lines included.
146
159
  #
147
- def process_line( line )
148
- # reset multiline unless we're actually indented
149
- if @in_multiline && @multi_indent > @indent
150
- puts "Done multiline mi: #{@multi_indent}, i: #{@indent}" if @debug
151
- @in_multiline = false
152
- @exiting_multiline = true
153
- end
154
-
155
- if @in_multiline
156
- @last.add_line line
160
+ def handle_block_line( line )
161
+ if line.strip == END_BLOCK
162
+ @in_block = false
163
+ @block_end_indent = leading_ws( line )
164
+ finalize_declaration( @save_line )
157
165
  else
158
- setup_process_obj_line
159
- process_obj_line line
166
+ @block_value << line
160
167
  end
161
168
  end
162
169
 
170
+ # ---------------------------------------------------------------------
171
+ # Script bodies (indented lines, no BEGIN/END)
172
+ # ---------------------------------------------------------------------
173
+
163
174
  #
164
- # Setup and get ready to process an object line.
175
+ # One line while a script body might be open. Re-dispatches the
176
+ # line normally if it turns out to have closed the body.
165
177
  #
166
- def setup_process_obj_line
167
- if @exiting_multiline
168
- @exiting_multiline = false
169
- @indent += 1
170
- end
178
+ def handle_body_line( line )
179
+ consumed = @body.handle_line( line, tab_count( line ) )
180
+ dispatch_line( line ) unless consumed
181
+ end
171
182
 
172
- if @indent.positive?
173
- @parent = @last
174
- @parent_stack.push @parent
175
- elsif @indent.negative?
176
- @indent.abs.times do
177
- @parent_stack.pop
178
- @parent = @parent_stack.last
179
- end
183
+ # ---------------------------------------------------------------------
184
+ # Object declarations
185
+ # ---------------------------------------------------------------------
186
+
187
+ #
188
+ # A line that starts (or is) an object declaration.
189
+ #
190
+ def handle_declaration_line( line )
191
+ if line.strip.end_with?( BEGIN_BLOCK )
192
+ @in_block = true
193
+ @save_line = line
194
+ return
180
195
  end
196
+
197
+ finalize_declaration( line )
181
198
  end
182
199
 
183
200
  #
184
- # Process one line and add objects.
201
+ # Place the declaration at its indentation level and create the
202
+ # object (and its Source::ObjNode).
185
203
  #
186
- def process_obj_line( line )
187
- name, type, value = split_line( line )
188
- unless @block_value == ''
189
- value = @block_value
190
- @block_value = ''
191
- end
192
- params = { name: name, type: type, value: value, parent: @parent }
204
+ def finalize_declaration( line )
205
+ line_tabs = tab_count( line )
206
+ @indent_stack.place( line_tabs, @last, @last_node )
207
+ create_declared_obj( line, line_tabs )
208
+ end
209
+
210
+ #
211
+ # Create the heap object and its source node for one declaration
212
+ # line, and start body collection if its type calls for one.
213
+ #
214
+ def create_declared_obj( line, line_tabs )
215
+ name, type, value, style = split_declaration( line )
216
+ leaf, parent, roots, created = @shorthand.expand( name, @indent_stack.parent )
217
+ roots.each { |r| @ledger.root( r ) }
218
+ created.each { |c| @ledger.created( c ) }
219
+
220
+ prior = parent.find_child( leaf )
221
+ params = { :name => leaf, :type => type, :value => value, :parent => parent }
193
222
  @last = @engine.factory.create( params )
223
+ @ledger.clash( prior, leaf, value ) if prior
224
+ @ledger.created( @last )
225
+ @ledger.root( @last ) if parent == @engine.heap.root
194
226
 
195
- if value&.empty? && @last&.multiline_value?
196
- @multi_indent = 0
197
- @in_multiline = true
198
- puts "*** Start multiline. multi_indent: #{@multi_indent}" if @debug
199
- end
227
+ 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 )
229
+ # First non-empty doc wins, same as "first value wins" for a
230
+ # name re-declared across files -- @last is the same object
231
+ # across re-declarations (the factory returns the existing
232
+ # one), so a later, comment-less re-declaration doesn't blank
233
+ # out an earlier file's doc. @last can be nil for an unknown
234
+ # type (factory.create logs a warning and returns nil).
235
+ @last.doc = node.doc if @last && @last.doc.to_s.strip.empty?
236
+ @indent_stack.node.children << node
237
+ @last_node = node
238
+ @obj ||= @ledger.roots.last || @last
239
+
240
+ # A whitespace-only inline value (trailing spaces after the ':'
241
+ # on a `script :` line) counts as no value -- start the body.
242
+ no_value = value.nil? || value.strip.empty?
243
+ @body.start( node, @last, @indent_stack.tabs ) if no_value && @last&.multiline_value?
244
+ end
245
+
246
+ #
247
+ # Split a declaration line into name/type/value, plus a style
248
+ # hash (:block_style, :raw_tail), folding in any BEGIN/END block
249
+ # value collected just before it. raw_tail is the exact source
250
+ # text that followed the type bracket on this line -- eg.
251
+ # " : BEGIN" or " :" -- kept for a save to reproduce the
252
+ # declaration's original spacing when unchanged.
253
+ #
254
+ def split_declaration( line )
255
+ splitter = LineSplitter.new( line, @indent_stack&.tabs || 0 )
256
+ name, type, value = splitter.split
257
+ style = { :raw_tail => splitter.raw_tail, :block_style => :inline }
258
+ return name, type, value, style if @block_value == ''
259
+
260
+ value = @block_value
261
+ @block_value = ''
262
+ style[ :block_style ] = :begin_end
263
+ style[ :raw_end_indent ] = @block_end_indent
264
+ return name, type, value, style
265
+ end
266
+
267
+ #
268
+ # Build the source node for one object declaration. raw_value is
269
+ # kept byte-exact with whatever was handed to the heap object's
270
+ # set_value (trailing newline included, for a BEGIN/END block) so
271
+ # a save can compare the two directly.
272
+ #
273
+ def build_obj_node( raw_indent, name, type, value, style )
274
+ node = Source::ObjNode.new( :name => name, :raw_type => type )
275
+ node.raw_indent = raw_indent
276
+ node.raw_tail = style[ :raw_tail ]
277
+ node.block_style = style[ :block_style ]
278
+ node.raw_value = value
279
+ node.raw_end_indent = style[ :raw_end_indent ] || ''
280
+ node.obj = @last
281
+ return node
282
+ end
283
+
284
+ #
285
+ # Is this line a comment or a blank line?
286
+ # If so we'll skip it.
287
+ #
288
+ def skip_line?( line )
289
+ line = line.strip
290
+ return true if line.empty?
291
+ return true if line[ 0 ] == '#'
200
292
 
201
- @obj = @last if @obj.nil?
293
+ return false
202
294
  end
203
295
 
204
296
  #
@@ -222,10 +314,24 @@ module Gloo
222
314
  # Split the line into 3 parts.
223
315
  #
224
316
  def split_line( line )
225
- o = LineSplitter.new( line, @tabs )
317
+ o = LineSplitter.new( line, @indent_stack&.tabs || 0 )
226
318
  return o.split
227
319
  end
228
320
 
321
+ #
322
+ # The line's leading whitespace, exactly as written.
323
+ #
324
+ def leading_ws( line )
325
+ return line[ /\A[ \t]*/ ]
326
+ end
327
+
328
+ #
329
+ # A line's raw text with its trailing newline removed.
330
+ #
331
+ def chomped( line )
332
+ return line.chomp( "\n" )
333
+ end
334
+
229
335
  end
230
336
  end
231
337
  end