forthic 0.1.0 → 0.3.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +314 -14
  3. data/Rakefile +37 -8
  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 +682 -133
  18. data/lib/forthic/literals.rb +170 -0
  19. data/lib/forthic/module.rb +383 -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 +225 -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 +76 -39
  37. data/.standard.yml +0 -3
  38. data/CHANGELOG.md +0 -5
  39. data/Guardfile +0 -42
  40. data/lib/forthic/code_location.rb +0 -20
  41. data/lib/forthic/forthic_error.rb +0 -51
  42. data/lib/forthic/forthic_module.rb +0 -145
  43. data/lib/forthic/global_module.rb +0 -2341
  44. data/lib/forthic/positioned_string.rb +0 -19
  45. data/lib/forthic/token.rb +0 -38
  46. data/lib/forthic/variable.rb +0 -34
  47. data/lib/forthic/version.rb +0 -5
  48. data/lib/forthic/words/definition_word.rb +0 -40
  49. data/lib/forthic/words/end_array_word.rb +0 -28
  50. data/lib/forthic/words/end_module_word.rb +0 -16
  51. data/lib/forthic/words/imported_word.rb +0 -27
  52. data/lib/forthic/words/map_word.rb +0 -169
  53. data/lib/forthic/words/module_memo_bang_at_word.rb +0 -22
  54. data/lib/forthic/words/module_memo_bang_word.rb +0 -21
  55. data/lib/forthic/words/module_memo_word.rb +0 -35
  56. data/lib/forthic/words/module_word.rb +0 -21
  57. data/lib/forthic/words/push_value_word.rb +0 -21
  58. data/lib/forthic/words/start_module_word.rb +0 -31
  59. data/lib/forthic/words/word.rb +0 -30
  60. data/sig/forthic.rbs +0 -4
@@ -1,154 +1,357 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'forthic_error'
4
3
  require_relative 'tokenizer'
5
- require_relative 'token'
6
- require_relative 'code_location'
7
- require_relative 'positioned_string'
8
- require_relative 'words/word'
9
- require_relative 'words/push_value_word'
10
- require_relative 'words/start_module_word'
11
- require_relative 'words/end_module_word'
12
- require_relative 'words/end_array_word'
13
- require_relative 'words/definition_word'
14
- require_relative 'global_module'
4
+ require_relative 'module'
5
+ require_relative 'errors'
6
+ require_relative 'literals'
7
+ require 'time'
15
8
 
16
9
  module Forthic
17
- class Interpreter
18
- attr_accessor :stack, :global_module, :app_module, :module_stack, :registered_modules,
19
- :is_compiling, :should_stop, :is_memo_definition, :cur_definition,
20
- :screens, :default_module_flags, :module_flags, :string_location,
21
- :word_counts, :is_profiling, :start_profile_time, :timestamps
10
+ # Timestamp - Struct for profiling timestamps
11
+ Timestamp = Struct.new(:label, :time_ms, keyword_init: true)
12
+
13
+ # StartModuleWord - Handles module creation and switching
14
+ #
15
+ # Pushes a module onto the module stack, creating it if necessary.
16
+ # An empty name refers to the app module.
17
+ class StartModuleWord < Word
18
+ def execute(interp)
19
+ # The app module is the only module with a blank name
20
+ if @name == ""
21
+ interp.module_stack_push(interp.get_app_module)
22
+ return
23
+ end
24
+
25
+ # If the module is used by the current module, push it onto the stack, otherwise
26
+ # create a new module.
27
+ mod = interp.cur_module.find_module(@name)
28
+ unless mod
29
+ mod = Module.new(@name)
30
+ interp.cur_module.register_module(mod.name, mod.name, mod)
31
+
32
+ # If we're at the app module, also register with interpreter
33
+ if interp.cur_module.name == ""
34
+ interp.register_module(mod)
35
+ end
36
+ end
37
+ interp.module_stack_push(mod)
38
+ end
39
+ end
40
+
41
+ # EndModuleWord - Pops the current module from the module stack
42
+ #
43
+ # Completes module context and returns to the previous module.
44
+ class EndModuleWord < Word
45
+ def initialize
46
+ super("}")
47
+ end
48
+
49
+ def execute(interp)
50
+ interp.module_stack_pop
51
+ end
52
+ end
22
53
 
54
+ # EndArrayWord - Collects items from stack into an array
55
+ #
56
+ # Pops items from the stack until a START_ARRAY token is found,
57
+ # then pushes them as a single array in the correct order.
58
+ class EndArrayWord < Word
23
59
  def initialize
