forthic 0.2.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 (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 +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 +224 -77
  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
@@ -1,428 +1,877 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "forthic_error"
4
- 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"
3
+ require_relative 'tokenizer'
4
+ require_relative 'module'
5
+ require_relative 'errors'
6
+ require_relative 'literals'
7
+ require 'time'
15
8
 
16
9
  module Forthic
17
- # Error codes used throughout the interpreter
18
- module ErrorCodes
19
- SCREEN_NOT_FOUND = "screen-not-found"
20
- EXECUTION_ERROR = "execution-error"
21
- MODULE_NOT_FOUND = "module-not-found"
22
- STACK_UNDERFLOW = "stack-underflow"
23
- MODULE_EXECUTION_ERROR = "module-execution-error"
24
- UNKNOWN_TOKEN = "unknown-token"
25
- NESTED_DEFINITION = "nested-definition"
26
- NESTED_MEMO_DEFINITION = "nested-memo-definition"
27
- DEFINITION_WITHOUT_START = "definition-without-start"
28
- MISSING_DEFINITION = "missing-definition"
29
- WORD_NOT_FOUND = "word-not-found"
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
30
39
  end
31
40
 
32
- # Manages execution state for the interpreter
33
- class ExecutionState
34
- attr_accessor :stack, :module_stack, :is_compiling, :should_stop,
35
- :is_memo_definition, :cur_definition, :string_location
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
36
48
 
37
- def initialize(app_module)
38
- @stack = []
39
- @module_stack = [app_module]
40
- @is_compiling = false
41
- @should_stop = false
42
- @is_memo_definition = false
43
- @cur_definition = nil
44
- @string_location = nil
49
+ def execute(interp)
50
+ interp.module_stack_pop
45
51
  end
52
+ end
46
53
 
47
- def reset(app_module)
48
- @stack = []
49
- @module_stack = [app_module]
50
- @is_compiling = false
51
- @is_memo_definition = false
52
- @cur_definition = nil
53
- @string_location = nil
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
59
+ def initialize
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)
54
74
  end
55
75
  end
56
76
 
57
- # Manages profiling state and operations
58
- class ProfilingState
59
- attr_reader :word_counts, :is_profiling, :start_profile_time, :timestamps
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
60
86
 
61
- def initialize
62
- @word_counts = {}
63
- @is_profiling = false
64
- @start_profile_time = nil
65
- @timestamps = []
87
+ # Array-like access methods
88
+ def [](index)
89
+ @items[index]
66
90
  end
67
91
 
68
- def start_profiling
69
- @is_profiling = true
70
- @timestamps = []
71
- @start_profile_time = Time.now
72
- add_timestamp("START")
73
- @word_counts = {}
92
+ def []=(index, value)
93
+ @items[index] = value
74
94
  end
75
95
 
76
- def stop_profiling
77
- add_timestamp("END")
78
- @is_profiling = false
96
+ def length
97
+ @items.length
79
98
  end
80
99
 
81
- def count_word(word)
82
- return unless @is_profiling
83
- @word_counts[word.name] ||= 0
84
- @word_counts[word.name] += 1
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
85
110
  end
86
111
 
87
- def add_timestamp(label)
88
- return unless @is_profiling
89
- timestamp = {label: label, time_ms: (Time.now - @start_profile_time) * 1000}
90
- @timestamps.push(timestamp)
112
+ def get_raw_items
113
+ @items
91
114
  end
92
115
 
93
- def word_histogram
94
- @word_counts.map { |name, count| {word: name, count: count} }.sort_by { |item| -item[:count] }
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)
95
135
  end
96
136
  end
97
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.
98
153
  class Interpreter
99
- # Core interpreter components
100
- attr_reader :global_module, :app_module, :registered_modules
101
- # Screen and module management
102
- attr_accessor :screens, :default_module_flags, :module_flags
103
- # State objects
104
- attr_reader :execution_state, :profiling_state
105
-
106
- # Token handler lookup table
107
- TOKEN_HANDLERS = {
108
- TokenType::STRING => :handle_string_token,
109
- TokenType::COMMENT => :handle_comment_token,
110
- TokenType::START_ARRAY => :handle_start_array_token,
111
- TokenType::END_ARRAY => :handle_end_array_token,
112
- TokenType::START_MODULE => :handle_start_module_token,
113
- TokenType::END_MODULE => :handle_end_module_token,
114
- TokenType::START_DEF => :handle_start_definition_token,
115
- TokenType::START_MEMO => :handle_start_memo_token,
116
- TokenType::END_DEF => :handle_end_definition_token,
117
- TokenType::WORD => :handle_word_token
118
- }.freeze
154
+ attr_reader :timezone, :stack
155
+ attr_accessor :handle_error, :max_attempts, :on_word_execute
119
156
 
