mlld 2.0.2

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 (4) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +96 -0
  3. data/lib/mlld.rb +722 -0
  4. metadata +45 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7f537533eef4e1fa1124f71eaa8c469622854b8907751e742fb54e5f9c528212
4
+ data.tar.gz: d26e2fa2a32b69a325d859baec356331e01b7550e009b233186fa83bde0c1614
5
+ SHA512:
6
+ metadata.gz: ad5a2a5b02bcca2ed625eb2f69a35a04d5efaa051e43b079cad8cddd69faed5567b3e3063238116fc5e96cd8a4eecfe5d287db3319821c4eb81429a38ee3e95c
7
+ data.tar.gz: 2190023a9b5dbdab5c5752f4abbaec07e55d5fa614efb3182d2046d249ff2cb0023faa63889788277ca35e482b24d5808108d932e4695179ea6989e7b9fbddd7
data/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # mlld Ruby SDK
2
+
3
+ Ruby wrapper for mlld using a persistent NDJSON RPC transport over `mlld live --stdio`.
4
+
5
+ ## Requirements
6
+
7
+ - Ruby 3.0+
8
+ - Node.js runtime
9
+ - `mlld` CLI available by command path
10
+
11
+ ## Installation
12
+
13
+ From this repo checkout:
14
+
15
+ ```bash
16
+ gem build mlld.gemspec
17
+ gem install ./mlld-*.gem
18
+ ```
19
+
20
+ ## Quick Start
21
+
22
+ ```ruby
23
+ require 'mlld'
24
+
25
+ client = Mlld::Client.new
26
+
27
+ # Optional command override (local repo build example)
28
+ # client = Mlld::Client.new(command: 'node', command_args: ['./dist/cli.cjs'])
29
+
30
+ output = client.process('/show "Hello World"')
31
+ puts output
32
+
33
+ result = client.execute(
34
+ './agent.mld',
35
+ { 'text' => 'hello' },
36
+ state: { 'count' => 0 },
37
+ dynamic_modules: {
38
+ '@config' => { 'mode' => 'demo' }
39
+ },
40
+ timeout: 10
41
+ )
42
+ puts result.output
43
+
44
+ analysis = client.analyze('./module.mld')
45
+ p analysis.exports
46
+
47
+ client.close
48
+ ```
49
+
50
+ ## In-Flight State Updates
51
+
52
+ ```ruby
53
+ handle = client.process_async(
54
+ "loop(99999, 50ms) until @state.exit [\n continue\n]\nshow \"done\"",
55
+ state: { 'exit' => false },
56
+ timeout: 10,
57
+ mode: 'strict'
58
+ )
59
+
60
+ sleep 0.1
61
+ handle.update_state('exit', true)
62
+ puts handle.result
63
+ ```
64
+
65
+ ## API
66
+
67
+ ### Client
68
+
69
+ - `Mlld::Client.new(command: 'mlld', command_args: nil, timeout: 30.0, working_dir: nil)`
70
+ - `process(script, file_path: nil, payload: nil, state: nil, dynamic_modules: nil, dynamic_module_source: nil, mode: nil, allow_absolute_paths: nil, timeout: nil)`
71
+ - `process_async(...) -> Mlld::ProcessHandle`
72
+ - `execute(filepath, payload = nil, state: nil, dynamic_modules: nil, dynamic_module_source: nil, allow_absolute_paths: nil, mode: nil, timeout: nil)`
73
+ - `execute_async(...) -> Mlld::ExecuteHandle`
74
+ - `analyze(filepath)`
75
+ - `close`
76
+
77
+ ### Handle Methods
78
+
79
+ - `request_id`
80
+ - `cancel`
81
+ - `update_state(path, value, timeout: nil)`
82
+ - `wait`
83
+ - `result`
84
+
85
+ ### Convenience Functions
86
+
87
+ - `Mlld.process(...)`
88
+ - `Mlld.process_async(...)`
89
+ - `Mlld.execute(...)`
90
+ - `Mlld.execute_async(...)`
91
+ - `Mlld.analyze(...)`
92
+
93
+ ## Notes
94
+
95
+ - Each `Client` keeps one live RPC subprocess for repeated calls.
96
+ - `ExecuteResult.state_writes` merges final-result writes and streamed `state:write` events.
data/lib/mlld.rb ADDED
@@ -0,0 +1,722 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'thread'
6
+ require 'timeout'
7
+
8
+ module Mlld
9
+ class Error < StandardError
10
+ attr_reader :code, :returncode
11
+
12
+ def initialize(message, code: nil, returncode: nil)
13
+ super(message)
14
+ @code = code
15
+ @returncode = returncode
16
+ end
17
+ end
18
+
19
+ StateWrite = Struct.new(:path, :value, :timestamp, keyword_init: true)
20
+ Metrics = Struct.new(:total_ms, :parse_ms, :evaluate_ms, keyword_init: true)
21
+ Effect = Struct.new(:type, :content, :security, keyword_init: true)
22
+ ExecuteResult = Struct.new(:output, :state_writes, :exports, :effects, :metrics, keyword_init: true)
23
+
24
+ Executable = Struct.new(:name, :params, :labels, keyword_init: true)
25
+ Import = Struct.new(:from, :names, keyword_init: true)
26
+ Guard = Struct.new(:name, :timing, :label, keyword_init: true)
27
+ Needs = Struct.new(:cmd, :node, :py, keyword_init: true)
28
+ AnalysisError = Struct.new(:message, :line, :column, keyword_init: true)
29
+ AnalyzeResult = Struct.new(
30
+ :filepath,
31
+ :valid,
32
+ :errors,
33
+ :executables,
34
+ :exports,
35
+ :imports,
36
+ :guards,
37
+ :needs,
38
+ keyword_init: true
39
+ )
40
+
41
+ class BaseHandle
42
+ attr_reader :request_id
43
+
44
+ def initialize(client:, request_id:, response_queue:, timeout:)
45
+ @client = client
46
+ @request_id = request_id
47
+ @response_queue = response_queue
48
+ @timeout = timeout
49
+ @mutex = Mutex.new
50
+ @complete = false
51
+ @raw_result = nil
52
+ @state_write_events = []
53
+ @error = nil
54
+ end
55
+
56
+ def cancel
57
+ @client.send_cancel(@request_id)
58
+ end
59
+
60
+ def update_state(path, value, timeout: nil)
61
+ @client.send_state_update(@request_id, path, value, timeout || @timeout)
62
+ end
63
+
64
+ protected
65
+
66
+ def await_raw
67
+ @mutex.synchronize do
68
+ unless @complete
69
+ begin
70
+ @raw_result, @state_write_events = @client.await_request(
71
+ @request_id,
72
+ @response_queue,
73
+ @timeout
74
+ )
75
+ rescue Error => e
76
+ @error = e
77
+ end
78
+ @complete = true
79
+ end
80
+
81
+ raise @error if @error
82
+ raise Error.new('missing live result payload', code: 'TRANSPORT_ERROR') unless @raw_result
83
+
84
+ [@raw_result, @state_write_events]
85
+ end
86
+ end
87
+ end
88
+
89
+ class ProcessHandle < BaseHandle
90
+ def wait
91
+ result
92
+ end
93
+
94
+ def result
95
+ response, = await_raw
96
+ output = response['output']
97
+ output = response.fetch('value', '') if output.nil?
98
+ output.is_a?(String) ? output : output.to_s
99
+ end
100
+ end
101
+
102
+ class ExecuteHandle < BaseHandle
103
+ def wait
104
+ result
105
+ end
106
+
107
+ def result
108
+ response, state_write_events = await_raw
109
+ @client.decode_execute_result(response, state_write_events)
110
+ end
111
+ end
112
+
113
+ class Client
114
+ attr_accessor :command, :command_args, :timeout, :working_dir
115
+
116
+ def initialize(command: 'mlld', command_args: nil, timeout: 30.0, working_dir: nil)
117
+ @command = command
118
+ @command_args = Array(command_args)
119
+ @timeout = timeout
120
+ @working_dir = working_dir
121
+
122
+ @lock = Mutex.new
123
+ @write_lock = Mutex.new
124
+ @stdin = nil
125
+ @stdout = nil
126
+ @stderr = nil
127
+ @wait_thr = nil
128
+ @reader_thread = nil
129
+ @stderr_thread = nil
130
+ @stderr_lines = []
131
+ @pending = {}
132
+ @request_id = 0
133
+ end
134
+
135
+ def close
136
+ stdin = nil
137
+ stdout = nil
138
+ stderr = nil
139
+ wait_thr = nil
140
+ reader_thread = nil
141
+ stderr_thread = nil
142
+ pending_queues = nil
143
+
144
+ @lock.synchronize do
145
+ stdin = @stdin
146
+ stdout = @stdout
147
+ stderr = @stderr
148
+ wait_thr = @wait_thr
149
+ reader_thread = @reader_thread
150
+ stderr_thread = @stderr_thread
151
+ pending_queues = @pending.values
152
+
153
+ @stdin = nil
154
+ @stdout = nil
155
+ @stderr = nil
156
+ @wait_thr = nil
157
+ @reader_thread = nil
158
+ @stderr_thread = nil
159
+ @pending = {}
160
+ end
161
+
162
+ pending_queues&.each { |queue| queue << [:transport_error, Error.new('live transport closed', code: 'TRANSPORT_ERROR')] }
163
+
164
+ begin
165
+ stdin.close if stdin && !stdin.closed?
166
+ rescue StandardError
167
+ end
168
+
169
+ if wait_thr&.alive?
170
+ begin
171
+ Process.kill('TERM', wait_thr.pid)
172
+ Timeout.timeout(1) { wait_thr.value }
173
+ rescue StandardError
174
+ begin
175
+ Process.kill('KILL', wait_thr.pid)
176
+ rescue StandardError
177
+ end
178
+ begin
179
+ wait_thr.value
180
+ rescue StandardError
181
+ end
182
+ end
183
+ end
184
+
185
+ begin
186
+ stdout.close if stdout && !stdout.closed?
187
+ rescue StandardError
188
+ end
189
+
190
+ begin
191
+ stderr.close if stderr && !stderr.closed?
192
+ rescue StandardError
193
+ end
194
+
195
+ reader_thread&.join(1)
196
+ stderr_thread&.join(1)
197
+ end
198
+
199
+ def process(
200
+ script,
201
+ file_path: nil,
202
+ payload: nil,
203
+ state: nil,
204
+ dynamic_modules: nil,
205
+ dynamic_module_source: nil,
206
+ mode: nil,
207
+ allow_absolute_paths: nil,
208
+ timeout: nil
209
+ )
210
+ process_async(
211
+ script,
212
+ file_path: file_path,
213
+ payload: payload,
214
+ state: state,
215
+ dynamic_modules: dynamic_modules,
216
+ dynamic_module_source: dynamic_module_source,
217
+ mode: mode,
218
+ allow_absolute_paths: allow_absolute_paths,
219
+ timeout: timeout
220
+ ).result
221
+ end
222
+
223
+ def process_async(
224
+ script,
225
+ file_path: nil,
226
+ payload: nil,
227
+ state: nil,
228
+ dynamic_modules: nil,
229
+ dynamic_module_source: nil,
230
+ mode: nil,
231
+ allow_absolute_paths: nil,
232
+ timeout: nil
233
+ )
234
+ params = { 'script' => script }
235
+ params['filePath'] = file_path if file_path
236
+ params['payload'] = payload unless payload.nil?
237
+ params['state'] = state if state
238
+ params['dynamicModules'] = dynamic_modules if dynamic_modules
239
+ params['dynamicModuleSource'] = dynamic_module_source if dynamic_module_source
240
+ params['mode'] = mode if mode
241
+ params['allowAbsolutePaths'] = allow_absolute_paths unless allow_absolute_paths.nil?
242
+
243
+ request_id, response_queue = send_request('process', params)
244
+ ProcessHandle.new(
245
+ client: self,
246
+ request_id: request_id,
247
+ response_queue: response_queue,
248
+ timeout: resolve_timeout(timeout)
249
+ )
250
+ end
251
+
252
+ def execute(
253
+ filepath,
254
+ payload = nil,
255
+ state: nil,
256
+ dynamic_modules: nil,
257
+ dynamic_module_source: nil,
258
+ allow_absolute_paths: nil,
259
+ mode: nil,
260
+ timeout: nil
261
+ )
262
+ execute_async(
263
+ filepath,
264
+ payload,
265
+ state: state,
266
+ dynamic_modules: dynamic_modules,
267
+ dynamic_module_source: dynamic_module_source,
268
+ allow_absolute_paths: allow_absolute_paths,
269
+ mode: mode,
270
+ timeout: timeout
271
+ ).result
272
+ end
273
+
274
+ def execute_async(
275
+ filepath,
276
+ payload = nil,
277
+ state: nil,
278
+ dynamic_modules: nil,
279
+ dynamic_module_source: nil,
280
+ allow_absolute_paths: nil,
281
+ mode: nil,
282
+ timeout: nil
283
+ )
284
+ params = { 'filepath' => filepath }
285
+ params['payload'] = payload unless payload.nil?
286
+ params['state'] = state if state
287
+ params['dynamicModules'] = dynamic_modules if dynamic_modules
288
+ params['dynamicModuleSource'] = dynamic_module_source if dynamic_module_source
289
+ params['allowAbsolutePaths'] = allow_absolute_paths unless allow_absolute_paths.nil?
290
+ params['mode'] = mode if mode
291
+
292
+ request_id, response_queue = send_request('execute', params)
293
+ ExecuteHandle.new(
294
+ client: self,
295
+ request_id: request_id,
296
+ response_queue: response_queue,
297
+ timeout: resolve_timeout(timeout)
298
+ )
299
+ end
300
+
301
+ def analyze(filepath)
302
+ result, = request('analyze', { 'filepath' => filepath }, nil)
303
+ build_analyze_result(result, filepath)
304
+ end
305
+
306
+ def send_cancel(request_id)
307
+ send_control_request({ 'method' => 'cancel', 'id' => request_id })
308
+ rescue Error
309
+ nil
310
+ end
311
+
312
+ def send_state_update(request_id, path, value, timeout)
313
+ unless path.is_a?(String) && !path.strip.empty?
314
+ raise Error.new('state update path is required', code: 'INVALID_REQUEST')
315
+ end
316
+
317
+ resolved_timeout = resolve_timeout(timeout)
318
+ max_wait = resolved_timeout || 2.0
319
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + max_wait
320
+
321
+ loop do
322
+ begin
323
+ request('state:update', {
324
+ 'requestId' => request_id,
325
+ 'path' => path,
326
+ 'value' => value
327
+ }, resolved_timeout)
328
+ return nil
329
+ rescue Error => error
330
+ raise unless error.code == 'REQUEST_NOT_FOUND'
331
+ raise if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
332
+
333
+ sleep(0.025)
334
+ end
335
+ end
336
+ end
337
+
338
+ def await_request(request_id, response_queue, timeout)
339
+ state_write_events = []
340
+ deadline = timeout ? Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout : nil
341
+
342
+ loop do
343
+ remaining = deadline ? deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
344
+ if remaining && remaining <= 0
345
+ remove_pending(request_id)
346
+ send_cancel(request_id)
347
+ raise Error.new("request timeout after #{timeout}s", code: 'TIMEOUT')
348
+ end
349
+
350
+ entry = nil
351
+ if remaining
352
+ begin
353
+ entry = Timeout.timeout(remaining) { response_queue.pop }
354
+ rescue Timeout::Error
355
+ remove_pending(request_id)
356
+ send_cancel(request_id)
357
+ raise Error.new("request timeout after #{timeout}s", code: 'TIMEOUT')
358
+ end
359
+ else
360
+ entry = response_queue.pop
361
+ end
362
+
363
+ kind, payload = entry
364
+
365
+ if kind == :event
366
+ state_write = state_write_from_event(payload)
367
+ state_write_events << state_write if state_write
368
+ next
369
+ end
370
+
371
+ raise payload if kind == :transport_error
372
+ next unless kind == :result && payload.is_a?(Hash)
373
+
374
+ error_payload = payload['error']
375
+ raise error_from_payload(error_payload) if error_payload.is_a?(Hash)
376
+
377
+ payload.delete('id')
378
+ return [payload, state_write_events]
379
+ end
380
+ end
381
+
382
+ def decode_execute_result(result, state_write_events)
383
+ state_writes = Array(result['stateWrites']).map do |write|
384
+ next unless write.is_a?(Hash)
385
+
386
+ StateWrite.new(
387
+ path: write['path'].to_s,
388
+ value: write['value'],
389
+ timestamp: write['timestamp']
390
+ )
391
+ end.compact
392
+
393
+ state_writes = merge_state_writes(state_writes, state_write_events)
394
+
395
+ metrics_payload = result['metrics']
396
+ metrics = nil
397
+ if metrics_payload.is_a?(Hash)
398
+ metrics = Metrics.new(
399
+ total_ms: metrics_payload['totalMs'] || 0,
400
+ parse_ms: metrics_payload['parseMs'] || 0,
401
+ evaluate_ms: metrics_payload['evaluateMs'] || 0
402
+ )
403
+ end
404
+
405
+ effects = Array(result['effects']).map do |effect|
406
+ next unless effect.is_a?(Hash)
407
+
408
+ Effect.new(
409
+ type: effect['type'].to_s,
410
+ content: effect['content'],
411
+ security: effect['security']
412
+ )
413
+ end.compact
414
+
415
+ ExecuteResult.new(
416
+ output: result['output'].to_s,
417
+ state_writes: state_writes,
418
+ exports: result.fetch('exports', []),
419
+ effects: effects,
420
+ metrics: metrics
421
+ )
422
+ end
423
+
424
+ private
425
+
426
+ def request(method, params, timeout)
427
+ request_id, response_queue = send_request(method, params)
428
+ await_request(request_id, response_queue, timeout)
429
+ end
430
+
431
+ def send_request(method, params)
432
+ request_id = nil
433
+ response_queue = nil
434
+ stdin = nil
435
+ payload = nil
436
+
437
+ @lock.synchronize do
438
+ ensure_transport_locked
439
+ request_id = @request_id
440
+ @request_id += 1
441
+
442
+ response_queue = Queue.new
443
+ @pending[request_id] = response_queue
444
+
445
+ stdin = @stdin
446
+ unless stdin
447
+ @pending.delete(request_id)
448
+ raise Error.new('live transport stdin is unavailable', code: 'TRANSPORT_ERROR')
449
+ end
450
+
451
+ payload = JSON.generate({ 'method' => method, 'id' => request_id, 'params' => params })
452
+ end
453
+
454
+ @write_lock.synchronize do
455
+ stdin.write(payload)
456
+ stdin.write("\n")
457
+ stdin.flush
458
+ end
459
+
460
+ [request_id, response_queue]
461
+ rescue StandardError => e
462
+ remove_pending(request_id) if request_id
463
+ raise e if e.is_a?(Error)
464
+
465
+ raise Error.new("failed to send request: #{e}", code: 'TRANSPORT_ERROR')
466
+ end
467
+
468
+ def send_control_request(payload)
469
+ stdin = @lock.synchronize { @stdin }
470
+ raise Error.new('live transport is unavailable', code: 'TRANSPORT_ERROR') unless stdin
471
+
472
+ @write_lock.synchronize do
473
+ stdin.write(JSON.generate(payload))
474
+ stdin.write("\n")
475
+ stdin.flush
476
+ end
477
+ end
478
+
479
+ def remove_pending(request_id)
480
+ @lock.synchronize { @pending.delete(request_id) }
481
+ end
482
+
483
+ def transport_running_locked?
484
+ @wait_thr&.alive? && @reader_thread&.alive? && @stdin && !@stdin.closed?
485
+ end
486
+
487
+ def ensure_transport_locked
488
+ return if transport_running_locked?
489
+
490
+ @stderr_lines = []
491
+
492
+ command = [@command, *@command_args, 'live', '--stdio']
493
+ options = {}
494
+ options[:chdir] = @working_dir if @working_dir
495
+
496
+ @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(*command, **options)
497
+ @reader_thread = Thread.new { reader_loop }
498
+ @stderr_thread = Thread.new { stderr_loop }
499
+ rescue StandardError => e
500
+ raise Error.new("failed to create live transport stdio pipes: #{e}", code: 'TRANSPORT_ERROR')
501
+ end
502
+
503
+ def reader_loop
504
+ stdout = @lock.synchronize { @stdout }
505
+ return unless stdout
506
+
507
+ while (line = stdout.gets)
508
+ line = line.strip
509
+ next if line.empty?
510
+
511
+ envelope = nil
512
+ begin
513
+ envelope = JSON.parse(line)
514
+ rescue JSON::ParserError => e
515
+ fail_all_pending(Error.new("invalid live response: #{e.message}", code: 'TRANSPORT_ERROR'))
516
+ next
517
+ end
518
+
519
+ event = envelope['event']
520
+ if event.is_a?(Hash)
521
+ event_request_id = request_id_from_payload(event['id'])
522
+ if event_request_id
523
+ queue = @lock.synchronize { @pending[event_request_id] }
524
+ queue << [:event, event] if queue
525
+ end
526
+ end
527
+
528
+ result = envelope['result']
529
+ if result.is_a?(Hash)
530
+ result_request_id = request_id_from_payload(result['id'])
531
+ if result_request_id
532
+ queue = @lock.synchronize { @pending.delete(result_request_id) }
533
+ queue << [:result, result] if queue
534
+ end
535
+ end
536
+ end
537
+ ensure
538
+ returncode = @wait_thr&.value&.exitstatus
539
+ message = @stderr_lines.join.strip
540
+ message = 'live transport closed' if message.empty?
541
+ fail_all_pending(Error.new(message, code: 'TRANSPORT_ERROR', returncode: returncode))
542
+ end
543
+
544
+ def stderr_loop
545
+ stderr = @lock.synchronize { @stderr }
546
+ return unless stderr
547
+
548
+ stderr.each_line do |line|
549
+ @stderr_lines << line
550
+ end
551
+ end
552
+
553
+ def fail_all_pending(error)
554
+ pending_queues = @lock.synchronize do
555
+ queues = @pending.values
556
+ @pending = {}
557
+ @stdin = nil
558
+ @stdout = nil
559
+ @stderr = nil
560
+ @wait_thr = nil
561
+ queues
562
+ end
563
+
564
+ pending_queues.each do |queue|
565
+ queue << [:transport_error, error]
566
+ end
567
+ end
568
+
569
+ def resolve_timeout(timeout)
570
+ return timeout unless timeout.nil?
571
+
572
+ @timeout
573
+ end
574
+
575
+ def error_from_payload(error_payload)
576
+ Error.new(
577
+ error_payload.fetch('message', 'mlld request failed').to_s,
578
+ code: error_payload['code'].is_a?(String) ? error_payload['code'] : nil
579
+ )
580
+ end
581
+
582
+ def request_id_from_payload(value)
583
+ return value if value.is_a?(Integer)
584
+ return value.to_i if value.is_a?(String) && value.match?(/\A\d+\z/)
585
+
586
+ nil
587
+ end
588
+
589
+ def state_write_from_event(event)
590
+ return nil unless event['type'] == 'state:write'
591
+
592
+ write = event['write']
593
+ return nil unless write.is_a?(Hash)
594
+
595
+ path = write['path']
596
+ return nil unless path.is_a?(String) && !path.empty?
597
+
598
+ StateWrite.new(path: path, value: write['value'], timestamp: write['timestamp'])
599
+ end
600
+
601
+ def merge_state_writes(primary, secondary)
602
+ return primary if secondary.empty?
603
+ return secondary if primary.empty?
604
+
605
+ merged = []
606
+ seen = {}
607
+
608
+ (primary + secondary).each do |state_write|
609
+ key = state_write_key(state_write)
610
+ next if seen[key]
611
+
612
+ seen[key] = true
613
+ merged << state_write
614
+ end
615
+
616
+ merged
617
+ end
618
+
619
+ def state_write_key(state_write)
620
+ encoded_value = JSON.generate(state_write.value)
621
+ "#{state_write.path}|#{encoded_value}"
622
+ rescue StandardError
623
+ "#{state_write.path}|#{state_write.value.inspect}"
624
+ end
625
+
626
+ def build_analyze_result(result, fallback_filepath)
627
+ errors = Array(result['errors']).map do |entry|
628
+ next unless entry.is_a?(Hash)
629
+
630
+ AnalysisError.new(
631
+ message: entry.fetch('message', '').to_s,
632
+ line: entry['line'],
633
+ column: entry['column']
634
+ )
635
+ end.compact
636
+
637
+ executables = Array(result['executables']).map do |entry|
638
+ next unless entry.is_a?(Hash)
639
+
640
+ Executable.new(
641
+ name: entry.fetch('name', '').to_s,
642
+ params: Array(entry['params']),
643
+ labels: Array(entry['labels'])
644
+ )
645
+ end.compact
646
+
647
+ imports = Array(result['imports']).map do |entry|
648
+ next unless entry.is_a?(Hash)
649
+
650
+ Import.new(
651
+ from: entry.fetch('from', '').to_s,
652
+ names: Array(entry['names'])
653
+ )
654
+ end.compact
655
+
656
+ guards = Array(result['guards']).map do |entry|
657
+ next unless entry.is_a?(Hash)
658
+
659
+ Guard.new(
660
+ name: entry.fetch('name', '').to_s,
661
+ timing: entry.fetch('timing', '').to_s,
662
+ label: entry['label']
663
+ )
664
+ end.compact
665
+
666
+ needs = nil
667
+ if result['needs'].is_a?(Hash)
668
+ needs = Needs.new(
669
+ cmd: Array(result.dig('needs', 'cmd')),
670
+ node: Array(result.dig('needs', 'node')),
671
+ py: Array(result.dig('needs', 'py'))
672
+ )
673
+ end
674
+
675
+ AnalyzeResult.new(
676
+ filepath: result.fetch('filepath', fallback_filepath),
677
+ valid: result.fetch('valid', true),
678
+ errors: errors,
679
+ executables: executables,
680
+ exports: Array(result['exports']),
681
+ imports: imports,
682
+ guards: guards,
683
+ needs: needs
684
+ )
685
+ end
686
+ end
687
+
688
+ class << self
689
+ def default_client
690
+ @default_client ||= Client.new
691
+ end
692
+
693
+ def close
694
+ return unless @default_client
695
+
696
+ @default_client.close
697
+ @default_client = nil
698
+ end
699
+
700
+ def process(script, **kwargs)
701
+ default_client.process(script, **kwargs)
702
+ end
703
+
704
+ def process_async(script, **kwargs)
705
+ default_client.process_async(script, **kwargs)
706
+ end
707
+
708
+ def execute(filepath, payload = nil, **kwargs)
709
+ default_client.execute(filepath, payload, **kwargs)
710
+ end
711
+
712
+ def execute_async(filepath, payload = nil, **kwargs)
713
+ default_client.execute_async(filepath, payload, **kwargs)
714
+ end
715
+
716
+ def analyze(filepath)
717
+ default_client.analyze(filepath)
718
+ end
719
+ end
720
+ end
721
+
722
+ at_exit { Mlld.close }
metadata ADDED
@@ -0,0 +1,45 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mlld
3
+ version: !ruby/object:Gem::Version
4
+ version: 2.0.2
5
+ platform: ruby
6
+ authors:
7
+ - mlld-lang
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Persistent live --stdio SDK wrapper for mlld from Ruby.
13
+ email:
14
+ - opensource@mlld.dev
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - README.md
20
+ - lib/mlld.rb
21
+ homepage: https://github.com/mlld-lang/mlld
22
+ licenses:
23
+ - MIT
24
+ metadata:
25
+ homepage_uri: https://github.com/mlld-lang/mlld
26
+ source_code_uri: https://github.com/mlld-lang/mlld
27
+ changelog_uri: https://github.com/mlld-lang/mlld/blob/main/CHANGELOG.md
28
+ rdoc_options: []
29
+ require_paths:
30
+ - lib
31
+ required_ruby_version: !ruby/object:Gem::Requirement
32
+ requirements:
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: '3.0'
36
+ required_rubygems_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ requirements: []
42
+ rubygems_version: 4.0.3
43
+ specification_version: 4
44
+ summary: Ruby wrapper for the mlld CLI
45
+ test_files: []