24
- @stack = []
60
+ super("]")
61
+ end
62
+
63
+ def execute(interp)
64
+ items = []
65
+ item = interp.stack_pop
66
+ # NOTE: This won't infinite loop because interp.stack_pop will eventually fail
67
+ loop do
68
+ break if item.is_a?(Token) && item.type == TokenType::START_ARRAY
69
+ items << item
70
+ item = interp.stack_pop
71
+ end
72
+ items.reverse!
73
+ interp.stack_push(items)
74
+ end
75
+ end
76
+
77
+ # Stack - Wrapper for the interpreter's data stack
78
+ #
79
+ # Provides stack operations with support for array indexing.
80
+ # Handles PositionedString unwrapping and provides JSON serialization.
81
+ # Items can be accessed with bracket notation (e.g., stack[0]).
82
+ class Stack
83
+ def initialize(items = [])
84
+ @items = items
85
+ end
86
+
87
+ # Array-like access methods
88
+ def [](index)
89
+ @items[index]
90
+ end
91
+
92
+ def []=(index, value)
93
+ @items[index] = value
94
+ end
95
+
96
+ def length
97
+ @items.length
98
+ end
99
+
100
+ alias size length
101
+
102
+ def get_items
103
+ @items.map do |item|
104
+ if item.is_a?(PositionedString)
105
+ item.value
106
+ else
107
+ item
108
+ end
109
+ end
110
+ end
111
+
112
+ def get_raw_items
113
+ @items
114
+ end
115
+
116
+ def set_raw_items(items)
117
+ @items = items
118
+ end
119
+
120
+ def to_json(*args)
121
+ @items.to_json(*args)
122
+ end
123
+
124
+ def pop
125
+ @items.pop
126
+ end
127
+
128
+ def push(item)
129
+ @items.push(item)
130
+ end
131
+
132
+ # Duplicate stack with a shallow copy of items
133
+ def dup
134
+ Stack.new(@items.dup)
135
+ end
136
+ end
137
+
138
+ # Interpreter - Base Forthic interpreter
139
+ #
140
+ # Core interpreter that tokenizes and executes Forthic code.
141
+ # Manages the data stack, module stack, and execution context.
142
+ #
143
+ # Features:
144
+ # - Stack-based execution model
145
+ # - Module system with imports and namespacing
146
+ # - Literal handlers for parsing values (numbers, dates, booleans, etc.)
147
+ # - Error handling with recovery attempts
148
+ # - Profiling and performance tracking
149
+ # - Streaming execution support
150
+ #
151
+ # Note: This is the base interpreter without standard library modules.
152
+ # Use StandardInterpreter for a full-featured interpreter with stdlib.
153
+ class Interpreter
154
+ attr_reader :timezone, :stack
155
+ attr_accessor :handle_error, :max_attempts, :on_word_execute
156
+
157
+ def initialize(modules = [], timezone = "UTC")
158
+ @timezone = timezone
159
+ @stack = Stack.new
160
+
161
+ @tokenizer_stack = []
162
+ @max_attempts = 3
163
+ @handle_error = nil
164
+
165
+ @app_module = Module.new("")
166
+ @app_module.set_interp(self)
167
+ @module_stack = [@app_module]
25
168
  @registered_modules = {}
26
169
  @is_compiling = false
27
- @should_stop = false
28
170
  @is_memo_definition = false
29
171
  @cur_definition = nil
30
- @screens = {}
31
- @default_module_flags = {}
32
- @module_flags = {}
33
- @string_location = nil
34
172
 
35
- @global_module = GlobalModule.new(self)
36
- @app_module = ForthicModule.new("", self)
37
- @module_stack = [@app_module]
173
+ # Debug support
174
+ @string_location = nil
175
+ @previous_token = nil
38
176
 
177
+ # Profiling support
39
178
  @word_counts = {}
40
179
  @is_profiling = false
41
180
  @start_profile_time = nil
42
181
  @timestamps = []
182
+
183
+ # Streaming support
184
+ @streaming_token_index = 0
185
+ @stream = false
186
+ @previous_delta_length = 0
187
+
188
+ # Progress callback for streaming execution
189
+ @on_word_execute = nil
190
+ @word_execution_count = 0
191
+ @total_words_estimate = 0
192
+
193
+ # Literal handlers
194
+ @literal_handlers = []
195
+ register_standard_literals
196
+
197
+ # If modules are provided, import them unprefixed as a convenience
198
+ import_modules(modules)
199
+ end
200
+
201
+ def get_timezone
202
+ @timezone
43
203
  end
44
204
 
45
- def halt
46
- @should_stop = true
205
+ def set_timezone(timezone)
206
+ @timezone = timezone
47
207
  end
48
208
 
49
- # @return [ForthicModule]
50
209
  def get_app_module
51
210
  @app_module
52
211
  end
53
212
 