120
- def initialize
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]
121
168
  @registered_modules = {}
122
- @screens = {}
123
- @default_module_flags = {}
124
- @module_flags = {}
169
+ @is_compiling = false
170
+ @is_memo_definition = false
171
+ @cur_definition = nil
125
172
 
126
- @global_module = GlobalModule.new(self)
127
- @app_module = ForthicModule.new("", self)
173
+ # Debug support
174
+ @string_location = nil
175
+ @previous_token = nil
128
176
 
129
- @execution_state = ExecutionState.new(@app_module)
130
- @profiling_state = ProfilingState.new
131
- end
177
+ # Profiling support
178
+ @word_counts = {}
179
+ @is_profiling = false
180
+ @start_profile_time = nil
181
+ @timestamps = []
132
182
 
133
- def halt
134
- @execution_state.should_stop = true
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)
135
199
  end
136
200
 
137
- # @return [ForthicModule]
138
- def get_app_module
139
- @app_module
201
+ def get_timezone
202
+ @timezone
140
203
  end
141
204
 
142
- # @return [CodeLocation, nil]
143
- def get_string_location
144
- @execution_state.string_location
205
+ def set_timezone(timezone)
206
+ @timezone = timezone
145
207
  end
146
208
 
147
- # Delegation methods for execution state
148
- def stack
149
- @execution_state.stack
209
+ def get_app_module
210
+ @app_module
150
211
  end
151
212
 
152
- def stack=(new_stack)
153
- @execution_state.stack = new_stack
213
+ def get_top_input_string
214
+ return "" if @tokenizer_stack.empty?
215
+ @tokenizer_stack[0].get_input_string
154
216
  end
155
217
 
156
- def module_stack
157
- @execution_state.module_stack
218
+ def get_tokenizer
219
+ @tokenizer_stack.last
158
220
  end
159
221
 
160
- def is_compiling
161
- @execution_state.is_compiling
222
+ def get_string_location
223
+ @string_location
162
224
  end
163
225
 
164
- def cur_definition
165
- @execution_state.cur_definition
226
+ def set_max_attempts(max_attempts)
227
+ @max_attempts = max_attempts
166
228
  end
167
229
 
168
- # @param [String] module_id
169
- # @param [Hash] flags
170
- def set_flags(module_id, flags)
171
- @default_module_flags[module_id] = flags
172
- @module_flags[module_id] = flags
230
+ def set_error_handler(handle_error)
231
+ @handle_error = handle_error
173
232
  end
174
233
 
175
- # @param [String] module_id
176
- # @return [Hash]
177
- def get_flags(module_id)
178
- module_flags = @module_flags[module_id] || {}
179
- result = module_flags.dup
180
- @module_flags[module_id] = @default_module_flags[module_id].dup
181
- result
234
+ def get_max_attempts
235
+ @max_attempts
182
236
  end
183
237
 
184
- # @param [String] module_id
185
- # @param [Hash] flags
186
- def modify_flags(module_id, flags)
187
- module_flags = @module_flags[module_id] || {}
188
- @module_flags[module_id] = module_flags.merge(flags)
238
+ def get_error_handler
239
+ @handle_error
189
240
  end
190
241
 
191
242
  def reset
243
+ @stack = Stack.new
192
244
  @app_module.variables = {}
193
- @execution_state.reset(@app_module)
194
- end
195
245
 
196
- # @param [String] screen_name
197
- # @return [String]
198
- def get_screen_forthic(screen_name)
199
- screen = @screens[screen_name]
200
- raise ForthicError.new(ErrorCodes::SCREEN_NOT_FOUND, "Unable to find screen \"#{screen_name}\"", "Screen not found. Check the screen name for typos or ensure it has been properly registered.") unless screen
201
- screen
246
+ @module_stack = [@app_module]
247
+ @is_compiling = false
248
+ @is_memo_definition = false
249
+ @cur_definition = nil
250
+
251
+ # Debug support
252
+ @string_location = nil
202
253
  end
