scjson 0.3.3 → 0.4.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.
@@ -7,5 +7,5 @@
7
7
  # Licensed under the BSD 1-Clause License.
8
8
 
9
9
  module Scjson
10
- VERSION = '0.1.4'
10
+ VERSION = '0.4.0'
11
11
  end
data/lib/scjson.rb CHANGED
@@ -7,13 +7,22 @@
7
7
  # Licensed under the BSD 1-Clause License.
8
8
 
9
9
  require 'json'
10
- require 'nokogiri'
10
+ begin
11
+ require 'nokogiri'
12
+ NOKOGIRI_AVAILABLE = true
13
+ rescue LoadError
14
+ NOKOGIRI_AVAILABLE = false
15
+ end
16
+ require 'shellwords'
11
17
 
12
18
  require_relative 'scjson/version'
19
+ require_relative 'scjson/types'
13
20
 
14
21
  # Canonical SCXML <-> scjson conversion for the Ruby agent.
15
22
  module Scjson
16
23
  XMLNS = 'http://www.w3.org/2005/07/scxml'.freeze
24
+ XINCLUDE_NS = 'http://www.w3.org/2001/XInclude'.freeze
25
+ XINCLUDE_CLARK_INCLUDE = "{#{XINCLUDE_NS}}include".freeze
17
26
 
18
27
  ATTRIBUTE_MAP = {
19
28
  'datamodel' => 'datamodel_attribute',
@@ -30,6 +39,8 @@ module Scjson
30
39
  else content donedata initial
31
40
  ].freeze
32
41
 