54
- # @return [CodeLocation, nil]
213
+ def get_top_input_string
214
+ return "" if @tokenizer_stack.empty?
215
+ @tokenizer_stack[0].get_input_string
216
+ end
217
+
218
+ def get_tokenizer
219
+ @tokenizer_stack.last
220
+ end
221
+
55
222
  def get_string_location
56
223
  @string_location
57
224
  end
58
225
 
59
- # @param [String] module_id
60
- # @param [Hash] flags
61
- def set_flags(module_id, flags)
62
- @default_module_flags[module_id] = flags
63
- @module_flags[module_id] = flags
226
+ def set_max_attempts(max_attempts)
227
+ @max_attempts = max_attempts
64
228
  end
65
229
 
66
- # @param [String] module_id
67
- # @return [Hash]
68
- def get_flags(module_id)
69
- module_flags = @module_flags[module_id] || {}
70
- result = module_flags.dup
71
- @module_flags[module_id] = @default_module_flags[module_id].dup
72
- result
230
+ def set_error_handler(handle_error)
231
+ @handle_error = handle_error
73
232
  end
74
233
 
75
- # @param [String] module_id
76
- # @param [Hash] flags
77
- def modify_flags(module_id, flags)
78
- module_flags = @module_flags[module_id] || {}
79
- @module_flags[module_id] = module_flags.merge(flags)
234
+ def get_max_attempts
235
+ @max_attempts
236
+ end
237
+
238
+ def get_error_handler
239
+ @handle_error
80
240
  end
81
241
 
82
242
  def reset
83
- @stack = []
243
+ @stack = Stack.new
84
244
  @app_module.variables = {}
245
+
85
246
  @module_stack = [@app_module]
86
247
  @is_compiling = false
87
248
  @is_memo_definition = false
88
249
  @cur_definition = nil
250
+
251
+ # Debug support
89
252
  @string_location = nil
90
253
  end
91
254
 
92
- # @param [String] screen_name
93
- # @return [String]
94
- def get_screen_forthic(screen_name)
95
- screen = @screens[screen_name]
96
- raise ForthicError.new("interpreter-199", "Unable to find screen \"#{screen_name}\"", "Hmmm...something went wrong. Please file a ticket if this continues to happen") unless screen
97
- screen
255
+ def run(string, reference_location = nil)
256
+ @tokenizer_stack.push(Tokenizer.new(string, reference_location))
257
+
258
+ if @handle_error
259
+ execute_with_recovery
260
+ else
261
+ run_with_tokenizer(@tokenizer_stack.last)
262
+ end
263
+
264
+ @tokenizer_stack.pop
265
+ true
266
+ end
267
+
268
+ def execute_with_recovery(num_attempts = 0)
269
+ num_attempts += 1
270
+ if num_attempts > @max_attempts
271
+ raise TooManyAttemptsError.new(
272
+ get_top_input_string,
273
+ num_attempts,
274
+ @max_attempts
275
+ )
276
+ end
277
+ continue_execution
278
+ num_attempts
279
+ rescue => e
280
+ raise unless @handle_error
281
+ @handle_error.call(e, self)
282
+ execute_with_recovery(num_attempts)
98
283
  end
99
284
 
100
- # @param [String] string
101
- # @param [CodeLocation, nil] reference_location
102
- # @return [Boolean]
103
- def run(string, reference_location = nil)
104
- tokenizer = Tokenizer.new(string, reference_location)
105
- run_with_tokenizer(tokenizer)
285
+ def continue_execution
286
+ run_with_tokenizer(@tokenizer_stack.last)
106
287
  end
107
288
 
108
- # @param [Tokenizer] tokenizer
109
- # @return [Boolean]
110
289
  def run_with_tokenizer(tokenizer)
111
290
  token = nil
112
291
  loop do
292
+ @previous_token = token
113
293
  token = tokenizer.next_token
114
294
  handle_token(token)
115
- break if token.type == TokenType::EOS || @should_stop
116
- next if [TokenType::START_DEF, TokenType::END_DEF, TokenType::COMMENT].include?(token.type) || @is_compiling
295
+ break if token.type == TokenType::EOS
117
296
  end
118
- true
119
- # rescue => e
120
- # error = ForthicError.new("interpreter-213", "Ran into an error executing this '#{token.string}'", "If there is an unknown error in the stack details, please file a ticket so we can resolve it.", token.location)
121
- # error.set_caught_error(e)
122
- # raise error
297
+ true # Done executing
123
298
  end
124
299
 
125
- # @return [ForthicModule]
126
300
  def cur_module
127
301
  @module_stack.last
128
302
  end
129
303
 
130
- # @param [String] name
131
- # @return [ForthicModule]
132
304
  def find_module(name)
133
305
  result = @registered_modules[name]
