forthic 0.2.4 → 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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +311 -21
  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 +677 -318
  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 -24
  34. data/protos/README.md +43 -0
  35. data/protos/v1/forthic_runtime.proto +200 -0
  36. metadata +73 -46
  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 -35
  42. data/lib/forthic/errors/unknown_word_error.rb +0 -39
  43. data/lib/forthic/forthic_error.rb +0 -65
  44. data/lib/forthic/forthic_module.rb +0 -146
  45. data/lib/forthic/global_module.rb +0 -2328
  46. data/lib/forthic/positioned_string.rb +0 -19
  47. data/lib/forthic/token.rb +0 -37
  48. data/lib/forthic/variable.rb +0 -34
  49. data/lib/forthic/version.rb +0 -5
  50. data/lib/forthic/words/definition_word.rb +0 -38
  51. data/lib/forthic/words/end_array_word.rb +0 -28
  52. data/lib/forthic/words/end_module_word.rb +0 -16
  53. data/lib/forthic/words/imported_word.rb +0 -27
  54. data/lib/forthic/words/map_word.rb +0 -169
  55. data/lib/forthic/words/module_memo_bang_at_word.rb +0 -22
  56. data/lib/forthic/words/module_memo_bang_word.rb +0 -21
  57. data/lib/forthic/words/module_memo_word.rb +0 -35
  58. data/lib/forthic/words/module_word.rb +0 -21
  59. data/lib/forthic/words/push_value_word.rb +0 -21
  60. data/lib/forthic/words/start_module_word.rb +0 -31
  61. data/lib/forthic/words/word.rb +0 -30
  62. data/sig/forthic.rbs +0 -4
@@ -1,518 +1,877 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "forthic_error"
4
- require_relative "errors/unknown_word_error"
5
- require_relative "tokenizer"
6
- require_relative "token"
7
- require_relative "code_location"
8
- require_relative "positioned_string"
9
- require_relative "words/word"
10
- require_relative "words/push_value_word"
11
- require_relative "words/start_module_word"
12
- require_relative "words/end_module_word"
13
- require_relative "words/end_array_word"
14
- require_relative "words/definition_word"
15
- require_relative "global_module"
3
+ require_relative 'tokenizer'
4
+ require_relative 'module'
5
+ require_relative 'errors'
6
+ require_relative 'literals'
7
+ require 'time'
16
8
 
17
9
  module Forthic
18
- # Error codes used throughout the interpreter
19
- module ErrorCodes
20
- SCREEN_NOT_FOUND = "screen-not-found"
21
- EXECUTION_ERROR = "execution-error"
22
- MODULE_NOT_FOUND = "module-not-found"
23
- STACK_UNDERFLOW = "stack-underflow"
24
- MODULE_EXECUTION_ERROR = "module-execution-error"
25
- UNKNOWN_TOKEN = "unknown-token"
26
- NESTED_DEFINITION = "nested-definition"
27
- NESTED_MEMO_DEFINITION = "nested-memo-definition"
28
- DEFINITION_WITHOUT_START = "definition-without-start"
29
- MISSING_DEFINITION = "missing-definition"
30
- 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
31
39
  end
32
40
 
33
- # Manages execution state for the interpreter
34
- class ExecutionState
35
- attr_accessor :stack, :module_stack, :is_compiling, :should_stop,
36
- :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
37
48
 
38
- def initialize(app_module)
39
- @stack = []
40
- @module_stack = [app_module]
41
- @is_compiling = false
42
- @should_stop = false
43
- @is_memo_definition = false
44
- @cur_definition = nil
45
- @string_location = nil
49
+ def execute(interp)
50
+ interp.module_stack_pop
46
51
  end
52
+ end
47
53
 
48
- def reset(app_module)
49
- @stack = []
50
- @module_stack = [app_module]
51
- @is_compiling = false
52
- @is_memo_definition = false
53
- @cur_definition = nil
54
- @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)
55
74
  end
56
75
  end
57
76
 
58
- # Manages profiling state and operations
59
- class ProfilingState
60
- 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
61
86
 
62
- def initialize
63
- @word_counts = {}
64
- @is_profiling = false
65
- @start_profile_time = nil
66
- @timestamps = []
87
+ # Array-like access methods
88
+ def [](index)
89
+ @items[index]
67
90
  end
68
91
 
69
- def start_profiling
70
- @is_profiling = true
71
- @timestamps = []
72
- @start_profile_time = Time.now
73
- add_timestamp("START")
74
- @word_counts = {}
92
+ def []=(index, value)
93
+ @items[index] = value
75
94
  end
