scjson 0.3.5 → 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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/lib/scjson/version.rb +1 -1
  3. data/lib/scjson.rb +176 -23
  4. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a958225a6d29fbafd51d046f9c38d7c65ff98aa80c7727e321c6c68ac952c00a
4
- data.tar.gz: 13acb52ec061e0ab3c0c09c76a9ea172370938e6770efa7ee69a162183365fe9
3
+ metadata.gz: cf4ac84d8fbd3c14f1f60e0e4b36bb543b9d0c0bbf018d7dcd21c070a2503553
4
+ data.tar.gz: 1ed8ff218919ff5709709c8f880f13cdec73a622a3890ac5e305a296aa7fe806
5
5
  SHA512:
6
- metadata.gz: b958161b70d561ccfdc324b1855a5f1a710edb0506798764c83e9dc8d26ef2989e2d8c1953a4a05834a01872b2b832adfde4d3a000e8e05f71b13f5c02a6e8a2
7
- data.tar.gz: 5991fbfe49a89974bedf45a8030cdf5fead46e001fd3851ca14ac5f1131a8519ba3f16d487499a839d7a97c208e0652de82b6ffb7a48c439ab2cdb753f5e9188
6
+ metadata.gz: 1fad325a32b3b769dac3be62beab1e6013d60e1f6b5690aeea076c74f6446547e0caa4474780c5c04694837b120833b48dc236addf53454a84e7377070fae8e0
7
+ data.tar.gz: 01acafaa1b2ee89da044ee309efb93becddb66abea2f3f52bda40e9970807586a42d91498b30edb1b51bceb2908c9d191b6330acf3355d2e60f2d7eca2a0e55e
@@ -7,5 +7,5 @@
7
7
  # Licensed under the BSD 1-Clause License.
8
8
 
9
9
  module Scjson
10
- VERSION = '0.3.5'
10
+ VERSION = '0.4.0'
11
11
  end
data/lib/scjson.rb CHANGED
@@ -21,6 +21,8 @@ require_relative 'scjson/types'
21
21
  # Canonical SCXML <-> scjson conversion for the Ruby agent.
22
22
  module Scjson
23
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
24
26
 
25
27
  ATTRIBUTE_MAP = {
26
28
  'datamodel' => 'datamodel_attribute',
@@ -37,6 +39,8 @@ module Scjson
37
39
  else content donedata initial
38
40
  ].freeze
39
41
 
42
+ SOURCE_BODY_TAGS = %w[script data].freeze
43
+
40
44
  STRUCTURAL_FIELDS = %w[
41
45
  state parallel final history transition invoke finalize datamodel data
42
46
  onentry onexit log send cancel raise assign script foreach param if_value
@@ -58,6 +62,7 @@ module Scjson
58
62
  raise ArgumentError, 'Document missing <scxml> root element' unless root
59
63
 
60
64
  map = element_to_hash(root)
65
+ attach_root_sibling_comments(doc, root, map)
61
66
  collapse_whitespace(map)
62
67
  remove_empty(map) if omit_empty
63
68
  return JSON.pretty_generate(map)
@@ -102,6 +107,7 @@ module Scjson
102
107
  doc.encoding = 'utf-8'
103
108
  root = build_element(doc, 'scxml', data)
104
109
  doc.root = root
110
+ add_preceding_help_text_comments(doc, root, data)
105
111
  return doc.to_xml
106
112
  end
107
113
  # Fallback: use Python CLI converter when Nokogiri is unavailable.
@@ -144,6 +150,36 @@ module Scjson
144
150
  end
145
151
  private_class_method :local_name
146
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
+
147
183
  def append_child(hash, key, value)
148
184
  if hash.key?(key)
149
185
  existing = hash[key]
@@ -164,8 +200,81 @@ module Scjson
164
200
  end
165
201
  private_class_method :wrap_list
166
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
+
167
276
  def any_element_to_hash(node)
168
- result = { 'qname' => node.name }
277
+ result = { 'qname' => clark_name(node) }
169
278
  text = node.text
170
279
  result['text'] = text.to_s if text
171
280
  unless node.attribute_nodes.empty?
@@ -183,9 +292,11 @@ module Scjson
183
292
  end
184
293
  private_class_method :any_element_to_hash
185
294
 
186
- def element_to_hash(node)
295
+ def element_to_hash(node, inside_source_body = false, inside_extension = false)
187
296
  map = {}
188
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')
189
300
 
190
301
  node.attribute_nodes.each do |attr|
191
302
  name = local_name(attr)
@@ -239,17 +350,32 @@ module Scjson
239
350
  end
240
351
 
241
352
  text_items = []
353
+ pending_comments = []
242
354
  node.children.each do |child|
243
- 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?
244
360
  child_local = local_name(child)
245
- if SCXML_ELEMENTS.include?(child_local)
361
+ if scxml_element?(child)
246
362
  key = case child_local
247
363
  when 'if' then 'if_value'
248
364
  when 'else' then 'else_value'
249
365
  when 'raise' then 'raise_value'
250
366
  else child_local
251
367
  end
252
- 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 = []
253
379
  target_key = if child_local == 'scxml' && local != 'scxml'
254
380
  'content'
255
381
  elsif local == 'content' && child_local == 'scxml'
@@ -263,14 +389,27 @@ module Scjson
263
389
  append_child(map, target_key, child_map)
264
390
  end
265
391
  else
266
- 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))
267
398
  end
