forthic 0.2.0 → 0.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +314 -14
  3. data/Rakefile +36 -7
  4. data/lib/forthic/decorators/docs.rb +69 -0
  5. data/lib/forthic/decorators/word.rb +331 -0
  6. data/lib/forthic/errors.rb +270 -0
  7. data/lib/forthic/grpc/client.rb +223 -0
  8. data/lib/forthic/grpc/errors.rb +149 -0
  9. data/lib/forthic/grpc/forthic_runtime_pb.rb +32 -0
  10. data/lib/forthic/grpc/forthic_runtime_services_pb.rb +31 -0
  11. data/lib/forthic/grpc/remote_module.rb +120 -0
  12. data/lib/forthic/grpc/remote_runtime_module.rb +148 -0
  13. data/lib/forthic/grpc/remote_word.rb +91 -0
  14. data/lib/forthic/grpc/runtime_manager.rb +60 -0
  15. data/lib/forthic/grpc/serializer.rb +184 -0
  16. data/lib/forthic/grpc/server.rb +361 -0
  17. data/lib/forthic/interpreter.rb +694 -245
  18. data/lib/forthic/literals.rb +200 -0
  19. data/lib/forthic/module.rb +440 -0
  20. data/lib/forthic/modules/standard/array_module.rb +940 -0
  21. data/lib/forthic/modules/standard/boolean_module.rb +176 -0
  22. data/lib/forthic/modules/standard/core_module.rb +362 -0
  23. data/lib/forthic/modules/standard/datetime_module.rb +349 -0
  24. data/lib/forthic/modules/standard/json_module.rb +55 -0
  25. data/lib/forthic/modules/standard/math_module.rb +365 -0
  26. data/lib/forthic/modules/standard/record_module.rb +203 -0
  27. data/lib/forthic/modules/standard/string_module.rb +170 -0
  28. data/lib/forthic/tokenizer.rb +242 -78
  29. data/lib/forthic/utils.rb +35 -0
  30. data/lib/forthic/websocket/handler.rb +548 -0
  31. data/lib/forthic/websocket/serializer.rb +160 -0
  32. data/lib/forthic/word_options.rb +141 -0
  33. data/lib/forthic.rb +30 -20
  34. data/protos/README.md +43 -0
  35. data/protos/v1/forthic_runtime.proto +200 -0
  36. metadata +72 -39
  37. data/.standard.yml +0 -3
  38. data/CHANGELOG.md +0 -11
  39. data/CLAUDE.md +0 -74
  40. data/Guardfile +0 -42
  41. data/lib/forthic/code_location.rb +0 -20
  42. data/lib/forthic/forthic_error.rb +0 -50
  43. data/lib/forthic/forthic_module.rb +0 -146
  44. data/lib/forthic/global_module.rb +0 -2328
  45. data/lib/forthic/positioned_string.rb +0 -19
  46. data/lib/forthic/token.rb +0 -37
  47. data/lib/forthic/variable.rb +0 -34
  48. data/lib/forthic/version.rb +0 -5
  49. data/lib/forthic/words/definition_word.rb +0 -38
  50. data/lib/forthic/words/end_array_word.rb +0 -28
  51. data/lib/forthic/words/end_module_word.rb +0 -16
  52. data/lib/forthic/words/imported_word.rb +0 -27
  53. data/lib/forthic/words/map_word.rb +0 -169
  54. data/lib/forthic/words/module_memo_bang_at_word.rb +0 -22
  55. data/lib/forthic/words/module_memo_bang_word.rb +0 -21
  56. data/lib/forthic/words/module_memo_word.rb +0 -35
  57. data/lib/forthic/words/module_word.rb +0 -21
  58. data/lib/forthic/words/push_value_word.rb +0 -21
  59. data/lib/forthic/words/start_module_word.rb +0 -31
  60. data/lib/forthic/words/word.rb +0 -30
  61. data/sig/forthic.rbs +0 -4
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'date'
5
+
6
+ module Forthic
7
+ # Literal Handlers for Forthic Interpreters
8
+ #
9
+ # This module provides literal parsing functions that convert string tokens into typed values.
10
+ # These handlers are used by the Forthic interpreter to recognize and parse different literal types.
11
+ #
12
+ # You can use these built-in handlers or create custom literal handlers for your own Forthic
13
+ # interpreters. Each handler should be a Proc/lambda that takes a string and returns the parsed
14
+ # value or nil if the string doesn't match the expected format.
15
+ #
16
+ # Built-in literal types:
17
+ # - Boolean: TRUE, FALSE
18
+ # - Integer: 42, -10, 0
19
+ # - Float: 3.14, -2.5, 0.0
20
+ # - Time: 9:00, 11:30 PM, 22:15
21
+ # - Date: 2020-06-05, YYYY-MM-DD (with wildcards)
22
+ # - ZonedDateTime: ISO 8601 timestamps with timezone support
23
+
24
+ # LiteralHandler type: Proc that takes string, returns parsed value or nil
25
+ # @example
26
+ # handler = ->(str) { str == "TRUE" ? true : nil }
27
+
28
+ # Parse boolean literals: TRUE, FALSE
29
+ #
30
+ # @param str [String] The string to parse
31
+ # @return [Boolean, nil] true, false, or nil if not a boolean
32
+ def self.to_bool(str)
33
+ return true if str == "TRUE"
34
+ return false if str == "FALSE"
35
+ nil
36
+ end
37
+
38
+ # Parse float literals: 3.14, -2.5, 0.0
39
+ # Must contain a decimal point
40
+ #
41
+ # @param str [String] The string to parse
42
+ # @return [Float, nil] The parsed float or nil if invalid
43
+ def self.to_float(str)
44
+ return nil unless str.include?(".")
45
+ result = Float(str)
46
+ result
47
+ rescue ArgumentError
48
+ nil
49
+ end
50
+
51
+ # Parse integer literals: 42, -10, 0
52
+ # Must not contain a decimal point
53
+ #
54
+ # @param str [String] The string to parse
55
+ # @return [Integer, nil] The parsed integer or nil if invalid
56
+ def self.to_int(str)
57
+ return nil if str.include?(".")
58
+ result = Integer(str, 10)
59
+ # Verify it's actually an integer string (not "42abc")
60
+ return nil unless result.to_s == str
61
+ result
62
+ rescue ArgumentError
63
+ nil
64
+ end
65
+
66
+ # SimpleTime - Represents a time of day (hour and minute)
67
+ #
68
+ # This is a simplified replacement for Temporal.PlainTime
69
+ # @!attribute hour
70
+ # @return [Integer] Hour (0-23)
71
+ # @!attribute minute
72
+ # @return [Integer] Minute (0-59)
73
+ SimpleTime = Struct.new(:hour, :minute, keyword_init: true) do
74
+ def to_s
75
+ format("%02d:%02d", hour, minute)
76
+ end
77
+ end
78
+
79
+ # Parse time literals: 9:00, 11:30 PM, 22:15 AM
80
+ #
81
+ # @param str [String] The string to parse
82
+ # @return [SimpleTime, nil] The parsed time or nil if invalid
83
+ def self.to_time(str)
84
+ match = str.match(/^(\d{1,2}):(\d{2})(?:\s*(AM|PM))?$/)
85
+ return nil unless match
86
+
87
+ hours = match[1].to_i
88
+ minutes = match[2].to_i
89
+ meridiem = match[3]
90
+
91
+ # Adjust for AM/PM
92
+ if meridiem == "PM" && hours < 12
93
+ hours += 12
94
+ elsif meridiem == "AM" && hours == 12
95
+ hours = 0
96
+ elsif meridiem == "AM" && hours > 12
97
+ # Handle invalid cases like "22:15 AM"
98
+ hours -= 12
99
+ end
100
+
101
+ return nil if hours > 23 || minutes >= 60
102
+
103
+ SimpleTime.new(hour: hours, minute: minutes)
104
+ end
105
+
106
+ # Create a date literal handler with timezone support
107
+ # Parses: 2020-06-05, YYYY-MM-DD (with wildcards)
108
+ #
109
+ # @param timezone [String] The timezone to use for wildcard resolution
110
+ # @return [Proc] A literal handler proc
111
+ def self.to_literal_date(timezone)
112
+ lambda do |str|
113
+ match = str.match(/^(\d{4}|YYYY)-(\d{2}|MM)-(\d{2}|DD)$/)
114
+ return nil unless match
115
+
116
+ # Get current date in the specified timezone
117
+ # Use ENV['TZ'] to temporarily set timezone for Time.now
118
+ old_tz = ENV['TZ']
119
+ begin
120
+ ENV['TZ'] = timezone
121
+ now = Time.now
122
+ year = match[1] == "YYYY" ? now.year : match[1].to_i
123
+ month = match[2] == "MM" ? now.month : match[2].to_i
124
+ day = match[3] == "DD" ? now.day : match[3].to_i
125
+
126
+ Date.new(year, month, day)
127
+ rescue ArgumentError
128
+ nil
129
+ ensure
130
+ ENV['TZ'] = old_tz
131
+ end
132
+ end
133
+ end
134
+
135
+ # Create a zoned datetime literal handler with timezone support
136
+ # Parses ISO 8601 datetime with IANA timezone bracket notation:
137
+ # - 2025-05-20T08:00:00[America/Los_Angeles] (IANA timezone)
138
+ # - 2025-05-20T08:00:00-07:00[America/Los_Angeles] (offset + IANA)
139
+ # - 2025-05-24T10:15:00Z (UTC)
140
+ # - 2025-05-24T10:15:00-05:00 (offset only)
141
+ # - 2025-05-24T10:15:00 (uses default timezone)
142
+ #
143
+ # @param timezone [String] The default timezone to use
144
+ # @return [Proc] A literal handler proc
145
+ def self.to_zoned_datetime(timezone)
146
+ lambda do |str|
147
+ return nil unless str.include?("T")
148
+
149
+ begin
150
+ # Extract IANA timezone from brackets if present
151
+ bracket_match = str.match(/\[([^\]]+)\]$/)
152
+
153
+ if bracket_match
154
+ # Extract IANA timezone name from brackets
155
+ tz_name = bracket_match[1]
156
+
157
+ # Extract datetime string (before bracket)
158
+ datetime_str = str[0...bracket_match.begin(0)]
159
+
160
+ # Parse datetime (may have offset)
161
+ # Handle Z suffix
162
+ datetime_str = datetime_str.sub(/Z$/, "+00:00")
163
+ time = Time.parse(datetime_str)
164
+
165
+ # Convert to specified timezone using TZInfo
166
+ require 'tzinfo'
167
+ tz = TZInfo::Timezone.get(tz_name)
168
+ return tz.to_local(time)
169
+ end
170
+
171
+ # No brackets - handle as before
172
+
173
+ # Handle explicit UTC (Z suffix)
174
+ if str.end_with?("Z")
175
+ return Time.parse(str).utc
176
+ end
177
+
178
+ # Handle explicit timezone offset (+05:00, -05:00)
179
+ if str.match?(/[+-]\d{2}:\d{2}$/)
180
+ return Time.parse(str)
181
+ end
182
+
183
+ # No timezone specified, use interpreter's timezone
184
+ # Parse as UTC first, then convert to specified timezone using ENV['TZ']
185
+ old_tz = ENV['TZ']
186
+ begin
187
+ ENV['TZ'] = timezone
188
+ time = Time.parse(str)
189
+ time
190
+ ensure
191
+ ENV['TZ'] = old_tz
192
+ end
193
+ rescue ArgumentError, TZInfo::InvalidTimezoneIdentifier
194
+ # ArgumentError: invalid time format
195
+ # TZInfo::InvalidTimezoneIdentifier: invalid timezone name
196
+ nil
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,440 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'tokenizer'
4
+ require_relative 'errors'
5
+ require 'set'
6
+
7
+ module Forthic
8
+ # WordHandler type: Proc that takes an interpreter
9
+ # @example
10
+ # handler = ->(interp) { interp.stack_push(42) }
11
+
12
+ # -------------------------------------
13
+ # Variable
14
+ # Variable - Named mutable value container
15
+ #
16
+ # Represents a variable that can store and retrieve values within a module scope.
17
+ # Variables are accessed by name and can be set to any value type.
18
+ class Variable
19
+ attr_reader :name
20
+ attr_accessor :value
21
+
22
+ def initialize(name, value = nil)
23
+ @name = name
24
+ @value = value
25
+ end
26
+
27
+ def get_name
28
+ @name
29
+ end
30
+
31
+ def set_value(val)
32
+ @value = val
33
+ end
34
+
35
+ def get_value
36
+ @value
37
+ end
38
+
39
+ def dup
40
+ Variable.new(@name, @value)
41
+ end
42
+ end
43
+
44
+ # -------------------------------------
45
+ # Words
46
+
47
+ # Word - Base class for all executable words in Forthic
48
+ #
49
+ # A word is the fundamental unit of execution in Forthic. When interpreted,
50
+ # it performs an action (typically manipulating the stack or control flow).
51
+ # All concrete word types must override the execute method.
52
+ class Word
53
+ attr_reader :name, :string
54
+ attr_accessor :location
55
+
56
+ def initialize(name)
57
+ @name = name
58
+ @string = name
59
+ @location = nil
60
+ @error_handlers = []
61
+ end
62
+
63
+ def set_location(location)
64
+ @location = location
65
+ end
66
+
67
+ def get_location
68
+ @location
69
+ end
70
+
71
+ def add_error_handler(&handler)
72
+ @error_handlers << handler
73
+ end
74
+
75
+ def remove_error_handler(handler)
76
+ @error_handlers.delete(handler)
77
+ end
78
+
79
+ def clear_error_handlers
80
+ @error_handlers.clear
81
+ end
82
+
83
+ def error_handlers
84
+ @error_handlers.dup
85
+ end
86
+
87
+ def try_error_handlers(error, interp)
88
+ @error_handlers.each do |handler|
89
+ begin
90
+ handler.call(error, self, interp)
91
+ return true # Handler succeeded
92
+ rescue => e
93
+ next # Try next handler
94
+ end
95
+ end
96
+ false # No handler succeeded
97
+ end
98
+
99
+ def execute(_interp)
100
+ raise NotImplementedError, "Must override Word#execute"
101
+ end
102
+ end
103
+
104
+ # PushValueWord - Word that pushes a value onto the stack
105
+ #
106
+ # Executes by pushing its stored value onto the interpreter's stack.
107
+ # Used for literals, variables, and constants.
108
+ class PushValueWord < Word
109
+ attr_reader :value
110
+
111
+ def initialize(name, value)
112
+ super(name)
113
+ @value = value
114
+ end
115
+
116
+ def execute(interp)
117
+ interp.stack_push(@value)
118
+ end
119
+ end
120
+
121
+ # DefinitionWord - User-defined word composed of other words
122
+ #
123
+ # Represents a word defined in Forthic code using `:`
124
+ # Contains a sequence of words that are executed in order.
125
+ # Provides error context by tracking both call site and definition location.
126
+ class DefinitionWord < Word
127
+ attr_reader :words
128
+
129
+ def initialize(name)
130
+ super(name)
131
+ @words = []
132
+ end
133
+
134
+ def add_word(word)
135
+ @words << word
136
+ end
137
+
138
+ def execute(interp)
139
+ @words.each do |word|
140
+ begin
141
+ word.execute(interp)
142
+ rescue => e
143
+ tokenizer = interp.get_tokenizer
144
+ raise WordExecutionError.new(
145
+ "Error executing #{@name}",
146
+ e,
147
+ call_location: tokenizer.get_token_location, # Where the word was called
148
+ definition_location: word.get_location # Where the word was defined
149
+ )
150
+ end
151
+ end
152
+ end
153
+ end
154
+
155
+ # ModuleMemoWord - Memoized word that caches its result
156
+ #
157
+ # Executes the wrapped word once and caches the result on the stack.
158
+ # Subsequent calls return the cached value without re-executing.
159
+ # Defined in Forthic using `@:`. Can be refreshed using the `!` and `!@` variants.
160
+ class ModuleMemoWord < Word
161
+ attr_reader :word, :value
162
+ attr_accessor :has_value
163
+
164
+ def initialize(word)
165
+ super(word.name)
166
+ @word = word
167
+ @has_value = false
168
+ @value = nil
169
+ end
170
+
171
+ def refresh(interp)
172
+ @word.execute(interp)
173
+ @value = interp.stack_pop
174
+ @has_value = true
175
+ end
176
+
177
+ def execute(interp)
178
+ refresh(interp) unless @has_value
179
+ interp.stack_push(@value)
180
+ end
181
+ end
182
+
183
+ # ModuleMemoBangWord - Forces refresh of a memoized word
184
+ #
185
+ # Re-executes the memoized word and updates its cached value.
186
+ # Named with a `!` suffix (e.g., `WORD!` for a memo word named `WORD`).
187
+ # Does not push the new value onto the stack.
188
+ class ModuleMemoBangWord < Word
189
+ attr_reader :memo_word
190
+
191
+ def initialize(memo_word)
192
+ super("#{memo_word.name}!")
193
+ @memo_word = memo_word
194
+ end
195
+
196
+ def execute(interp)
197
+ @memo_word.refresh(interp)
198
+ end
199
+ end
200
+
201
+ # ModuleMemoBangAtWord - Refreshes a memoized word and returns its value
202
+ #
203
+ # Re-executes the memoized word, updates its cached value, and pushes the new value onto the stack.
204
+ # Named with a `!@` suffix (e.g., `WORD!@` for a memo word named `WORD`).
205
+ # Combines the refresh and retrieval operations.
206
+ class ModuleMemoBangAtWord < Word
207
+ attr_reader :memo_word
208
+
209
+ def initialize(memo_word)
210
+ super("#{memo_word.name}!@")
211
+ @memo_word = memo_word
212
+ end
213
+
214
+ def execute(interp)
215
+ @memo_word.refresh(interp)
216
+ interp.stack_push(@memo_word.value)
217
+ end
218
+ end
219
+
220
+ # ExecuteWord - Wrapper word that executes another word
221
+ #
222
+ # Delegates execution to a target word. Used for prefixed module imports
223
+ # to create words like `prefix.word` that execute the original word from the imported module.
224
+ class ExecuteWord < Word
225
+ attr_reader :target_word
226
+
227
+ def initialize(name, target_word)
228
+ super(name)
229
+ @target_word = target_word
230
+ end
231
+
232
+ def execute(interp)
233
+ @target_word.execute(interp)
234
+ end
235
+ end
236
+
237
+ # ModuleWord - Word that executes a handler function with error handling support
238
+ #
239
+ # Used for module words created via decorators or add_module_word().
240
+ # Integrates per-word error handler functionality. Ruby version is synchronous.
241
+ class ModuleWord < Word
242
+ attr_reader :handler
243
+
244
+ def initialize(name, &handler)
245
+ super(name)
246
+ @handler = handler
247
+ end
248
+
249
+ def execute(interp)
250
+ @handler.call(interp)
251
+ rescue IntentionalStopError => e
252
+ # Never handle intentional flow control errors
253
+ raise
254
+ rescue => e
255
+ # Try error handlers
256
+ handled = try_error_handlers(e, interp)
257
+ raise unless handled # Re-raise if not handled
258
+ # If handled, execution continues (error suppressed)
259
+ end
260
+ end
261
+
262
+ # -------------------------------------
263
+ # Module
264
+
265
+ # Module - Container for words, variables, and imported modules
266
+ #
267
+ # Modules provide namespacing and code organization in Forthic.
268
+ # Each module maintains its own dictionary of words, variables, and imported modules.
269
+ #
270
+ # Features:
271
+ # - Word and variable management
272
+ # - Module importing with optional prefixes
273
+ # - Exportable word lists for controlled visibility
274
+ # - Module duplication and copying for isolated execution contexts
275
+ #
276
+ # Modules can be defined inline with `{module_name ... }` syntax or
277
+ # loaded from external sources.
278
+ class Module
279
+ attr_accessor :words, :exportable, :variables, :modules, :module_prefixes
280
+ attr_accessor :name, :forthic_code, :interp
281
+
282
+ def initialize(name, forthic_code = "")
283
+ @words = []
284
+ @exportable = []
285
+ @variables = {}
286
+ @modules = {}
287
+ @module_prefixes = {}
288
+ @name = name
289
+ @forthic_code = forthic_code
290
+ @interp = nil
291
+ end
292
+
293
+ def get_name
294
+ @name
295
+ end
296
+
297
+ def set_interp(interp)
298
+ @interp = interp
299
+ end
300
+
301
+ def get_interp
302
+ raise "Module #{@name} has no interpreter" unless @interp
303
+ @interp
304
+ end
305
+
306
+ # Duplication methods
307
+ def dup
308
+ result = Module.new(@name)
309
+ result.words = @words.dup
310
+ result.exportable = @exportable.dup
311
+ @variables.each do |key, var|
312
+ result.variables[key] = var.dup
313
+ end
314
+ @modules.each do |key, mod|
315
+ result.modules[key] = mod
316
+ end
317
+ result.forthic_code = @forthic_code
318
+ result
319
+ end
320
+
321
+ def copy(interp)
322
+ result = Module.new(@name)
323
+ result.words = @words.dup
324
+ result.exportable = @exportable.dup
325
+ @variables.each do |key, var|
326
+ result.variables[key] = var.dup
327
+ end
328
+ @modules.each do |key, mod|
329
+ result.modules[key] = mod
330
+ end
331
+
332
+ # Restore module_prefixes
333
+ @module_prefixes.each do |module_name, prefixes|
334
+ prefixes.each do |prefix|
335
+ result.import_module(prefix, @modules[module_name], interp)
336
+ end
337
+ end
338
+
339
+ result.forthic_code = @forthic_code
340
+ result
341
+ end
342
+
343
+ # Module management
344
+ def find_module(name)
345
+ @modules[name]
346
+ end
347
+
348
+ def register_module(module_name, prefix, mod)
349
+ @modules[module_name] = mod
350
+
351
+ @module_prefixes[module_name] ||= Set.new
352
+ @module_prefixes[module_name].add(prefix)
353
+ end
354
+
355
+ def import_module(prefix, mod, _interp)
356
+ new_module = mod.dup
357
+
358
+ words = new_module.exportable_words
359
+ words.each do |word|
360
+ # For unprefixed imports, add word directly
361
+ if prefix == ""
362
+ add_word(word)
363
+ else
364
+ # For prefixed imports, create word that executes the target word
365
+ prefixed_word = ExecuteWord.new("#{prefix}.#{word.name}", word)
366
+ add_word(prefixed_word)
367
+ end
368
+ end
369
+ register_module(mod.name, prefix, new_module)
370
+ end
371
+
372
+ # Word management
373
+ def add_word(word)
374
+ @words << word
375
+ end
376
+
377
+ def add_memo_words(word)
378
+ memo_word = ModuleMemoWord.new(word)
379
+ @words << memo_word
380
+ @words << ModuleMemoBangWord.new(memo_word)
381
+ @words << ModuleMemoBangAtWord.new(memo_word)
382
+ memo_word
383
+ end
384
+
385
+ def add_exportable(names)
386
+ @exportable.concat(names)
387
+ end
388
+
389
+ def add_exportable_word(word)
390
+ @words << word
391
+ @exportable << word.name
392
+ end
393
+
394
+ def add_module_word(word_name, word_func = nil, &block)
395
+ # Support both calling styles:
396
+ # 1. add_module_word("NAME", proc) - old style
397
+ # 2. add_module_word("NAME") { |interp| ... } - new style (block)
398
+ handler = block || word_func
399
+ raise ArgumentError, "Must provide either a proc or block" unless handler
400
+
401
+ word = ModuleWord.new(word_name, &handler)
402
+ add_exportable_word(word)
403
+ word
404
+ end
405
+
406
+ def exportable_words
407
+ result = []
408
+ @words.each do |word|
409
+ result << word if @exportable.include?(word.name)
410
+ end
411
+ result
412
+ end
413
+
414
+ def find_word(name)
415
+ result = find_dictionary_word(name)
416
+ result = find_variable(name) unless result
417
+ result
418
+ end
419
+
420
+ def find_dictionary_word(word_name)
421
+ # Search backwards (most recent first)
422
+ (@words.length - 1).downto(0) do |i|
423
+ w = @words[i]
424
+ return w if w.name == word_name
425
+ end
426
+ nil
427
+ end
428
+
429
+ def find_variable(varname)
430
+ var_result = @variables[varname]
431
+ return PushValueWord.new(varname, var_result) if var_result
432
+ nil
433
+ end
434
+
435
+ # Variable management
436
+ def add_variable(name, value = nil)
437
+ @variables[name] ||= Variable.new(name, value)
438
+ end
439
+ end
440
+ end