203
254
 
204
- # @param [String] string
205
- # @param [CodeLocation, nil] reference_location
206
- # @return [Boolean]
207
255
  def run(string, reference_location = nil)
208
- tokenizer = Tokenizer.new(string, reference_location)
209
- run_with_tokenizer(tokenizer)
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)
283
+ end
284
+
285
+ def continue_execution
286
+ run_with_tokenizer(@tokenizer_stack.last)
210
287
  end
211
288
 
212
- # @param [Tokenizer] tokenizer
213
- # @return [Boolean]
214
289
  def run_with_tokenizer(tokenizer)
215
290
  token = nil
216
291
  loop do
292
+ @previous_token = token
217
293
  token = tokenizer.next_token
218
294
  handle_token(token)
219
- break if token.type == TokenType::EOS || @execution_state.should_stop
220
- next if [TokenType::START_DEF, TokenType::END_DEF, TokenType::COMMENT].include?(token.type) || @execution_state.is_compiling
295
+ break if token.type == TokenType::EOS
221
296
  end
222
- true
223
- rescue => e
224
- error = ForthicError.new(ErrorCodes::EXECUTION_ERROR, "Error executing token '#{token&.string}'", "An unexpected error occurred during execution. Check the token syntax and try again.", token&.location)
225
- error.set_caught_error(e)
226
- raise error
297
+ true # Done executing
227
298
  end
228
299
 
229
- # @return [ForthicModule]
230
300
  def cur_module
231
- @execution_state.module_stack.last
301
+ @module_stack.last
232
302
  end
233
303
 
234
- # @param [String] name
235
- # @return [ForthicModule]
236
304
  def find_module(name)
237
- raise ArgumentError, "Module name cannot be nil" if name.nil?
238
- raise ArgumentError, "Module name cannot be empty" if name.empty?
239
-
240
305
  result = @registered_modules[name]
241
- raise ForthicError.new(ErrorCodes::MODULE_NOT_FOUND, "Module '#{name}' not found", "Check the module name for typos and ensure it has been properly registered.") 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)
242
320
  result
243
321
  end
244
322
 
245
- # @param [Object] val
246
323
  def stack_push(val)
247
- @execution_state.stack.push(val)
324
+ @stack.push(val)
248
325
  end
249
326
 
250
- # @return [Object]
251
327
  def stack_pop
252
- raise ForthicError.new(ErrorCodes::STACK_UNDERFLOW, "Stack underflow", "Attempted to pop from an empty stack. This indicates a logical error in the Forthic code.") if @execution_state.stack.empty?
253
- result = @execution_state.stack.pop
254
- @execution_state.string_location = result.is_a?(PositionedString) ? result.location : nil
255
- result.is_a?(PositionedString) ? result.value_of : result
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
335
+ result = @stack.pop
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
256
353
  end
257
354
 
258
- # @param [ForthicModule] mod
259
355
  def module_stack_push(mod)
260
- raise ArgumentError, "Module cannot be nil" if mod.nil?
261
- @execution_state.module_stack.push(mod)
356
+ @module_stack.push(mod)
262
357
  end
263
358
 
264
359
  def module_stack_pop
265
- @execution_state.module_stack.pop
360
+ @module_stack.pop
266
361
  end
267
362
 
268
- # @param [ForthicModule] mod
269
363
  def register_module(mod)
270
- raise ArgumentError, "Module cannot be nil" if mod.nil?
271
- raise ArgumentError, "Module must respond to :name" unless mod.respond_to?(:name)
272
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
273
382
  end
274
383
 
275
- # @param [ForthicModule] mod
276
- # @param [String] prefix
384
+ # A convenience method to register and use a module
277
385
  def import_module(mod, prefix = "")
278
- raise ArgumentError, "Module cannot be nil" if mod.nil?
279
386
  register_module(mod)
280
- @app_module.import_module(prefix, mod, self)
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)
281
403
  end
282
404
 
283
- # @param [ForthicModule] mod
284
405
  def run_module_code(mod)