76
95
 
77
- def stop_profiling
78
- add_timestamp("END")
79
- @is_profiling = false
96
+ def length
97
+ @items.length
80
98
  end
81
99
 
82
- def count_word(word)
83
- return unless @is_profiling
84
- @word_counts[word.name] ||= 0
85
- @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
86
110
  end
87
111
 
88
- def add_timestamp(label)
89
- return unless @is_profiling
90
- timestamp = {label: label, time_ms: (Time.now - @start_profile_time) * 1000}
91
- @timestamps.push(timestamp)
112
+ def get_raw_items
113
+ @items
92
114
  end
93
115
 
94
- def word_histogram
95
- @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)
96
135
  end
97
136
  end
98
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.
99
153
  class Interpreter
100
- # Core interpreter components
101
- attr_reader :global_module, :app_module, :registered_modules
102
- # Screen and module management
103
- attr_accessor :screens, :default_module_flags, :module_flags
104
- # State objects
105
- attr_reader :execution_state, :profiling_state
106
-
107
- # Token handler lookup table
108
- TOKEN_HANDLERS = {
109
- TokenType::STRING => :handle_string_token,
110
- TokenType::COMMENT => :handle_comment_token,
111
- TokenType::START_ARRAY => :handle_start_array_token,
112
- TokenType::END_ARRAY => :handle_end_array_token,
113
- TokenType::START_MODULE => :handle_start_module_token,
114
- TokenType::END_MODULE => :handle_end_module_token,
115
- TokenType::START_DEF => :handle_start_definition_token,
116
- TokenType::START_MEMO => :handle_start_memo_token,
117
- TokenType::END_DEF => :handle_end_definition_token,
118
- TokenType::WORD => :handle_word_token
119
- }.freeze
154
+ attr_reader :timezone, :stack
155
+ attr_accessor :handle_error, :max_attempts, :on_word_execute
120
156
 
121
- 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]
122
168
  @registered_modules = {}
123
- @screens = {}
124
- @default_module_flags = {}
125
- @module_flags = {}
169
+ @is_compiling = false
170
+ @is_memo_definition = false
171
+ @cur_definition = nil
126
172
 
127
- @global_module = GlobalModule.new(self)
128
- @app_module = ForthicModule.new("", self)
173
+ # Debug support
174
+ @string_location = nil
175
+ @previous_token = nil
129
176
 
130
- @execution_state = ExecutionState.new(@app_module)
131
- @profiling_state = ProfilingState.new
132
- end
177
+ # Profiling support
178
+ @word_counts = {}
179
+ @is_profiling = false
180
+ @start_profile_time = nil
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
133
192
 
134
- def halt
135
- @execution_state.should_stop = true
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)
136
199
  end
137
200
 
138
- # @return [ForthicModule]
139
- def get_app_module
140
- @app_module
201
+ def get_timezone
202
+ @timezone
141
203
  end
142
204
 
143
- # @return [CodeLocation, nil]
144
- def get_string_location
145
- @execution_state.string_location
205
+ def set_timezone(timezone)
206
+ @timezone = timezone
146
207
  end
147
208
 
148
- # Delegation methods for execution state
149
- def stack
150
- @execution_state.stack
209
+ def get_app_module
210
+ @app_module
151
211
  end
152
212
 
153
- def stack=(new_stack)
154
- @execution_state.stack = new_stack
213
+ def get_top_input_string
214
+ return "" if @tokenizer_stack.empty?
215
+ @tokenizer_stack[0].get_input_string
155
216
  end
156
217
 
157
- def module_stack
158
- @execution_state.module_stack
218
+ def get_tokenizer
219
+ @tokenizer_stack.last
159
220
  end
160
221
 
161
- def is_compiling
162
- @execution_state.is_compiling
222
+ def get_string_location
223
+ @string_location
163
224
  end
164
225
 
165
- def cur_definition
166
- @execution_state.cur_definition
226
+ def set_max_attempts(max_attempts)
227
+ @max_attempts = max_attempts
167
228
  end
168
229
 
169
- # @param [String] module_id
170
- # @param [Hash] flags
171
- def set_flags(module_id, flags)
172
- @default_module_flags[module_id] = flags
173
- @module_flags[module_id] = flags
230
+ def set_error_handler(handle_error)
231
+ @handle_error = handle_error
174
232
  end
175
233
 