268
399
  elsif child.text?
269
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
270
405
  text_items << value if value && !value.strip.empty?
271
406
  end
272
407
  end
273
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
+
274
413
  text_items.each { |text| append_child(map, 'content', text) }
275
414
 
276
415
  if local == 'scxml'
@@ -378,6 +517,11 @@ module Scjson
378
517
  raise ArgumentError, 'Expected object for element construction' unless map.is_a?(Hash)
379
518
 
380
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
381
525
  element = Nokogiri::XML::Element.new(element_name, doc)
382
526
 
383
527
  if name == 'scxml'
@@ -390,23 +534,28 @@ module Scjson
390
534
  element.add_child(Nokogiri::XML::Text.new(map['text'], doc))
391
535
  end
392
536
 
393
- if map['attributes'].is_a?(Hash)
394
- map['attributes'].each do |attr_name, attr_value|
395
- element[attr_name] = attr_value if attr_value
396
- end
537
+ attrs.each do |attr_name, attr_value|
538
+ element[attr_name] = attr_value if attr_value
397
539
  end
398
540
 
399
541
  map.each do |key, value|
400
- next if %w[qname text attributes].include?(key)
542
+ next if %w[qname text attributes help_text].include?(key)
401
543
 
402
544
  case key
403
545
  when 'content'
404
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
405
554
  when 'children'
406
555
  wrap_list(value).each do |child_map|
407
556
  next unless child_map.is_a?(Hash)
408
557
  child_name = child_map['qname'] || 'content'
409
- element.add_child(build_element(doc, child_name, child_map))
558
+ add_child_element(doc, element, child_name, child_map)
410
559
  end
411
560
  when 'other_attributes'
412
561
  next unless value.is_a?(Hash)
@@ -435,7 +584,7 @@ module Scjson
435
584
  element['initial'] = joined
436
585
  else
437
586
  wrap_list(value).each do |child|
438
- element.add_child(build_element(doc, 'initial', child))
587
+ add_child_element(doc, element, 'initial', child)
439
588
  end
440
589
  next
441
590
  end
@@ -451,12 +600,12 @@ module Scjson
451
600
 
452
601
  if STRUCTURAL_FIELDS.include?(key) || %w[if_value else_value raise_value].include?(key)
453
602
  wrap_list(value).each do |child|
454
- element.add_child(build_element(doc, child_name, child))
603
+ add_child_element(doc, element, child_name, child)
455
604
  end
456
605
  elsif value.is_a?(Array) && value.all? { |item| !item.is_a?(Hash) }
457
606
  element[key] = join_tokens(value)
458
607
  elsif value.is_a?(Hash)
459
- element.add_child(build_element(doc, child_name, value))
608
+ add_child_element(doc, element, child_name, value)
460
609
  elsif !value.nil?
461
610
  element[key] = value.to_s
462
611
  end
@@ -485,26 +634,32 @@ module Scjson
485
634
 
486
635
  next unless item.is_a?(Hash)
487
636
 
488
- 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
489
641
  wrap_list(item['content']).each do |inner|
490
642
  content_element = Nokogiri::XML::Element.new('content', doc)
491
643
  if inner.is_a?(String)
492
644
  content_element.add_child(Nokogiri::XML::Text.new(inner, doc))
493
645
  elsif inner.is_a?(Hash)
494
- content_element.add_child(build_element(doc, 'content', inner))
646
+ add_child_element(doc, content_element, 'content', inner)
495
647
  end
496
648
  element.add_child(content_element)
497
649
  end
498
650
  next
499
651
  end
500
652
 
501
- 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
502
657
  content_element = Nokogiri::XML::Element.new('content', doc)
503
658
  wrap_list(item['content']).each do |inner|
504
659
  if inner.is_a?(String)
505
660
  content_element.add_child(Nokogiri::XML::Text.new(inner, doc))
506
661
  elsif inner.is_a?(Hash)
507
- content_element.add_child(build_element(doc, 'content', inner))
662
+ add_child_element(doc, content_element, 'content', inner)
508
663
  end
509
664
  end
510
665
  element.add_child(content_element)
@@ -512,8 +667,7 @@ module Scjson
512
667
  end
513
668
 
514
669
  if item.key?('qname')
515
- child = build_element(doc, item['qname'], item)
516
- element.add_child(child)
670
+ add_child_element(doc, element, item['qname'], item)
517
671
  next
518
672
  end
519
673
 
@@ -528,8 +682,7 @@ module Scjson
528
682
  if parent_name == 'data' && child_name == 'content'
529
683
  element.add_child(Nokogiri::XML::Text.new(item['content'].to_s, doc))
530
684
  else
531
- child_element = build_element(doc, child_name, item)
532
- element.add_child(child_element)
685
+ add_child_element(doc, element, child_name, item)
533
686
  end
534
687
  end
535
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.5
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-04 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