134
- raise ForthicError.new("interpreter-236", "Couldn't find '#{name}' module", "This is most likely a typo in your Forthic code. Please check to see if '#{name}' is properly spelled and that you have permission to access it") unless result
306
+ unless result
307
+ raise UnknownModuleError.new(
308
+ get_top_input_string,
309
+ name,
310
+ location: @string_location
311
+ )
312
+ end
313
+ result
314
+ end
315
+
316
+ def stack_peek
317
+ top = @stack[@stack.length - 1]
318
+ result = top
319
+ result = top.value if top.is_a?(PositionedString)
135
320
  result
136
321
  end
137
322
 
138
- # @param [Object] val
139
323
  def stack_push(val)
140
324
  @stack.push(val)
141
325
  end
142
326
 
143
- # @return [Object]
144
327
  def stack_pop
145
- raise ForthicError.new("interpreter-251", "Stack underflow", "This happens when we expect something to be on the stack, but it's empty. This is caused by a logical error in the Forthic and can be resolved through debugging.") if @stack.empty?
328
+ if @stack.length.zero?
329
+ tokenizer = @tokenizer_stack.length > 0 ? get_tokenizer : nil
330
+ raise StackUnderflowError.new(
331
+ get_top_input_string,
332
+ location: tokenizer&.get_token_location
333
+ )
334
+ end
146
335
  result = @stack.pop
147
- @string_location = result.is_a?(PositionedString) ? result.location : nil
148
- result.is_a?(PositionedString) ? result.value_of : result
336
+
337
+ # If we have a PositionedString, we need to record the location
338
+ @string_location = nil
339
+ if result.is_a?(PositionedString)
340
+ positioned_string = result
341
+ result = positioned_string.value
342
+ @string_location = positioned_string.location
343
+ end
344
+ result
345
+ end
346
+
347
+ def get_stack
348
+ @stack
349
+ end
350
+
351
+ def set_stack(stack)
352
+ @stack = stack
149
353
  end
150
354
 
151
- # @param [ForthicModule] mod
152
355
  def module_stack_push(mod)
153
356
  @module_stack.push(mod)
154
357
  end
@@ -157,47 +360,147 @@ module Forthic
157
360
  @module_stack.pop
158
361
  end
159
362
 
160
- # @param [ForthicModule] mod
161
363
  def register_module(mod)
162
364
  @registered_modules[mod.name] = mod
365
+ mod.set_interp(self)
366
+ end
367
+
368
+ # If names is an array of strings, import each module without a prefix (empty string)
369
+ # If names is an array of arrays, import each module using the first element as the
370
+ # module name and the second element as the prefix
371
+ def use_modules(names)
372
+ names.each do |name|
373
+ module_name = name
374
+ prefix = "" # Default to empty prefix (no prefix)
375
+ if name.is_a?(Array)
376
+ module_name = name[0]
377
+ prefix = name[1] # Allow explicit prefix specification
378
+ end
379
+ mod = find_module(module_name)
380
+ get_app_module.import_module(prefix, mod, self)
381
+ end
382
+ end
383
+
384
+ # A convenience method to register and use a module
385
+ def import_module(mod, prefix = "")
386
+ register_module(mod)
387
+ use_modules([[mod.name, prefix]])
388
+ end
389
+
390
+ def import_modules(modules)
391
+ modules.each do |mod|
392
+ import_module(mod)
393
+ end
394
+ end
395
+
396
+ # Transforms simple module names to unprefixed imports: "math" -> ["math", ""]
397
+ # Preserves explicit prefix specifications: ["math", "m"] -> ["math", "m"]
398
+ def use_modules_unprefixed(names)
399
+ unprefixed = names.map do |name|
400
+ name.is_a?(Array) ? name : [name, ""]
401
+ end
402
+ use_modules(unprefixed)
163
403
  end
164
404
 
165
- # @param [ForthicModule] mod
166
405
  def run_module_code(mod)
167
406
  module_stack_push(mod)
168
- run(mod.forthic_code)
407
+ begin
408
+ # Set source to module name when running module code
409
+ module_location = CodeLocation.new(source: mod.name)
410
+ run(mod.forthic_code, module_location)
411
+ rescue => e
412
+ raise ModuleError.new(
413
+ get_top_input_string,
414
+ mod.name,
415
+ e,
416
+ location: @string_location,
417
+ cause: e
418
+ )
419
+ end
420
+
169
421
  module_stack_pop
170
- rescue => e
171
- error = ForthicError.new("interpreter-278", "Something went wrong when running the module #{mod.name}", "TODO: File a ticket")
172
- error.set_caught_error(e)
173
- raise error
174
422
  end
175
423
 