285
- raise ArgumentError, "Module cannot be nil" if mod.nil?
286
406
  module_stack_push(mod)
287
- 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
+
288
421
  module_stack_pop
289
- rescue => e
290
- error = ForthicError.new(ErrorCodes::MODULE_EXECUTION_ERROR, "Error executing module '#{mod.name}'", "An error occurred while running the module code. Check the module implementation for syntax errors.")
291
- error.set_caught_error(e)
292
- raise error
293
422
  end
294
423
 
295
- # @param [String] name
296
- # @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
+
297
464
  def find_word(name)
465
+ # 1. Check module stack (dictionary words + variables)
298
466
  result = nil
299
- @execution_state.module_stack.reverse_each do |m|
467
+ (@module_stack.length - 1).downto(0) do |i|
468
+ m = @module_stack[i]
300
469
  result = m.find_word(name)
301
470
  break if result
302
471
  end
303
- 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
+
304
485
  result
305
486
  end
306
487
 
307
- # Delegation methods for profiling
488
+ # ======================
489
+ # Profiling
490
+
308
491
  def start_profiling
309
- @profiling_state.start_profiling
492
+ @is_profiling = true
493
+ @timestamps = []
494
+ @start_profile_time = Time.now.to_f * 1000 # milliseconds
495
+ add_timestamp("START")
496
+ @word_counts = {}
310
497
  end
311
498
 
312
- def stop_profiling
313
- @profiling_state.stop_profiling
499
+ def count_word(word)
500
+ return unless @is_profiling
501
+ name = word.name
502
+ @word_counts[name] ||= 0
503
+ @word_counts[name] += 1
314
504
  end
315
505
 
316
- def count_word(word)
317
- @profiling_state.count_word(word)
506
+ def stop_profiling
507
+ add_timestamp("END")
508
+ @is_profiling = false
318
509
  end
319
510
 
320
511
  def add_timestamp(label)
321
- @profiling_state.add_timestamp(label)
512
+ return unless @is_profiling
513
+ timestamp = Timestamp.new(
514
+ label: label,
515
+ time_ms: (Time.now.to_f * 1000) - @start_profile_time
516
+ )
517
+ @timestamps << timestamp
322
518
  end
323
519
 
324
520
  def word_histogram
325
- @profiling_state.word_histogram
521
+ items = []
522
+ @word_counts.each do |name, count|
523
+ items << { word: name, count: count }
524
+ end
525
+ items.sort_by { |item| -item[:count] }
326
526
  end
327
527
 
328
528
  def profile_timestamps
329
- @profiling_state.timestamps
529
+ @timestamps
330
530
  end
331
531
 
332
- # @param [Token] token
333
- def handle_token(token)
334
- return if token.type == TokenType::EOS
532
+ # ======================
533
+ # Handle tokens
335
534
 
336
- handler = TOKEN_HANDLERS[token.type]
337
- if handler
338
- send(handler, token)
535
+ def handle_token(token)
536
+ case token.type
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
339
566
  else
340
- raise ForthicError.new(ErrorCodes::UNKNOWN_TOKEN, "Unknown token type '#{token.string}'", "This token type is not recognized. Check for typos or unsupported syntax.", token.location)
567
+ raise UnknownTokenError.new(
568
+ get_top_input_string,
569
+ token.string,
570
+ location: @string_location
571
+ )
341
572
  end
342
573
  end
343
574
 
344
- # @param [Token] token
345
575
  def handle_string_token(token)
346
576
  value = PositionedString.new(token.string, token.location)
347
577
  handle_word(PushValueWord.new("<string>", value))
348
578
  end
349
579
 
350
- # @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
351
586
  def handle_start_module_token(token)
352
587
  word = StartModuleWord.new(token.string)
353
- @execution_state.cur_definition.add_word(word) if @execution_state.is_compiling
354
- count_word(word)
588
+
589
+ @cur_definition.add_word(word) if @is_compiling
590
+ count_word(word) # For profiling
355
591
  word.execute(self)
356
592
  end
357
593
 
358
- # @param [Token] _token
359
594
  def handle_end_module_token(_token)
360
595
  word = EndModuleWord.new
