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
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Forthic
6
+ # Parse a datetime string and create a Time object in the specified timezone
7
+ #
8
+ # Expected format: "YYYY-MM-DDTHH:MM:SS"
9
+ # Example: "2025-05-24T10:15:30"
10
+ #
11
+ # @param date_string [String] The datetime string to parse
12
+ # @param timezone [String] The timezone identifier (e.g., "UTC", "America/New_York")
13
+ # @return [Time, nil] The parsed Time object with timezone, or nil if parsing fails
14
+ def self.parse_zoned_datetime(date_string, timezone)
15
+ # Parse the date string and create a Time in the specified timezone
16
+ year = date_string[0, 4].to_i
17
+ month = date_string[5, 2].to_i
18
+ day = date_string[8, 2].to_i
19
+ hour = date_string[11, 2].to_i
20
+ minute = date_string[14, 2].to_i
21
+ second = date_string[17, 2].to_i
22
+
23
+ # Create Time object in the specified timezone using ENV['TZ']
24
+ old_tz = ENV['TZ']
25
+ begin
26
+ ENV['TZ'] = timezone
27
+ Time.new(year, month, day, hour, minute, second)
28
+ ensure
29
+ ENV['TZ'] = old_tz
30
+ end
31
+ rescue StandardError
32
+ # Return nil if parsing fails
33
+ nil
34
+ end
35
+ end
@@ -0,0 +1,548 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'serializer'
4
+ require_relative '../interpreter'
5
+
6
+ module Forthic
7
+ module WebSocket
8
+ # ForthicWebSocketHandler - WebSocket message handler for Forthic runtime
9
+ #
10
+ # Mirrors the gRPC server functionality but uses JSON messages over WebSocket.
11
+ # Designed to work with Rails ActionCable for browser-server communication.
12
+ #
13
+ # Features:
14
+ # - Execute individual words with stack state
15
+ # - Execute sequences of words (batched optimization)
16
+ # - List available runtime-specific modules
17
+ # - Get detailed module information with word metadata
18
+ # - Streaming execution with progress updates (NEW for WebSocket)
19
+ # - Rich error handling with stack traces and context
20
+ #
21
+ # Message protocol defined in BROWSER_SERVER_WEBSOCKET_PLAN.md
22
+ class Handler
23
+ # Standard library modules (available in all runtimes)
24
+ # These are excluded from list_modules as they're not runtime-specific
25
+ STANDARD_MODULES = %w[
26
+ array record string math datetime json boolean core
27
+ ].freeze
28
+
29
+ attr_reader :runtime_modules, :timezone
30
+
31
+ # Initialize handler with optional configuration
32
+ #
33
+ # @param timezone [String] Timezone for the interpreter (default: "UTC")
34
+ # @param modules_config [Hash, nil] Optional modules configuration
35
+ def initialize(timezone: "UTC", modules_config: nil)
36
+ @timezone = timezone
37
+ @runtime_modules = {}
38
+
39
+ # TODO: Implement module loading from config when needed
40
+ # For now, we only have stdlib modules in Ruby
41
+ if modules_config
42
+ puts "[WEBSOCKET_HANDLER] Module config support not yet implemented"
43
+ end
44
+ end
45
+
46
+ # Handle incoming WebSocket message
47
+ #
48
+ # Routes messages to appropriate handler based on message type.
49
+ #
50
+ # @param message [Hash] Parsed JSON message from client
51
+ # @return [Hash, Array<Hash>] Response message(s) to send back
52
+ def handle_message(message)
53
+ message_type = message['type']
54
+ message_id = message['id']
55
+
56
+ puts "[WEBSOCKET] Received message: type=#{message_type} id=#{message_id}"
57
+
58
+ case message_type
59
+ when 'execute_word'
60
+ execute_word(message)
61
+ when 'execute_sequence'
62
+ execute_sequence(message)
63
+ when 'list_modules'
64
+ list_modules(message)
65
+ when 'get_module_info'
66
+ get_module_info(message)
67
+ when 'streaming_execute'
68
+ streaming_execute(message)
69
+ else
70
+ error_response(message_id, "Unknown message type: #{message_type}")
71
+ end
72
+ rescue => e
73
+ puts "[WEBSOCKET] ERROR handling message: #{e.message}"
74
+ puts e.backtrace[0..5].join("\n")
75
+ error_response(message['id'], "Handler error: #{e.message}")
76
+ end
77
+
78
+ # Execute a single word in the Ruby runtime
79
+ #
80
+ # Message format:
81
+ # {
82
+ # "type": "execute_word",
83
+ # "id": "uuid",
84
+ # "params": {
85
+ # "word": "MY-WORD",
86
+ # "stack": [{"type": "int", "value": 42}, ...]
87
+ # }
88
+ # }
89
+ #
90
+ # @param message [Hash] The execution request message
91
+ # @return [Hash] The execution response message
92
+ def execute_word(message)
93
+ params = message['params']
94
+ word_name = params['word']
95
+ stack_json = params['stack'] || []
96
+
97
+ puts "[EXECUTE_WORD] word='#{word_name}' stack_size=#{stack_json.length}"
98
+
99
+ # Deserialize the stack
100
+ stack = Serializer.deserialize_stack(stack_json)
101
+ puts "[EXECUTE_WORD] Deserialized stack: #{stack.map { |x| x.class.name }}"
102
+
103
+ # Execute word with stack-based execution
104
+ result_stack = execute_with_stack(word_name, stack)
105
+ puts "[EXECUTE_WORD] Result stack: #{result_stack.map { |x| x.class.name }}"
106
+
107
+ # Serialize result stack
108
+ response_stack = Serializer.serialize_stack(result_stack)
109
+ puts "[EXECUTE_WORD] Success"
110
+
111
+ {
112
+ 'type' => 'execute_word_response',
113
+ 'id' => message['id'],
114
+ 'result' => {
115
+ 'stack' => response_stack
116
+ }
117
+ }
118
+ rescue => e
119
+ puts "[EXECUTE_WORD] ERROR: #{e.message}"
120
+ puts "[EXECUTE_WORD] Backtrace:"
121
+ puts e.backtrace[0..5].join("\n")
122
+
123
+ build_error_response(message['id'], 'execute_word_response', e, word_name)
124
+ end
125
+
126
+ # Execute a sequence of words in one batch (optimization for remote execution)
127
+ #
128
+ # Message format:
129
+ # {
130
+ # "type": "execute_sequence",
131
+ # "id": "uuid",
132
+ # "params": {
133
+ # "words": ["WORD1", "WORD2", "WORD3"],
134
+ # "stack": [...]
135
+ # }
136
+ # }
137
+ #
138
+ # @param message [Hash] The execution request message
139
+ # @return [Hash] The execution response message
140
+ def execute_sequence(message)
141
+ params = message['params']
142
+ word_names = params['words'] || []
143
+ stack_json = params['stack'] || []
144
+
145
+ puts "[EXECUTE_SEQUENCE] words=#{word_names} stack_size=#{stack_json.length}"
146
+
147
+ # Deserialize the initial stack
148
+ stack = Serializer.deserialize_stack(stack_json)
149
+ puts "[EXECUTE_SEQUENCE] Deserialized stack: #{stack.map { |x| x.class.name }}"
150
+
151
+ # Execute the word sequence
152
+ result_stack = execute_sequence_with_stack(word_names, stack)
153
+ puts "[EXECUTE_SEQUENCE] Result stack: #{result_stack.map { |x| x.class.name }}"
154
+
155
+ # Serialize result stack
156
+ response_stack = Serializer.serialize_stack(result_stack)
157
+ puts "[EXECUTE_SEQUENCE] Success"
158
+
159
+ {
160
+ 'type' => 'execute_sequence_response',
161
+ 'id' => message['id'],
162
+ 'result' => {
163
+ 'stack' => response_stack
164
+ }
165
+ }
166
+ rescue => e
167
+ puts "[EXECUTE_SEQUENCE] ERROR: #{e.message}"
168
+ puts "[EXECUTE_SEQUENCE] Backtrace:"
169
+ puts e.backtrace[0..5].join("\n")
170
+
171
+ build_error_response(
172
+ message['id'],
173
+ 'execute_sequence_response',
174
+ e,
175
+ nil,
176
+ { 'word_sequence' => word_names.join(', ') }
177
+ )
178
+ end
179
+
180
+ # List available runtime-specific modules (excludes standard library)
181
+ #
182
+ # Message format:
183
+ # {
184
+ # "type": "list_modules",
185
+ # "id": "uuid"
186
+ # }
187
+ #
188
+ # @param message [Hash] The request message
189
+ # @return [Hash] List of module summaries
190
+ def list_modules(message)
191
+ modules = []
192
+
193
+ # Only return runtime-specific modules (not standard library)
194
+ @runtime_modules.each do |name, mod|
195
+ # Get module metadata
196
+ word_count = if mod.respond_to?(:get_word_docs)
197
+ mod.get_word_docs.length
198
+ else
199
+ mod.exportable.length
200
+ end
201
+
202
+ summary = {
203
+ 'name' => name,
204
+ 'description' => "Ruby-specific #{name} module",
205
+ 'word_count' => word_count
206
+ }
207
+ modules << summary
208
+ end
209
+
210
+ {
211
+ 'type' => 'list_modules_response',
212
+ 'id' => message['id'],
213
+ 'result' => {
214
+ 'modules' => modules
215
+ }
216
+ }
217
+ rescue => e
218
+ puts "[LIST_MODULES] ERROR: #{e.message}"
219
+ build_error_response(message['id'], 'list_modules_response', e)
220
+ end
221
+
222
+ # Get detailed information about a specific module
223
+ #
224
+ # Message format:
225
+ # {
226
+ # "type": "get_module_info",
227
+ # "id": "uuid",
228
+ # "params": {
229
+ # "module_name": "pandas"
230
+ # }
231
+ # }
232
+ #
233
+ # @param message [Hash] The request message
234
+ # @return [Hash] Module information with word details
235
+ def get_module_info(message)
236
+ params = message['params']
237
+ module_name = params['module_name']
238
+
239
+ unless @runtime_modules.key?(module_name)
240
+ puts "[GET_MODULE_INFO] Module '#{module_name}' not found"
241
+ return {
242
+ 'type' => 'get_module_info_response',
243
+ 'id' => message['id'],
244
+ 'result' => {
245
+ 'module_name' => module_name,
246
+ 'description' => 'Module not found',
247
+ 'words' => []
248
+ }
249
+ }
250
+ end
251
+
252
+ mod = @runtime_modules[module_name]
253
+ words = []
254
+
255
+ # Use DecoratedModule.get_word_docs() if available
256
+ if mod.respond_to?(:get_word_docs)
257
+ # DecoratedModule with word/direct_word decorators
258
+ word_docs = mod.get_word_docs
259
+ word_docs.each do |doc|
260
+ word_info = {
261
+ 'name' => doc[:name],
262
+ 'stack_effect' => doc[:stack_effect],
263
+ 'description' => doc[:description]
264
+ }
265
+ words << word_info
266
+ end
267
+ else
268
+ # Fallback: Extract word information from exportable words
269
+ mod.exportable.each do |word_name|
270
+ word_info = {
271
+ 'name' => word_name,
272
+ 'stack_effect' => '( -- )',
273
+ 'description' => "#{word_name} word from #{module_name} module"
274
+ }
275
+ words << word_info
276
+ end
277
+ end
278
+
279
+ {
280
+ 'type' => 'get_module_info_response',
281
+ 'id' => message['id'],
282
+ 'result' => {
283
+ 'module_name' => module_name,
284
+ 'description' => "Ruby-specific #{module_name} module",
285
+ 'words' => words
286
+ }
287
+ }
288
+ rescue => e
289
+ puts "[GET_MODULE_INFO] ERROR: #{e.message}"
290
+ puts e.backtrace[0..5].join("\n")
291
+ build_error_response(message['id'], 'get_module_info_response', e)
292
+ end
293
+
294
+ # Execute Forthic code with streaming progress updates
295
+ #
296
+ # This is a NEW feature for WebSocket (not available in gRPC).
297
+ # Sends progress updates during execution for long-running operations.
298
+ #
299
+ # Message format:
300
+ # {
301
+ # "type": "streaming_execute",
302
+ # "id": "uuid",
303
+ # "params": {
304
+ # "code": "LOAD-DATA PROCESS ANALYZE SAVE",
305
+ # "stack": [...]
306
+ # }
307
+ # }
308
+ #
309
+ # Returns an array of messages: [progress_updates..., final_response]
310
+ #
311
+ # @param message [Hash] The execution request message
312
+ # @param transmit_callback [Proc, nil] Optional callback for transmitting progress (ActionCable)
313
+ # @return [Array<Hash>] Array of progress updates and final response
314
+ def streaming_execute(message, transmit_callback = nil)
315
+ params = message['params']
316
+ code = params['code']
317
+ stack_json = params['stack'] || []
318
+
319
+ puts "[STREAMING_EXECUTE] code='#{code}' stack_size=#{stack_json.length}"
320
+
321
+ responses = []
322
+
323
+ # Deserialize the initial stack
324
+ stack = Serializer.deserialize_stack(stack_json)
325
+
326
+ # Create interpreter
327
+ interp = StandardInterpreter.new([], @timezone)
328
+
329
+ # Register runtime-specific modules
330
+ @runtime_modules.each do |name, mod|
331
+ interp.register_module(mod)
332
+ end
333
+
334
+ # Import all runtime-specific modules
335
+ unless @runtime_modules.empty?
336
+ module_names = @runtime_modules.keys
337
+ interp.use_modules(module_names)
338
+ end
339
+
340
+ # Push stack items
341
+ stack.each { |item| interp.stack_push(item) }
342
+
343
+ # Set up progress tracking
344
+ interp.setup_progress_tracking(code)
345
+
346
+ # Set up progress callback
347
+ interp.on_word_execute = lambda do |word_name, step, total_steps|
348
+ # Create progress message
349
+ progress_message = {
350
+ 'type' => 'streaming_progress',
351
+ 'id' => message['id'],
352
+ 'progress' => {
353
+ 'step' => step,
354
+ 'total_steps' => total_steps,
355
+ 'current_word' => word_name,
356
+ 'message' => "Executing #{word_name}...",
357
+ 'partial_stack' => Serializer.serialize_stack(interp.get_stack.get_items)
358
+ }
359
+ }
360
+
361
+ puts "[STREAMING_EXECUTE] Progress: #{step}/#{total_steps} - #{word_name}"
362
+
363
+ # If we have a transmit callback (from ActionCable), use it immediately
364
+ # Otherwise, collect responses to return
365
+ if transmit_callback
366
+ transmit_callback.call(progress_message)
367
+ else
368
+ responses << progress_message
369
+ end
370
+ end
371
+
372
+ # Execute the code
373
+ interp.run(code)
374
+
375
+ # Clean up progress tracking
376
+ interp.reset_progress_tracking
377
+
378
+ # Get final stack
379
+ final_stack = interp.get_stack.get_items
380
+ response_stack = Serializer.serialize_stack(final_stack)
381
+
382
+ # Create final response
383
+ final_response = {
384
+ 'type' => 'streaming_execute_response',
385
+ 'id' => message['id'],
386
+ 'result' => {
387
+ 'stack' => response_stack
388
+ }
389
+ }
390
+
391
+ puts "[STREAMING_EXECUTE] Success"
392
+
393
+ # Return all responses (progress updates + final)
394
+ responses << final_response
395
+ responses
396
+ rescue => e
397
+ puts "[STREAMING_EXECUTE] ERROR: #{e.message}"
398
+ puts e.backtrace[0..5].join("\n")
399
+
400
+ # Clean up on error
401
+ interp.reset_progress_tracking if interp
402
+
403
+ error_response = build_error_response(message['id'], 'streaming_execute_response', e)
404
+ [error_response]
405
+ end
406
+
407
+ # Cleanup handler resources
408
+ def cleanup
409
+ # Currently no cleanup needed
410
+ # Future: Could close interpreter, clear modules, etc.
411
+ end
412
+
413
+ private
414
+
415
+ # Execute a word with given stack state
416
+ #
417
+ # @param word_name [String] The word to execute
418
+ # @param stack [Array] The initial stack state
419
+ # @return [Array] The resulting stack
420
+ def execute_with_stack(word_name, stack)
421
+ # Create a fresh interpreter for this execution
422
+ # (to avoid state pollution between requests)
423
+ interp = StandardInterpreter.new([], @timezone)
424
+
425
+ # Register runtime-specific modules in fresh interpreter
426
+ @runtime_modules.each do |name, mod|
427
+ interp.register_module(mod)
428
+ end
429
+
430
+ # Import all runtime-specific modules so their words are available
431
+ unless @runtime_modules.empty?
432
+ module_names = @runtime_modules.keys
433
+ interp.use_modules(module_names)
434
+ end
435
+
436
+ # Push all stack items onto interpreter stack
437
+ stack.each do |item|
438
+ interp.stack_push(item)
439
+ end
440
+
441
+ # Execute the word
442
+ interp.run(word_name)
443
+
444
+ # Get resulting stack as a list
445
+ interp.get_stack.get_items
446
+ end
447
+
448
+ # Execute a sequence of words in one go (batched execution)
449
+ #
450
+ # @param word_names [Array<String>] List of word names to execute in order
451
+ # @param stack [Array] Initial stack state
452
+ # @return [Array] Final stack state after executing all words
453
+ def execute_sequence_with_stack(word_names, stack)
454
+ # Create a fresh interpreter for this execution
455
+ interp = StandardInterpreter.new([], @timezone)
456
+
457
+ # Register runtime-specific modules
458
+ @runtime_modules.each do |name, mod|
459
+ interp.register_module(mod)
460
+ end
461
+
462
+ # Import all runtime-specific modules
463
+ unless @runtime_modules.empty?
464
+ module_names = @runtime_modules.keys
465
+ interp.use_modules(module_names)
466
+ end
467
+
468
+ # Push all stack items onto interpreter stack
469
+ stack.each do |item|
470
+ interp.stack_push(item)
471
+ end
472
+
473
+ # Execute each word in sequence
474
+ word_names.each do |word_name|
475
+ interp.run(word_name)
476
+ end
477
+
478
+ # Get resulting stack as a list
479
+ interp.get_stack.get_items
480
+ end
481
+
482
+ # Build error response message
483
+ #
484
+ # @param message_id [String] The message ID
485
+ # @param response_type [String] The response message type
486
+ # @param exception [Exception] The exception that was raised
487
+ # @param word_name [String, nil] The word that was being executed (optional)
488
+ # @param context [Hash, nil] Additional context information (optional)
489
+ # @return [Hash] Error response message
490
+ def build_error_response(message_id, response_type, exception, word_name = nil, context = nil)
491
+ # Extract stack trace
492
+ stack_trace = exception.backtrace || []
493
+
494
+ # Get error type name
495
+ error_type = exception.class.name
496
+
497
+ # Build context dictionary
498
+ error_context = {}
499
+ error_context['word_name'] = word_name if word_name
500
+ error_context.merge!(context) if context
501
+
502
+ # Try to extract module information from the backtrace
503
+ module_name = nil
504
+ word_location = nil
505
+
506
+ stack_trace.each do |line|
507
+ # Check if this is a Forthic module
508
+ if line.include?('forthic/modules/')
509
+ module_name = line.split('forthic/modules/').last.split('.').first.gsub('_module', '')
510
+ word_location = line
511
+ break
512
+ elsif line.include?('forthic/websocket/')
513
+ word_location = line
514
+ end
515
+ end
516
+
517
+ {
518
+ 'type' => response_type,
519
+ 'id' => message_id,
520
+ 'error' => {
521
+ 'message' => exception.message,
522
+ 'error_type' => error_type,
523
+ 'stack_trace' => stack_trace,
524
+ 'module_name' => module_name,
525
+ 'word_location' => word_location,
526
+ 'context' => error_context
527
+ }.compact # Remove nil values
528
+ }
529
+ end
530
+
531
+ # Build a simple error response for unknown message types
532
+ #
533
+ # @param message_id [String] The message ID
534
+ # @param error_message [String] The error message
535
+ # @return [Hash] Error response message
536
+ def error_response(message_id, error_message)
537
+ {
538
+ 'type' => 'error',
539
+ 'id' => message_id,
540
+ 'error' => {
541
+ 'message' => error_message,
542
+ 'error_type' => 'MessageError'
543
+ }
544
+ }
545
+ end
546
+ end
547
+ end
548
+ end