176
- # @param [String] name
177
- # @return [Word, nil]
424
+ # ======================
425
+ # Literal Handlers
426
+
427
+ # Register standard literal handlers
428
+ # Order matters: more specific handlers first
429
+ def register_standard_literals
430
+ @literal_handlers = [
431
+ method(:to_bool).to_proc, # TRUE, FALSE
432
+ method(:to_float).to_proc, # 3.14
433
+ Forthic.to_zoned_datetime(@timezone), # 2020-06-05T10:15:00Z
434
+ Forthic.to_literal_date(@timezone), # 2020-06-05, YYYY-MM-DD
435
+ method(:to_time).to_proc, # 9:00, 11:30 PM
436
+ method(:to_int).to_proc, # 42
437
+ ]
438
+ end
439
+
440
+ # Register a custom literal handler
441
+ # Handlers are checked in registration order
442
+ def register_literal_handler(handler)
443
+ @literal_handlers << handler
444
+ end
445
+
446
+ # Unregister a literal handler
447
+ def unregister_literal_handler(handler)
448
+ @literal_handlers.delete(handler)
449
+ end
450
+
451
+ # Try to parse string as a literal value
452
+ # Returns PushValueWord if successful, nil otherwise
453
+ def find_literal_word(name)
454
+ @literal_handlers.each do |handler|
455
+ value = handler.call(name)
456
+ return PushValueWord.new(name, value) unless value.nil?
457
+ end
458
+ nil
459
+ end
460
+
461
+ # ======================
462
+ # Find Word
463
+
178
464
  def find_word(name)
465
+ # 1. Check module stack (dictionary words + variables)
179
466
  result = nil
180
- @module_stack.reverse_each do |m|
467
+ (@module_stack.length - 1).downto(0) do |i|
468
+ m = @module_stack[i]
181
469
  result = m.find_word(name)
182
470
  break if result
183
471
  end
184
- result ||= @global_module.find_word(name)
472
+
473
+ # 2. Check literal handlers as fallback
474
+ result = find_literal_word(name) unless result
475
+
476
+ # 3. Throw error if still not found
477
+ unless result
478
+ raise UnknownWordError.new(
479
+ get_top_input_string,
480
+ name,
481
+ location: get_string_location
482
+ )
483
+ end
484
+
185
485
  result
186
486
  end
187
487
 
488
+ # ======================
489
+ # Profiling
490
+
188
491
  def start_profiling
189
492
  @is_profiling = true
190
493
  @timestamps = []
191
- @start_profile_time = Time.now
494
+ @start_profile_time = Time.now.to_f * 1000 # milliseconds
192
495
  add_timestamp("START")
193
496
  @word_counts = {}
194
497
  end
195
498
 
196
- # @param [Word] word
197
499
  def count_word(word)
198
500
  return unless @is_profiling
199
- @word_counts[word.name] ||= 0
200
- @word_counts[word.name] += 1
501
+ name = word.name
502
+ @word_counts[name] ||= 0
503
+ @word_counts[name] += 1
201
504
  end
202
505
 
203
506
  def stop_profiling
@@ -205,99 +508,141 @@ module Forthic
205
508
  @is_profiling = false
206
509
  end
207
510
 
208
- # @param [String] label
209
511
  def add_timestamp(label)
210
512
  return unless @is_profiling
211
- timestamp = { label: label, time_ms: (Time.now - @start_profile_time) * 1000 }
212
- @timestamps.push(timestamp)
513
+ timestamp = Timestamp.new(
514
+ label: label,
515
+ time_ms: (Time.now.to_f * 1000) - @start_profile_time
516
+ )
517
+ @timestamps << timestamp
213
518
  end
214
519
 
215
- # @return [Array<Hash>]
216
520
  def word_histogram
217
- @word_counts.map { |name, count| { word: name, count: count } }.sort_by { |item| -item[:count] }
521
+ items = []
522
+ @word_counts.each do |name, count|
523
+ items << { word: name, count: count }
524
+ end
525
+ items.sort_by { |item| -item[:count] }
218
526
  end
219
527
 
220
- # @return [Array<Hash>]
221
528
  def profile_timestamps
222
529
  @timestamps
223
530
  end
224
531
 
225
- # @param [Token] token
532
+ # ======================
533
+ # Handle tokens
534
+
226
535
  def handle_token(token)
227
536
  case token.type