361
- @execution_state.cur_definition.add_word(word) if @execution_state.is_compiling
596
+
597
+ @cur_definition.add_word(word) if @is_compiling
362
598
  count_word(word)
363
599
  word.execute(self)
364
600
  end
365
601
 
366
- # @param [Token] token
367
602
  def handle_start_array_token(token)
368
603
  handle_word(PushValueWord.new("<start_array_token>", token))
369
604
  end
370
605
 
371
- # @param [Token] _token
372
606
  def handle_end_array_token(_token)
373
607
  handle_word(EndArrayWord.new)
374
608
  end
375
609
 
376
- # @param [Token] _token
377
610
  def handle_comment_token(_token)
378
- # Handle comment token (no-op)
611
+ # Comment handling (currently no-op)
379
612
  end
380
613
 
381
- # @param [Token] token
382
614
  def handle_start_definition_token(token)
383
- raise ForthicError.new(ErrorCodes::NESTED_DEFINITION, "Nested definition not allowed", "A definition was started while another definition is active. Ensure all definitions end with semicolons.", token.location) if @execution_state.is_compiling
384
- @execution_state.cur_definition = DefinitionWord.new(token.string)
385
- @execution_state.is_compiling = true
386
- @execution_state.is_memo_definition = false
615
+ if @is_compiling
616
+ raise MissingSemicolonError.new(
617
+ get_top_input_string,
618
+ location: @previous_token&.location
619
+ )
620
+ end
621
+ @cur_definition = DefinitionWord.new(token.string)
622
+ @is_compiling = true
623
+ @is_memo_definition = false
387
624
  end
388
625
 
389
- # @param [Token] token
390
626
  def handle_start_memo_token(token)
391
- raise ForthicError.new(ErrorCodes::NESTED_MEMO_DEFINITION, "Nested memo definition not allowed", "A memo definition was started while another definition is active. Ensure all definitions end with semicolons.", token.location) if @execution_state.is_compiling
392
- @execution_state.cur_definition = DefinitionWord.new(token.string)
393
- @execution_state.is_compiling = true
394
- @execution_state.is_memo_definition = true
627
+ if @is_compiling
628
+ raise MissingSemicolonError.new(
629
+ get_top_input_string,
630
+ location: @previous_token&.location
631
+ )
632
+ end
633
+ @cur_definition = DefinitionWord.new(token.string)
634
+ @is_compiling = true
635
+ @is_memo_definition = true
395
636
  end
396
637
 
397
- # @param [Token] token
398
638
  def handle_end_definition_token(token)
399
- raise ForthicError.new(ErrorCodes::DEFINITION_WITHOUT_START, "Definition ended without start", "A definition was ended when none was active. Check for extra semicolons.", token.location) unless @execution_state.is_compiling
400
- raise ForthicError.new(ErrorCodes::MISSING_DEFINITION, "No current definition to end", "Internal error: definition state is inconsistent.", token.location) unless @execution_state.cur_definition
401
- if @execution_state.is_memo_definition
402
- cur_module.add_memo_words(@execution_state.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
+
646
+ if @is_memo_definition
647
+ cur_module.add_memo_words(@cur_definition)
403
648
  else
404
- cur_module.add_word(@execution_state.cur_definition)
649
+ cur_module.add_word(@cur_definition)
405
650
  end
406
- @execution_state.is_compiling = false
651
+ @is_compiling = false
407
652
  end
408
653
 
409
- # @param [Token] token
410
654
  def handle_word_token(token)
411
- word = find_word(token.string)
412
- raise ForthicError.new(ErrorCodes::WORD_NOT_FOUND, "Word '#{token.string}' not found", "Check for typos in the word name or ensure the word has been defined.", token.location) unless word
655
+ word = find_word(token.string) # Throws UnknownWordError if not found
413
656
  handle_word(word, token.location)
414
657
  end
415
658
 
416
- # @param [Word] word
417
- # @param [CodeLocation, nil] location
418
659
  def handle_word(word, location = nil)
419
- if @execution_state.is_compiling
660
+ if @is_compiling
420
661
  word.set_location(location)
421
- @execution_state.cur_definition.add_word(word)
662
+ @cur_definition.add_word(word)
422
663
  else
423
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
+
424
672
  word.execute(self)
425
673
  end
426
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
427
876
  end
428
877
  end