42
+ SOURCE_BODY_TAGS = %w[script data].freeze
43
+
33
44
  STRUCTURAL_FIELDS = %w[
34
45
  state parallel final history transition invoke finalize datamodel data
35
46
  onentry onexit log send cancel raise assign script foreach param if_value
@@ -45,14 +56,42 @@ module Scjson
45
56
  # @param [Boolean] omit_empty Remove empty containers when true.
46
57
  # @return [String] Canonical scjson output.
47
58
  def xml_to_json(xml_str, omit_empty = true)
48
- doc = Nokogiri::XML(xml_str) { |cfg| cfg.strict.nonet }
49
- root = locate_root(doc)
50
- raise ArgumentError, 'Document missing <scxml> root element' unless root
51
-
52
- map = element_to_hash(root)
53
- collapse_whitespace(map)
54
- remove_empty(map) if omit_empty
55
- JSON.pretty_generate(map)
59
+ if NOKOGIRI_AVAILABLE
60
+ doc = Nokogiri::XML(xml_str) { |cfg| cfg.strict.nonet }
61
+ root = locate_root(doc)
62
+ raise ArgumentError, 'Document missing <scxml> root element' unless root
63
+
64
+ map = element_to_hash(root)
65
+ attach_root_sibling_comments(doc, root, map)
66
+ collapse_whitespace(map)
67
+ remove_empty(map) if omit_empty
68
+ return JSON.pretty_generate(map)
69
+ end
70
+ # Fallback: use Python CLI converter when Nokogiri is unavailable.
71
+ begin
72
+ require 'tmpdir'
73
+ Dir.mktmpdir('scjson-rb-xml2json') do |dir|
74
+ in_path = File.join(dir, 'in.scxml')
75
+ out_path = File.join(dir, 'out.scjson')
76
+ File.write(in_path, xml_str)
77
+ py_candidates = [ENV['PYTHON'], 'python3', 'python'].compact.uniq
78
+ ok = false
79
+ py_candidates.each do |py|
80
+ # Try package entrypoint; add repo-local 'py' to PYTHONPATH for import
81
+ repo_py = File.expand_path('../../py', __dir__)
82
+ env = {}
83
+ current_pp = ENV['PYTHONPATH']
84
+ env['PYTHONPATH'] = current_pp ? (repo_py + File::PATH_SEPARATOR + current_pp) : repo_py
85
+ cmd = [py, '-m', 'scjson.cli', 'json', in_path, '-o', out_path]
86
+ ok = system(env, *cmd, out: File::NULL, err: File::NULL) && File.file?(out_path)
87
+ break if ok
88
+ end
89
+ raise 'python converter failed' unless ok
90
+ return File.read(out_path)
91
+ end
92
+ rescue StandardError => e
93
+ raise LoadError, "SCXML->JSON conversion unavailable: Nokogiri missing and external converter failed (#{e})"
94
+ end
56
95
  end
57
96
 
58
97
  ##
@@ -61,13 +100,40 @@ module Scjson
61
100
  # @param [String] json_str Canonical scjson input.
62
101
  # @return [String] XML document encoded as UTF-8.
63
102
  def json_to_xml(json_str)
64
- data = JSON.parse(json_str)
65
- remove_empty(data)
66
- doc = Nokogiri::XML::Document.new
67
- doc.encoding = 'utf-8'
68
- root = build_element(doc, 'scxml', data)
69
- doc.root = root
70
- doc.to_xml
103
+ if NOKOGIRI_AVAILABLE
104
+ data = JSON.parse(json_str)
105
+ remove_empty(data)
106
+ doc = Nokogiri::XML::Document.new
107
+ doc.encoding = 'utf-8'
108
+ root = build_element(doc, 'scxml', data)
109
+ doc.root = root
110
+ add_preceding_help_text_comments(doc, root, data)
111
+ return doc.to_xml
112
+ end
113
+ # Fallback: use Python CLI converter when Nokogiri is unavailable.
114
+ begin
115
+ require 'tmpdir'
116
+ Dir.mktmpdir('scjson-rb-json2xml') do |dir|
117
+ in_path = File.join(dir, 'in.scjson')
118
+ out_path = File.join(dir, 'out.scxml')
119
+ File.write(in_path, json_str)
120
+ py_candidates = [ENV['PYTHON'], 'python3', 'python'].compact.uniq
121
+ ok = false
122
+ py_candidates.each do |py|
123
+ repo_py = File.expand_path('../../py', __dir__)
124
+ env = {}
125
+ current_pp = ENV['PYTHONPATH']
126
+ env['PYTHONPATH'] = current_pp ? (repo_py + File::PATH_SEPARATOR + current_pp) : repo_py
127
+ cmd = [py, '-m', 'scjson.cli', 'xml', in_path, '-o', out_path]
128
+ ok = system(env, *cmd, out: File::NULL, err: File::NULL) && File.file?(out_path)
129
+ break if ok
130
+ end
131
+ raise 'python converter failed' unless ok
132
+ return File.read(out_path)
133
+ end
134
+ rescue StandardError => e
135
+ raise LoadError, "JSON->SCXML conversion unavailable: Nokogiri missing and external converter failed (#{e})"
136
+ end
71
137
  end
72
138
 
73
139
  # ----------------------------
@@ -84,6 +150,36 @@ module Scjson
84
150
  end
85
151
  private_class_method :local_name
86
152
 
153
+ def scxml_element?(node)
154
+ ns = node.namespace&.href
155
+ SCXML_ELEMENTS.include?(local_name(node)) && (ns.nil? || ns.empty? || ns == XMLNS)
156
+ end
157
+ private_class_method :scxml_element?
158
+
159
+ def extension_element?(node)
160
+ ns = node.namespace&.href
161
+ !ns.nil? && !ns.empty? && ns != XMLNS
162
+ end
163
+ private_class_method :extension_element?
164
+
165
+ def comment_node?(node)
166
+ node.respond_to?(:comment?) && node.comment?
167
+ end
168
+ private_class_method :comment_node?
169
+
170
+ def processing_instruction_node?(node)
171
+ node.respond_to?(:processing_instruction?) && node.processing_instruction?
172
+ end
173
+ private_class_method :processing_instruction_node?
174
+
175
+ def clark_name(node)
176
+ ns = node.namespace&.href
177
+ return node.name if ns.nil? || ns.empty?
178
+
179
+ "{#{ns}}#{local_name(node)}"
180
+ end
181
+ private_class_method :clark_name
182
+
87
183
  def append_child(hash, key, value)
88
184
  if hash.key?(key)
89
185
  existing = hash[key]
@@ -104,8 +200,81 @@ module Scjson
104
200
  end
105
201
  private_class_method :wrap_list
106
202
 
203
+ def repair_comment_text(raw)
204
+ text = raw.to_s
205
+ return text.strip unless text.include?("\n")
206
+
207
+ lines = text.lines.map { |line| line.chomp("\n") }
208
+ lines.shift while !lines.empty? && lines.first.strip.empty?
209
+ lines.pop while !lines.empty? && lines.last.strip.empty?
210
+ return '' if lines.empty?
211
+
212
+ indents = lines.map do |line|
213
+ next if line.strip.empty?
214
+
215
+ line.length - line.sub(/\A[ \t]+/, '').length
216
+ end.compact
217
+ common = indents.empty? ? 0 : indents.min
218
+ lines = lines.map { |line| line.length >= common ? line[common, line.length] : line } if common.positive?
219
+ lines.join("\n")
220
+ end
221
+ private_class_method :repair_comment_text
222
+
223
+ def emit_safe_comment_text(text)
224
+ safe = text.to_s.gsub('--', '- -')
225
+ safe = "#{safe} " if safe.end_with?('-')
226
+ safe
227
+ end
228
+ private_class_method :emit_safe_comment_text
229
+
230
+ def append_help_text(map, comments, prepend: false)
231
+ repaired = comments.map(&:to_s)
232
+ return if repaired.empty?
233
+
234
+ existing = map['help_text']
235
+ if existing
236
+ existing = wrap_list(existing)
237
+ map['help_text'] = prepend ? repaired + existing : existing + repaired
238
+ else
239
+ map['help_text'] = repaired
240
+ end
241
+ end
242
+ private_class_method :append_help_text
243
+
244
+ def add_preceding_help_text_comments(doc, element, map)
245
+ return unless map.is_a?(Hash)
246
+
247
+ wrap_list(map['help_text']).each do |text|
248
+ element.add_previous_sibling(Nokogiri::XML::Comment.new(doc, emit_safe_comment_text(text)))
249
+ end
250
+ end
251
+ private_class_method :add_preceding_help_text_comments
252
+
253
+ def add_child_element(doc, parent, child_name, child_map)
254
+ child = build_element(doc, child_name, child_map)
255
+ if child_map.is_a?(Hash)
256
+ wrap_list(child_map['help_text']).each do |text|
257
+ parent.add_child(Nokogiri::XML::Comment.new(doc, emit_safe_comment_text(text)))
258
+ end
259
+ end
260
+ parent.add_child(child)
261
+ end
262
+ private_class_method :add_child_element
263
+
264
+ def attach_root_sibling_comments(doc, root, map)
265
+ comments = []
266
+ doc.children.each do |child|
267
+ next if child.equal?(root)
268
+ next unless comment_node?(child)
269
+
270
+ comments << repair_comment_text(child.text || '')
271
+ end
272
+ append_help_text(map, comments) unless comments.empty?
273
+ end
274
+ private_class_method :attach_root_sibling_comments
275
+
107
276
  def any_element_to_hash(node)
108
- result = { 'qname' => node.name }
277
+ result = { 'qname' => clark_name(node) }
109
278
  text = node.text
110
279
  result['text'] = text.to_s if text
111
280
  unless node.attribute_nodes.empty?
@@ -123,9 +292,11 @@ module Scjson
123
292
  end
124
293
  private_class_method :any_element_to_hash
125
294
 
126
- def element_to_hash(node)
295
+ def element_to_hash(node, inside_source_body = false, inside_extension = false)
127
296
  map = {}
128
297
  local = local_name(node)
298
+ elem_in_source = inside_source_body || SOURCE_BODY_TAGS.include?(local)
299
+ elem_in_extension = inside_extension || (!SCXML_ELEMENTS.include?(local) && local != 'scxml')
129
300
 
130
301
  node.attribute_nodes.each do |attr|
131
302
  name = local_name(attr)
@@ -179,17 +350,32 @@ module Scjson
179
350
  end
180
351
 
181
352
  text_items = []
353
+ pending_comments = []
182
354
  node.children.each do |child|
183
- if child.element?
355
+ if comment_node?(child)
356
+ pending_comments << repair_comment_text(child.text || '')
357
+ elsif processing_instruction_node?(child)
358
+ next
359
+ elsif child.element?
184
360
  child_local = local_name(child)
185
- if SCXML_ELEMENTS.include?(child_local)
361
+ if scxml_element?(child)
186
362
  key = case child_local
187
363
  when 'if' then 'if_value'
188
364
  when 'else' then 'else_value'
189
365
  when 'raise' then 'raise_value'
190
366
  else child_local
191
367
  end
192
- child_map = element_to_hash(child)
368
+ child_map = element_to_hash(child, elem_in_source || local == 'content', elem_in_extension)
369
+ target_eligible = SCXML_ELEMENTS.include?(child_local) && !elem_in_source && !elem_in_extension
370
+ if !pending_comments.empty? && local == 'content' && child_local == 'scxml'
371
+ # Comments inside an inline <content> payload are payload-local,
372
+ # not authoring metadata for the nested machine.
373
+ elsif !pending_comments.empty? && target_eligible
374
+ append_help_text(child_map, pending_comments, prepend: true)
375
+ elsif !pending_comments.empty? && SCXML_ELEMENTS.include?(local) && !elem_in_source && !elem_in_extension
376
+ append_help_text(map, pending_comments)
377
+ end
378
+ pending_comments = []
193
379
  target_key = if child_local == 'scxml' && local != 'scxml'
194
380
  'content'
195
381
  elsif local == 'content' && child_local == 'scxml'
@@ -203,14 +389,27 @@ module Scjson
203
389
  append_child(map, target_key, child_map)
204
390
  end
205
391
  else
206
- append_child(map, 'content', any_element_to_hash(child))
392
+ if !pending_comments.empty? && SCXML_ELEMENTS.include?(local) && !elem_in_source && !elem_in_extension
393
+ append_help_text(map, pending_comments)
394
+ end
395
+ pending_comments = []
396
+ target_key = extension_element?(child) ? 'other_element' : 'content'
397
+ append_child(map, target_key, any_element_to_hash(child))
207
398
  end
208
399
  elsif child.text?
209
400
  value = child.text
401
+ if value && !value.strip.empty? && !pending_comments.empty?
402
+ append_help_text(map, pending_comments) if SCXML_ELEMENTS.include?(local) && !elem_in_source && !elem_in_extension
403
+ pending_comments = []
404
+ end
210
405
  text_items << value if value && !value.strip.empty?
211
406
  end
212
407
  end
213
408
 
409
+ if !pending_comments.empty? && SCXML_ELEMENTS.include?(local) && !elem_in_source && !elem_in_extension
410
+ append_help_text(map, pending_comments)
411
+ end
412
+
214
413
  text_items.each { |text| append_child(map, 'content', text) }
215
414
 
216
415
  if local == 'scxml'
@@ -318,6 +517,11 @@ module Scjson
318
517
  raise ArgumentError, 'Expected object for element construction' unless map.is_a?(Hash)
319
518
 
320
519
  element_name = map['qname'] || name
520
+ attrs = map['attributes'].is_a?(Hash) ? map['attributes'].dup : {}
521
+ if element_name == XINCLUDE_CLARK_INCLUDE
522
+ element_name = 'xi:include'
523
+ attrs['xmlns:xi'] ||= XINCLUDE_NS
524
+ end
321
525
  element = Nokogiri::XML::Element.new(element_name, doc)
322
526
 
323
527
  if name == 'scxml'
@@ -330,23 +534,28 @@ module Scjson
330
534
  element.add_child(Nokogiri::XML::Text.new(map['text'], doc))
331
535
  end
332
536
 
333
- if map['attributes'].is_a?(Hash)
334
- map['attributes'].each do |attr_name, attr_value|
335
- element[attr_name] = attr_value if attr_value
336
- end
537
+ attrs.each do |attr_name, attr_value|
538
+ element[attr_name] = attr_value if attr_value
337
539
  end
338
540
 
339
541
  map.each do |key, value|
340
- next if %w[qname text attributes].include?(key)
542
+ next if %w[qname text attributes help_text].include?(key)
341
543
 
342
544
  case key
343
545
  when 'content'
344
546
  handle_content_nodes(doc, element, value, element_name)
547
+ when 'other_element'
548
+ wrap_list(value).each do |child_map|
549
+ next unless child_map.is_a?(Hash)
550
+
551
+ child_name = child_map['qname'] || 'content'
552
+ add_child_element(doc, element, child_name, child_map)
553
+ end
345
554
  when 'children'
346
555
  wrap_list(value).each do |child_map|
347
556
  next unless child_map.is_a?(Hash)
348
557
  child_name = child_map['qname'] || 'content'
349
- element.add_child(build_element(doc, child_name, child_map))
558
+ add_child_element(doc, element, child_name, child_map)
350
559
  end
351
560
  when 'other_attributes'
352
561
  next unless value.is_a?(Hash)
@@ -375,7 +584,7 @@ module Scjson
375
584
  element['initial'] = joined
376
585
  else
377
586
  wrap_list(value).each do |child|
378
- element.add_child(build_element(doc, 'initial', child))
587
+ add_child_element(doc, element, 'initial', child)
379
588
  end
380
589
  next
381
590
  end
@@ -391,12 +600,12 @@ module Scjson
391
600
 
392
601
  if STRUCTURAL_FIELDS.include?(key) || %w[if_value else_value raise_value].include?(key)
393
602
  wrap_list(value).each do |child|
394
- element.add_child(build_element(doc, child_name, child))
603
+ add_child_element(doc, element, child_name, child)
395
604
  end
396
605
  elsif value.is_a?(Array) && value.all? { |item| !item.is_a?(Hash) }
397
606
  element[key] = join_tokens(value)
398
607
  elsif value.is_a?(Hash)
399
- element.add_child(build_element(doc, child_name, value))
608
+ add_child_element(doc, element, child_name, value)
400
609
  elsif !value.nil?
401
610
  element[key] = value.to_s
402
611
  end
@@ -425,26 +634,32 @@ module Scjson
425
634
 
426
635
  next unless item.is_a?(Hash)
427
636
 
428
- if parent_name == 'send' && item.keys == ['content']
637
+ if parent_name == 'send' && (item.keys - ['help_text']) == ['content']
638
+ wrap_list(item['help_text']).each do |text|
639
+ element.add_child(Nokogiri::XML::Comment.new(doc, emit_safe_comment_text(text)))
640
+ end
429
641
  wrap_list(item['content']).each do |inner|
430
642
  content_element = Nokogiri::XML::Element.new('content', doc)
431
643
  if inner.is_a?(String)
432
644
  content_element.add_child(Nokogiri::XML::Text.new(inner, doc))
433
645
  elsif inner.is_a?(Hash)
434
- content_element.add_child(build_element(doc, 'content', inner))
646
+ add_child_element(doc, content_element, 'content', inner)
435
647
  end
436
648
  element.add_child(content_element)
437
649
  end
438
650
  next
439
651
  end
440
652
 
441
- if parent_name == 'donedata' && item.keys == ['content']
653
+ if parent_name == 'donedata' && (item.keys - ['help_text']) == ['content']
654
+ wrap_list(item['help_text']).each do |text|
655
+ element.add_child(Nokogiri::XML::Comment.new(doc, emit_safe_comment_text(text)))
656
+ end
442
657
  content_element = Nokogiri::XML::Element.new('content', doc)
443
658
  wrap_list(item['content']).each do |inner|
444
659
  if inner.is_a?(String)
445
660
  content_element.add_child(Nokogiri::XML::Text.new(inner, doc))
446
661
  elsif inner.is_a?(Hash)
447
- content_element.add_child(build_element(doc, 'content', inner))
662
+ add_child_element(doc, content_element, 'content', inner)
448
663
  end
449
664
  end
450
665
  element.add_child(content_element)
@@ -452,8 +667,7 @@ module Scjson
452
667
  end
453
668
 
454
669
  if item.key?('qname')
455
- child = build_element(doc, item['qname'], item)
456
- element.add_child(child)
670
+ add_child_element(doc, element, item['qname'], item)
457
671
  next
458
672
  end
459
673
 
@@ -468,8 +682,7 @@ module Scjson
468
682
  if parent_name == 'data' && child_name == 'content'
469
683
  element.add_child(Nokogiri::XML::Text.new(item['content'].to_s, doc))
470
684
  else
471
- child_element = build_element(doc, child_name, item)
472
- element.add_child(child_element)
685
+ add_child_element(doc, element, child_name, item)
473
686
  end
474
687
  end
475
688
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scjson
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.3
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Softoboros Technology Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-10-01 00:00:00.000000000 Z
11
+ date: 2026-05-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri
@@ -24,7 +24,9 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0'
27
- description:
27
+ description: 'scjson: SCXML ↔ JSON converter, validator, and execution trace interface.
28
+ Provides CLI tools for conversion, validation, and emitting deterministic traces
29
+ compatible with SCION semantics.'
28
30
  email:
29
31
  - info@softoboros.com
30
32
  executables:
@@ -32,14 +34,25 @@ executables:
32
34
  extensions: []
33
35
  extra_rdoc_files: []
34
36
  files:
37
+ - LEGAL.md
38
+ - LICENSE
39
+ - README.md
35
40
  - bin/scjson
36
41
  - lib/scjson.rb
37
42
  - lib/scjson/cli.rb
43
+ - lib/scjson/engine.rb
44
+ - lib/scjson/engine/context.rb
45
+ - lib/scjson/types.rb
38
46
  - lib/scjson/version.rb
39
- homepage:
47
+ homepage: https://github.com/SoftOboros/scjson
40
48
  licenses:
41
49
  - BSD-1-Clause
42
- metadata: {}
50
+ metadata:
51
+ source_code_uri: https://github.com/SoftOboros/scjson
52
+ documentation_uri: https://github.com/SoftOboros/scjson/tree/main/docs
53
+ changelog_uri: https://github.com/SoftOboros/scjson/releases
54
+ homepage_uri: https://github.com/SoftOboros/scjson
55
+ keywords: scxml,statecharts,state-machine,scjson,scml,execution
43
56
  post_install_message:
44
57
  rdoc_options: []
45
58
  require_paths:
@@ -58,5 +71,5 @@ requirements: []
58
71
  rubygems_version: 3.4.19
59
72
  signing_key:
60
73
  specification_version: 4
61
- summary: SCXML <-> scjson converter and validator
74
+ summary: SCXML/SCML execution, SCXML <-> scjson converter and validator
62
75
  test_files: []