228
- when TokenType::STRING then handle_string_token(token)
229
- when TokenType::COMMENT then handle_comment_token(token)
230
- when TokenType::START_ARRAY then handle_start_array_token(token)
231
- when TokenType::END_ARRAY then handle_end_array_token(token)
232
- when TokenType::START_MODULE then handle_start_module_token(token)
233
- when TokenType::END_MODULE then handle_end_module_token(token)
234
- when TokenType::START_DEF then handle_start_definition_token(token)
235
- when TokenType::START_MEMO then handle_start_memo_token(token)
236
- when TokenType::END_DEF then handle_end_definition_token(token)
237
- when TokenType::WORD then handle_word_token(token)
238
- when TokenType::EOS then return
537
+ when TokenType::STRING
538
+ handle_string_token(token)
539
+ when TokenType::COMMENT
540
+ handle_comment_token(token)
541
+ when TokenType::START_ARRAY
542
+ handle_start_array_token(token)
543
+ when TokenType::END_ARRAY
544
+ handle_end_array_token(token)
545
+ when TokenType::START_MODULE
546
+ handle_start_module_token(token)
547
+ when TokenType::END_MODULE
548
+ handle_end_module_token(token)
549
+ when TokenType::START_DEF
550
+ handle_start_definition_token(token)
551
+ when TokenType::START_MEMO
552
+ handle_start_memo_token(token)
553
+ when TokenType::END_DEF
554
+ handle_end_definition_token(token)
555
+ when TokenType::DOT_SYMBOL
556
+ handle_dot_symbol_token(token)
557
+ when TokenType::WORD
558
+ handle_word_token(token)
559
+ when TokenType::EOS
560
+ if @is_compiling
561
+ raise MissingSemicolonError.new(
562
+ get_top_input_string,
563
+ location: @previous_token&.location
564
+ )
565
+ end
239
566
  else
240
- raise ForthicError.new("interpreter-362", "Hmmm...the interpreter doesn't know what to make of '#{token.string}'", "This is most likely caused by a typo in the Forthic code and can be resolved by debugging.", token.location)
567
+ raise UnknownTokenError.new(
568
+ get_top_input_string,
569
+ token.string,
570
+ location: @string_location
571
+ )
241
572
  end
242
573
  end
243
574
 
244
- # @param [Token] token
245
575
  def handle_string_token(token)
246
576
  value = PositionedString.new(token.string, token.location)
247
577
  handle_word(PushValueWord.new("<string>", value))
248
578
  end
249
579
 
250
- # @param [Token] token
580
+ def handle_dot_symbol_token(token)
581
+ value = PositionedString.new(token.string, token.location)
582
+ handle_word(PushValueWord.new("<dot-symbol>", value))
583
+ end
584
+
585
+ # Start/end module tokens are treated as IMMEDIATE words *and* are also compiled
251
586
  def handle_start_module_token(token)
252
587
  word = StartModuleWord.new(token.string)
588
+
253
589
  @cur_definition.add_word(word) if @is_compiling
254
- count_word(word)
590
+ count_word(word) # For profiling
255
591
  word.execute(self)
256
592
  end
257
593
 
258
- # @param [Token] _token
259
594
  def handle_end_module_token(_token)
260
595
  word = EndModuleWord.new
596
+
261
597
  @cur_definition.add_word(word) if @is_compiling
262
598
  count_word(word)
263
599
  word.execute(self)
264
600
  end
265
601
 
266
- # @param [Token] token
267
602
  def handle_start_array_token(token)
268
603
  handle_word(PushValueWord.new("<start_array_token>", token))
269
604
  end
270
605
 
271
- # @param [Token] _token
272
606
  def handle_end_array_token(_token)
273
607
  handle_word(EndArrayWord.new)
274
608
  end
275
609
 
276
- # @param [Token] _token
277
610
  def handle_comment_token(_token)
278
- # Handle comment token (no-op)
611
+ # Comment handling (currently no-op)
279
612
  end
280
613
 
281
- # @param [Token] token
282
614
  def handle_start_definition_token(token)
283
- raise ForthicError.new("interpreter-407", "A definition was started while an existing definition was not ended", "This is probably caused by a missing semicolon. To resolve, ensure that all word definitions end with semicolons.", token.location) if @is_compiling
615
+ if @is_compiling
616
+ raise MissingSemicolonError.new(
617
+ get_top_input_string,
618
+ location: @previous_token&.location
619
+ )
620
+ end
284
621
  @cur_definition = DefinitionWord.new(token.string)
285
622
  @is_compiling = true
286
623
  @is_memo_definition = false
287
624
  end
288
625
 
289
- # @param [Token] token
290
626
  def handle_start_memo_token(token)
291
- raise ForthicError.new("interpreter-420", "A memo definition was started while an existing definition was not ended", "This is probably caused by a missing semicolon. To resolve, ensure that all word definitions end with semicolons.", token.location) if @is_compiling
627
+ if @is_compiling
628
+ raise MissingSemicolonError.new(
629
+ get_top_input_string,
630
+ location: @previous_token&.location
631
+ )
632
+ end
292
633
  @cur_definition = DefinitionWord.new(token.string)