176
- # @param [String] module_id
177
- # @return [Hash]
178
- def get_flags(module_id)
179
- module_flags = @module_flags[module_id] || {}
180
- result = module_flags.dup
181
- @module_flags[module_id] = @default_module_flags[module_id].dup
182
- result
234
+ def get_max_attempts
235
+ @max_attempts
183
236
  end
184
237
 
185
- # @param [String] module_id
186
- # @param [Hash] flags
187
- def modify_flags(module_id, flags)
188
- module_flags = @module_flags[module_id] || {}
189
- @module_flags[module_id] = module_flags.merge(flags)
238
+ def get_error_handler
239
+ @handle_error
190
240
  end
191
241
 
192
242
  def reset
243
+ @stack = Stack.new
193
244
  @app_module.variables = {}
194
- @execution_state.reset(@app_module)
195
- end
196
245
 
197
- # @param [String] screen_name
198
- # @return [String]
199
- def get_screen_forthic(screen_name)
200
- screen = @screens[screen_name]
201
- 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
202
- 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
203
253
  end
204
254
 
205
- # @param [String] string
206
- # @param [CodeLocation, nil] reference_location
207
- # @return [Boolean]
208
255
  def run(string, reference_location = nil)
209
- tokenizer = Tokenizer.new(string, reference_location)
210
- 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)
211
287
  end
212
288
 
213
- # @param [Tokenizer] tokenizer
214
- # @return [Boolean]
215
289
  def run_with_tokenizer(tokenizer)
216
290
  token = nil
217
291
  loop do
292
+ @previous_token = token
218
293
  token = tokenizer.next_token
219
294
  handle_token(token)
220
- break if token.type == TokenType::EOS || @execution_state.should_stop
221
- next if [TokenType::START_DEF, TokenType::END_DEF, TokenType::COMMENT].include?(token.type) || @execution_state.is_compiling
295
+ break if token.type == TokenType::EOS
222
296
  end
223
- true
224
- rescue ForthicError => e
225
- # Re-raise ForthicError and its subclasses without wrapping
226
- raise e
227
- rescue => e
228
- # Wrap non-ForthicError exceptions
229
- 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)
230
- error.set_caught_error(e)
231
- raise error
297
+ true # Done executing
232
298
  end
233
299
 
234
- # @return [ForthicModule]
235
300
  def cur_module
236
- @execution_state.module_stack.last
301
+ @module_stack.last
237
302
  end
238
303
 
239
- # @param [String] name
240
- # @return [ForthicModule]
241
304
  def find_module(name)
242
- raise ArgumentError, "Module name cannot be nil" if name.nil?
243
- raise ArgumentError, "Module name cannot be empty" if name.empty?
244
-
245
305
  result = @registered_modules[name]
246
- 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)
247
320
  result
248
321
  end
249
322
 
250
- # @param [Object] val
251
323
  def stack_push(val)
252
- @execution_state.stack.push(val)
324
+ @stack.push(val)
253
325
  end
254
326
 
255
- # @return [Object]
256
327
  def stack_pop
257
- 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?
258
- result = @execution_state.stack.pop
259
- @execution_state.string_location = result.is_a?(PositionedString) ? result.location : nil
260
- 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
261
353
  end
262
354
 
263
- # @param [ForthicModule] mod
264
355
  def module_stack_push(mod)
265
- raise ArgumentError, "Module cannot be nil" if mod.nil?
266
- @execution_state.module_stack.push(mod)
356
+ @module_stack.push(mod)
267
357
  end
268
358
 
269
359
  def module_stack_pop
270
- @execution_state.module_stack.pop
360
+ @module_stack.pop
271
361
  end
272
362
 
273
- # @param [ForthicModule] mod
274
363
  def register_module(mod)
275
- raise ArgumentError, "Module cannot be nil" if mod.nil?
276
- raise ArgumentError, "Module must respond to :name" unless mod.respond_to?(:name)
277
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
278
382
  end
279
383
 
280
- # @param [ForthicModule] mod
281
- # @param [String] prefix
384
+ # A convenience method to register and use a module
282
385
  def import_module(mod, prefix = "")
283
- raise ArgumentError, "Module cannot be nil" if mod.nil?
284
386
  register_module(mod)
285
- @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)
286
403
  end
287
404
 
288
- # @param [ForthicModule] mod
289
405
  def run_module_code(mod)
290
- raise ArgumentError, "Module cannot be nil" if mod.nil?
291
406
  module_stack_push(mod)
292
- 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
+
293
421
  module_stack_pop