293
634
  @is_compiling = true
294
635
  @is_memo_definition = true
295
636
  end
296
637
 
297
- # @param [Token] token
298
638
  def handle_end_definition_token(token)
299
- raise ForthicError.new("interpreter-433", "A definition was ended when one hadn't been started yet", "This is probably caused by an extra semicolon. To resolve, ensure that there are no spurious semicolons in the Forthic code.", token.location) unless @is_compiling
300
- raise ForthicError.new("interpreter-440", "Cannot finish definition because there is no current definition", "Please file a ticket", token.location) unless @cur_definition
639
+ unless @is_compiling && @cur_definition
640
+ raise ExtraSemicolonError.new(
641
+ get_top_input_string,
642
+ location: token.location
643
+ )
644
+ end
645
+
301
646
  if @is_memo_definition
302
647
  cur_module.add_memo_words(@cur_definition)
303
648
  else
@@ -306,23 +651,227 @@ module Forthic
306
651
  @is_compiling = false
307
652
  end
308
653
 
309
- # @param [Token] token
310
654
  def handle_word_token(token)
311
- word = find_word(token.string)
312
- raise ForthicError.new("interpreter-458", "Could not find word: #{token.string}", "Check to see if you have a typo in your word or the definition of that word", token.location) unless word
655
+ word = find_word(token.string) # Throws UnknownWordError if not found
313
656
  handle_word(word, token.location)
314
657
  end
315
658
 
316
- # @param [Word] word
317
- # @param [CodeLocation, nil] location
318
659
  def handle_word(word, location = nil)
319
660
  if @is_compiling
320
661
  word.set_location(location)
321
662
  @cur_definition.add_word(word)
322
663
  else
323
664
  count_word(word)
665
+
666
+ # Notify progress callback if set (for streaming execution)
667
+ if @on_word_execute && word.is_a?(DefinitionWord)
668
+ @word_execution_count += 1
669
+ @on_word_execute.call(word.name, @word_execution_count, @total_words_estimate)
670
+ end
671
+
324
672
  word.execute(self)
325
673
  end
326
674
  end
675
+
676
+ # Estimate the number of words in code (for progress tracking)
677
+ def estimate_word_count(code)
678
+ # Simple heuristic: count uppercase words (likely Forthic words)
679
+ code.scan(/[A-Z][A-Z0-9_-]*/).length
680
+ end
681
+
682
+ # Set up progress tracking for streaming execution
683
+ def setup_progress_tracking(code)
684
+ @word_execution_count = 0
685
+ @total_words_estimate = estimate_word_count(code)
686
+ end
687
+
688
+ # Reset progress tracking
689
+ def reset_progress_tracking
690
+ @word_execution_count = 0
691
+ @total_words_estimate = 0
692
+ @on_word_execute = nil
693
+ end
694
+
695
+ # Streaming execution support
696
+ def streaming_run(code_stream, done, reference_location = nil)
697
+ # Create a new Tokenizer for the full string
698
+ tokenizer = Tokenizer.new(code_stream, reference_location, done ? false : true)
699
+ tokens = []
700
+ eos_found = false
701
+
702
+ @tokenizer_stack.push(tokenizer)
703
+
704
+ # Gather tokens from the beginning
705
+ loop do
706
+ token = tokenizer.next_token
707
+ break unless token
708
+
709
+ # If we hit an EOS token then push it and break
710
+ if token.type == TokenType::EOS
711
+ tokens << token
712
+ eos_found = true
713
+ break
714
+ end
715
+
716
+ tokens << token
717
+ end
718
+
719
+ delta = eos_found ? nil : tokenizer.get_string_delta
720
+
721
+ new_stop = find_last_word_or_eos(tokens)
722
+
723
+ new_stop -= 1 if eos_found && !done
724
+ new_stop += 1 if !eos_found && !done
725
+
726
+ # Execute only tokens we have not executed previously
727
+ (@streaming_token_index...new_stop).each do |i|
728
+ token = tokens[i]
729
+ next unless token
730
+
731
+ handle_token(token)
732
+
733
+ if @stream && (token.type != TokenType::WORD || token.string != "START-LOG")
734
+ yield token.string
735
+ end
736
+ @previous_token = token
737
+ end
738
+
739
+ # Done with this tokenizer
740
+ @tokenizer_stack.pop
741
+
742
+ if @stream && !eos_found && delta
743
+ # Yield string delta if we're streaming and tokenizer has a delta
744
+ new_portion = delta[@previous_delta_length..-1]
745
+
746
+ yield({ string_delta: new_portion }) if new_portion && !new_portion.empty?
747
+ @previous_delta_length = delta.length
748
+ end
749
+
750
+ if done
751
+ end_stream
752
+ return
753
+ end
754
+
755
+ # Update our pointer and reset if done
756
+ @streaming_token_index = new_stop
757
+ end
758
+
759
+ def start_stream
760
+ @stream = true
761
+ @previous_delta_length = 0
762
+ @streaming_token_index = 0
763
+ end
764
+
765
+ def end_stream
766
+ @stream = false
767
+ @previous_delta_length = 0
768
+ @streaming_token_index = 0
769
+ end
770
+
771
+ private
772
+
773
+ def find_last_word_or_eos(tokens)
774
+ tokens.rindex { |token| token.type == TokenType::WORD || token.type == TokenType::EOS } || -1
775
+ end
776
+
777
+ # Wrapper methods for literal handlers to match expected interface
778
+ def to_bool(str)
779
+ Forthic.to_bool(str)
780
+ end
781
+
782
+ def to_float(str)
783
+ Forthic.to_float(str)
784
+ end
785
+
786
+ def to_int(str)
787
+ Forthic.to_int(str)
788
+ end
789
+
790
+ def to_time(str)
791
+ Forthic.to_time(str)
792
+ end
793
+ end
794
+
795
+ # Duplicate an interpreter
796
+ #
797
+ # @param interp [Interpreter] The interpreter to duplicate
798
+ # @return [Interpreter] A new interpreter with copied state
799
+ def self.dup_interpreter(interp)
800
+ # Create new interpreter of the same type as the source
801
+ result_interp = interp.class.new([], interp.get_timezone)
802
+
803
+ # Use copy() instead of dup() to preserve module_prefixes
804
+ result_interp.instance_variable_set(:@app_module, interp.instance_variable_get(:@app_module).copy(result_interp))
805
+ result_interp.instance_variable_set(:@module_stack, [result_interp.instance_variable_get(:@app_module)])
806
+
807
+ # Use Stack.dup() method
808
+ result_interp.instance_variable_set(:@stack, interp.instance_variable_get(:@stack).dup)
809
+
810
+ # Share registered modules reference (modules are shared, not copied)
811
+ result_interp.instance_variable_set(:@registered_modules, interp.instance_variable_get(:@registered_modules))
812
+
813
+ # Copy error handler if present
814
+ handle_error = interp.instance_variable_get(:@handle_error)
815
+ result_interp.instance_variable_set(:@handle_error, handle_error) if handle_error
816
+
817
+ result_interp
818
+ end
819
+
820
+ # StandardInterpreter - Full-featured interpreter with standard library
821
+ #
822
+ # Extends Interpreter and automatically imports standard modules:
823
+ # - CoreModule: Stack operations, variables, module system, control flow
824
+ # - ArrayModule: Array/collection operations
825
+ # - RecordModule: Record/hash operations
826
+ # - StringModule: String operations
827
+ # - MathModule: Mathematical operations
828
+ # - BooleanModule: Boolean logic
829
+ # - JsonModule: JSON operations
830
+ # - DateTimeModule: DateTime operations
831
+ #
832
+ # For most use cases, use this class. Use Interpreter if you need
833
+ # full control over which modules are loaded.
834
+ class StandardInterpreter < Interpreter
835
+ def initialize(modules = [], timezone = "UTC")
836
+ # Don't pass modules to super - we'll import them after stdlib
837
+ super([], timezone)
838
+
839
+ # Import standard library modules FIRST (checked last during lookup)
840
+ # This allows user modules to shadow stdlib words
841
+ import_standard_library
842
+
843
+ # Import user modules AFTER stdlib (checked first during lookup)
844
+ import_modules(modules)
845
+ end
846
+
847
+ private
848
+
849
+ def import_standard_library
850
+ require_relative 'modules/standard/core_module'
851
+ require_relative 'modules/standard/array_module'
852
+ require_relative 'modules/standard/record_module'
853
+ require_relative 'modules/standard/string_module'
854
+ require_relative 'modules/standard/math_module'
855
+ require_relative 'modules/standard/boolean_module'
856
+ require_relative 'modules/standard/json_module'
857
+ require_relative 'modules/standard/datetime_module'
858
+
859
+ stdlib = [
860
+ Modules::CoreModule.new,
861
+ Modules::ArrayModule.new,
862
+ Modules::RecordModule.new,
863
+ Modules::StringModule.new,
864
+ Modules::MathModule.new,
865
+ Modules::BooleanModule.new,
866
+ Modules::JsonModule.new,
867
+ Modules::DateTimeModule.new,
868
+ ]
869
+
870
+ # Import unprefixed at the BOTTOM of module stack
871
+ # This ensures they're checked LAST during find_word()
872
+ stdlib.each do |mod|
873
+ import_module(mod, "")
874
+ end
875
+ end
327
876
  end
328
- end
877
+ end