294
- rescue => e
295
- 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.")
296
- error.set_caught_error(e)
297
- raise error
298
422
  end
299
423
 
300
- # @param [String] name
301
- # @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
+
302
464
  def find_word(name)
465
+ # 1. Check module stack (dictionary words + variables)
303
466
  result = nil
304
- @execution_state.module_stack.reverse_each do |m|
467
+ (@module_stack.length - 1).downto(0) do |i|
468
+ m = @module_stack[i]
305
469
  result = m.find_word(name)
306
470
  break if result
307
471
  end
308
- 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
+
309
485
  result
310
486
  end
311
487
 
312
- # Delegation methods for profiling
488
+ # ======================
489
+ # Profiling
490
+
313
491
  def start_profiling
314
- @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 = {}
315
497
  end
316
498
 
317
- def stop_profiling
318
- @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
319
504
  end
320
505
 
321
- def count_word(word)
322
- @profiling_state.count_word(word)
506
+ def stop_profiling
507
+ add_timestamp("END")
508
+ @is_profiling = false
323
509
  end
324
510
 
325
511
  def add_timestamp(label)
326
- @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
327
518
  end
328
519
 
329
520
  def word_histogram
330
- @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] }
331
526
  end
332
527
 
333
528
  def profile_timestamps
334
- @profiling_state.timestamps
529
+ @timestamps
335
530
  end
336
531
 
337
- # @param [Token] token
338
- def handle_token(token)
339
- return if token.type == TokenType::EOS
532
+ # ======================
533
+ # Handle tokens
340
534
 
341
- handler = TOKEN_HANDLERS[token.type]
342
- if handler
343
- 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
344
566
  else
345
- 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
+ )
346
572
  end
347
573
  end
348
574
 
349
- # @param [Token] token
350
575
  def handle_string_token(token)
351
576
  value = PositionedString.new(token.string, token.location)
352
577
  handle_word(PushValueWord.new("<string>", value))
353
578
  end
354
579
 
355
- # @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
356
586
  def handle_start_module_token(token)
357
587
  word = StartModuleWord.new(token.string)
358
- @execution_state.cur_definition.add_word(word) if @execution_state.is_compiling
359
- count_word(word)
588
+
589
+ @cur_definition.add_word(word) if @is_compiling
590
+ count_word(word) # For profiling
360
591
  word.execute(self)
361
592
  end
362
593
 
363
- # @param [Token] _token
364
594
  def handle_end_module_token(_token)
365
595
  word = EndModuleWord.new
366
- @execution_state.cur_definition.add_word(word) if @execution_state.is_compiling
596
+
597
+ @cur_definition.add_word(word) if @is_compiling
367
598
  count_word(word)
368
599
  word.execute(self)
369
600
  end
370
601
 
371
- # @param [Token] token
372
602
  def handle_start_array_token(token)
373
603
  handle_word(PushValueWord.new("<start_array_token>", token))
374
604
  end
375
605
 
376
- # @param [Token] _token
377
606
  def handle_end_array_token(_token)
378
607
  handle_word(EndArrayWord.new)
379
608
  end
380
609
 
381
- # @param [Token] _token
382
610
  def handle_comment_token(_token)
383
- # Handle comment token (no-op)
611
+ # Comment handling (currently no-op)
384
612
  end
385
613
 
386
- # @param [Token] token
387
614
  def handle_start_definition_token(token)
388
- 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
389
- @execution_state.cur_definition = DefinitionWord.new(token.string)
390
- @execution_state.is_compiling = true
391
- @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
392
624
  end
393
625
 
394
- # @param [Token] token
395
626
  def handle_start_memo_token(token)
396
- 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
397
- @execution_state.cur_definition = DefinitionWord.new(token.string)
398
- @execution_state.is_compiling = true
399
- @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
400
636
  end
401
637
 
402
- # @param [Token] token
403
638
  def handle_end_definition_token(token)
404
- 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
405
- raise ForthicError.new(ErrorCodes::MISSING_DEFINITION, "No current definition to end", "Internal error: definition state is inconsistent.", token.location) unless @execution_state.cur_definition
406
- if @execution_state.is_memo_definition
407
- 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)
408
648
  else
409
- cur_module.add_word(@execution_state.cur_definition)
649
+ cur_module.add_word(@cur_definition)
410
650
  end
411
- @execution_state.is_compiling = false
651
+ @is_compiling = false
412
652
  end
413
653
 
414
- # @param [Token] token
415
654
  def handle_word_token(token)
416
- word = find_word(token.string)
417
- unless word
418
- # Generate suggested words for better error messages
419
- suggested_words = find_similar_words(token.string)
420
- raise Errors::UnknownWordError.new(token.string, token.location, suggested_words)
421
- end
655
+ word = find_word(token.string) # Throws UnknownWordError if not found
422
656
  handle_word(word, token.location)
423
657
  end
424
658
 
425
- # @param [Word] word
426
- # @param [CodeLocation, nil] location
427
659
  def handle_word(word, location = nil)
428
- if @execution_state.is_compiling
660
+ if @is_compiling
429
661
  word.set_location(location)
430
- @execution_state.cur_definition.add_word(word)
662
+ @cur_definition.add_word(word)
431
663
  else
432
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
+
433
672
  word.execute(self)
434
673
  end
435
674
  end
436
675
 
437
- private
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
438
681
 
439
- # Find words similar to the given word name for error suggestions
440
- # @param [String] word_name The unknown word to find suggestions for
441
- # @return [Array<String>] Array of similar word names
442
- def find_similar_words(word_name)
443
- return [] if word_name.nil? || word_name.empty?
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
444
687
 
445
- all_words = collect_available_words
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
446
694
 
447
- # Find words with similar names using simple string distance
448
- suggestions = all_words.select do |available_word|
449
- levenshtein_distance(word_name.downcase, available_word.downcase) <= 2
450
- end
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
451
701
 
452
- # If no close matches, try prefix matching
453
- if suggestions.empty?
454
- suggestions = all_words.select do |available_word|
455
- available_word.downcase.start_with?(word_name[0, 2].downcase) ||
456
- word_name.downcase.start_with?(available_word[0, 2].downcase)
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
457
714
  end
715
+
716
+ tokens << token
458
717
  end
459
718
 
460
- suggestions.take(3)
461
- end
719
+ delta = eos_found ? nil : tokenizer.get_string_delta
462
720
 
463
- # Collect all available word names from all modules in scope
464
- # @return [Array<String>] Array of all available word names
465
- def collect_available_words
466
- words = []
721
+ new_stop = find_last_word_or_eos(tokens)
467
722
 
468
- # Collect from global module (hardcoded common words since GlobalModule uses methods)
469
- global_words = %w[
470
- POP DUP SWAP >STR CONCAT SPLIT JOIN /N /R /T LOWERCASE UPPERCASE
471
- APPEND REVERSE UNIQUE MAP FOREACH KEYS VALUES LENGTH RANGE SLICE
472
- SELECT TAKE DROP NTH LAST FLATTEN REDUCE + - * / MOD
473
- == != > >= < <= OR AND NOT IN BOOL INT FLOAT
474
- VARIABLES ! @ !@ INTERPRET EXPORT USE-MODULES REC
475
- ]
476
- words.concat(global_words)
723
+ new_stop -= 1 if eos_found && !done
724
+ new_stop += 1 if !eos_found && !done
477
725
 
478
- # Collect from module stack
479
- @execution_state.module_stack.each do |mod|
480
- if mod.respond_to?(:words) && mod.words
481
- words.concat(mod.words.map(&:name))
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
482
735
  end
736
+ @previous_token = token
483
737
  end
484
738
 
485
- words.uniq.compact
486
- end
487
-
488
- # Calculate Levenshtein distance between two strings
489
- # @param [String] str1
490
- # @param [String] str2
491
- # @return [Integer] The edit distance between the strings
492
- def levenshtein_distance(str1, str2)
493
- return str2.length if str1.empty?
494
- return str1.length if str2.empty?
495
-
496
- # Create matrix
497
- matrix = Array.new(str1.length + 1) { Array.new(str2.length + 1, 0) }
498
-
499
- # Initialize first row and column
500
- (0..str1.length).each { |i| matrix[i][0] = i }
501
- (0..str2.length).each { |j| matrix[0][j] = j }
502
-
503
- # Fill matrix
504
- (1..str1.length).each do |i|
505
- (1..str2.length).each do |j|
506
- cost = (str1[i - 1] == str2[j - 1]) ? 0 : 1
507
- matrix[i][j] = [
508
- matrix[i - 1][j] + 1, # deletion
509
- matrix[i][j - 1] + 1, # insertion
510
- matrix[i - 1][j - 1] + cost # substitution
511
- ].min
512
- end
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
513
753
  end
514
754
 
515
- matrix[str1.length][str2.length]
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
516
875
  end
517
876
  end
518
877
  end