asciichem 0.28.2 → 0.29.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.
@@ -0,0 +1,855 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ # The transform's rules, backend-neutral: included by Transform
5
+ # (parslet) and by Engine::ParsanolEngine::Transform when that
6
+ # opt-in engine is active. String class_eval so the nested builder
7
+ # classes land as constants of the including class, unchanged.
8
+ module TransformRules
9
+ def self.included(base)
10
+ base.class_eval <<~'RULES'
11
+ # frozen_string_literal: true
12
+
13
+ # Shared helpers for Builder classes. Each builder parses a raw
14
+ # string captured by the grammar into typed model fields. The
15
+ # strip_value helper normalises parslet's quirky representation
16
+ # of "zero matches" (an empty array) into nil so the rest of the
17
+ # builder logic can treat nil as "absent".
18
+ module BuilderHelpers
19
+ def strip_value(value)
20
+ return nil if value.nil?
21
+ return nil if value.is_a?(Array) && value.empty?
22
+
23
+ s = value.to_s.strip
24
+ s.empty? ? nil : s
25
+ end
26
+ end
27
+
28
+ # -- top level --------------------------------------------------------
29
+
30
+ rule(formula: subtree(:subtree)) do
31
+ nodes = FormulaNormaliser.new(subtree).to_a
32
+ Model::Formula.new(nodes: nodes)
33
+ end
34
+
35
+ # -- text -------------------------------------------------------------
36
+
37
+ rule(text_run: simple(:text)) do
38
+ Model::Text.new(content: TextNormaliser.strip_quotes(text.to_s))
39
+ end
40
+
41
+ rule(group_text_run: simple(:text)) do
42
+ Model::Text.new(content: TextNormaliser.strip_quotes(text.to_s))
43
+ end
44
+
45
+ # -- embedded math ---------------------------------------------------
46
+
47
+ rule(math_source: simple(:source)) do
48
+ formula = Plurimath::Asciimath.new(source.to_s).to_formula
49
+ Model::EmbeddedMath.new(formula: formula, source: source.to_s)
50
+ end
51
+
52
+ # -- atoms -----------------------------------------------------------
53
+ #
54
+ # One rule covers every atom shape. The grammar wraps atoms in
55
+ # `.as(:atom)`, so the transform sees `{ atom: { element:...,
56
+ # isotope:..., subscript:..., superscript:..., lone_pairs:...,
57
+ # radical_electrons:... } }` with any of the optional keys absent.
58
+ # AtomBuilder.from_parse_tree normalises the variations.
59
+ rule(atom: subtree(:attrs)) do
60
+ AtomBuilder.from_parse_tree(attrs).build
61
+ end
62
+
63
+ # -- bonds ------------------------------------------------------------
64
+
65
+ rule(quadruple: simple(:_q)) { Model::Bond.new(kind: :quadruple) }
66
+ rule(triple: simple(:_t)) { Model::Bond.new(kind: :triple) }
67
+ rule(wedge: simple(:_w)) { Model::Bond.new(kind: :wedge) }
68
+ rule(hash: simple(:_h)) { Model::Bond.new(kind: :hash) }
69
+ rule(dative: simple(:_d)) { Model::Bond.new(kind: :dative) }
70
+ rule(wavy: simple(:_wv)) { Model::Bond.new(kind: :wavy) }
71
+ rule(double: simple(:_d2)) { Model::Bond.new(kind: :double) }
72
+ rule(single: simple(:_s)) { Model::Bond.new(kind: :single) }
73
+
74
+ # -- molecules -------------------------------------------------------
75
+
76
+ rule(stereo: simple(:letter),
77
+ coefficient: subtree(:coef),
78
+ units: subtree(:units)) do
79
+ Model::Molecule.new(
80
+ coefficient: CoefficientNormaliser.new(coef).to_s,
81
+ nodes: Array(units),
82
+ stereo: StereoNormaliser.normalise(letter)
83
+ )
84
+ end
85
+
86
+ rule(stereo: simple(:letter), units: subtree(:units)) do
87
+ Model::Molecule.new(
88
+ nodes: Array(units),
89
+ stereo: StereoNormaliser.normalise(letter)
90
+ )
91
+ end
92
+
93
+ rule(coefficient: subtree(:coef), units: subtree(:units)) do
94
+ Model::Molecule.new(
95
+ coefficient: CoefficientNormaliser.new(coef).to_s,
96
+ nodes: Array(units)
97
+ )
98
+ end
99
+
100
+ rule(units: subtree(:units)) do
101
+ Model::Molecule.new(nodes: Array(units))
102
+ end
103
+
104
+ # -- annotated molecules -------------------------------------------
105
+ #
106
+ # An annotated molecule wraps a built Model::Molecule with
107
+ # `@key("value")` metadata annotations. The grammar produces
108
+ # `{ mol: <Molecule>, annotations: [{ann_type:, ann_value:}, ...] }`.
109
+ # The transform receives the already-built Molecule (parslet
110
+ # transforms bottom-up) and applies the annotations.
111
+
112
+ rule(mol: simple(:mol), annotations: subtree(:anns)) do
113
+ AnnotationApplicator.new(mol, Array(anns)).apply
114
+ mol
115
+ end
116
+
117
+ # -- groups ----------------------------------------------------------
118
+
119
+ rule(open_bracket: simple(:open),
120
+ group_nodes: subtree(:nodes),
121
+ close_bracket: simple(:_close),
122
+ multiplicity: subtree(:mult)) do
123
+ Model::Group.new(
124
+ nodes: Array(nodes),
125
+ multiplicity: MultiplicityNormaliser.new(mult).to_s,
126
+ bracket: Group.bracket_kind(open.to_s)
127
+ )
128
+ end
129
+
130
+ # -- reactions -------------------------------------------------------
131
+
132
+ rule(reactants: subtree(:reactants),
133
+ arrow: subtree(:arrow),
134
+ products: subtree(:products)) do
135
+ ReactionBuilder.new(reactants, arrow, products).build
136
+ end
137
+
138
+ # -- reaction cascades ------------------------------------------------
139
+ #
140
+ # The grammar wraps a chain in `{ cascade: { first: <Reaction>,
141
+ # arrow:..., products:... (repeated) } }`. Fold into a single
142
+ # ReactionCascade with steps[0] = first and each subsequent step
143
+ # built from the previous step's products as reactants.
144
+
145
+ rule(cascade: subtree(:data)) do
146
+ CascadeBuilder.new(data).build
147
+ end
148
+
149
+ # -- electron configuration ------------------------------------------
150
+
151
+ # Inner rule: each `{orbital:..., occupancy:...}` hash becomes a
152
+ # `[orbital, occupancy]` string pair. The outer rule then collects
153
+ # these into an ElectronConfiguration.
154
+ rule(orbital: simple(:orb), occupancy: simple(:occ)) do
155
+ [orb.to_s, occ.to_s]
156
+ end
157
+
158
+ rule(electron_config: subtree(:raw_pairs)) do
159
+ pairs = Array(raw_pairs).map { |pair| Array(pair) }
160
+ Model::ElectronConfiguration.new(orbitals: pairs)
161
+ end
162
+
163
+ # -- crystals -------------------------------------------------------
164
+ #
165
+ # Grammar captures crystal_name, crystal_params, and crystal_body
166
+ # as optional strings. CrystalBuilder parses them into the model.
167
+
168
+ rule(crystal_node: subtree(:data)) do
169
+ hash = data.is_a?(Hash) ? data : {}
170
+ CrystalBuilder.new(
171
+ hash[:crystal_name],
172
+ hash[:crystal_params],
173
+ hash[:crystal_body]
174
+ ).build
175
+ end
176
+
177
+ # -- spectra --------------------------------------------------------
178
+
179
+ rule(spectrum_node: subtree(:data)) do
180
+ hash = data.is_a?(Hash) ? data : {}
181
+ SpectrumBuilder.new(
182
+ hash[:spectrum_type],
183
+ hash[:spectrum_params],
184
+ hash[:spectrum_body]
185
+ ).build
186
+ end
187
+
188
+ # -- computational chemistry ----------------------------------------
189
+
190
+ rule(calc_node: subtree(:data)) do
191
+ hash = data.is_a?(Hash) ? data : {}
192
+ CalculationBuilder.new(hash[:calc_params], hash[:calc_body]).build
193
+ end
194
+
195
+ # -- Z-Matrix -------------------------------------------------------
196
+
197
+ rule(zmatrix_node: subtree(:data)) do
198
+ hash = data.is_a?(Hash) ? data : {}
199
+ ZMatrixBuilder.new(hash[:zmatrix_body]).build
200
+ end
201
+
202
+ # -- reaction mechanisms --------------------------------------------
203
+
204
+ rule(mechanism_node: subtree(:data)) do
205
+ hash = data.is_a?(Hash) ? data : {}
206
+ MechanismBuilder.new(hash[:mechanism_body]).build
207
+ end
208
+
209
+ # -- internal helpers ------------------------------------------------
210
+
211
+ # Builds a Crystal from parsed grammar captures. The grammar
212
+ # captures the name, params, and body as raw strings; this class
213
+ # parses them into the model fields.
214
+ class CrystalBuilder
215
+ include BuilderHelpers
216
+
217
+ def initialize(name, params_str, body_str)
218
+ @name = strip_value(name)
219
+ @params_str = strip_value(params_str)
220
+ @body_str = strip_value(body_str)
221
+ end
222
+
223
+ def build
224
+ params = parse_params(@params_str)
225
+ atoms = parse_atoms(@body_str)
226
+ Model::Crystal.new(
227
+ name: @name,
228
+ a: params['a'],
229
+ b: params['b'],
230
+ c: params['c'],
231
+ alpha: params['alpha'],
232
+ beta: params['beta'],
233
+ gamma: params['gamma'],
234
+ spacegroup: params['sg'],
235
+ atoms: atoms
236
+ )
237
+ end
238
+
239
+ private
240
+
241
+ def parse_params(str)
242
+ return {} unless str
243
+
244
+ str.split(',').each_with_object({}) do |pair, memo|
245
+ key, val = pair.strip.split('=', 2)
246
+ memo[key] = val&.strip if key
247
+ end
248
+ end
249
+
250
+ def parse_atoms(str)
251
+ return [] unless str
252
+
253
+ formula = AsciiChem.parse(str)
254
+ formula.nodes.flat_map do |node|
255
+ next [] unless node.is_a?(Model::Molecule)
256
+
257
+ node.nodes.select { |n| n.is_a?(Model::Atom) }
258
+ end
259
+ end
260
+ end
261
+
262
+ # Builds a Spectrum from parsed grammar captures. Parses peak
263
+ # lines from the body string.
264
+ class SpectrumBuilder
265
+ include BuilderHelpers
266
+
267
+ def initialize(type_str, params_str, body_str)
268
+ @type = strip_value(type_str)
269
+ @params_str = strip_value(params_str)
270
+ @body_str = strip_value(body_str)
271
+ end
272
+
273
+ def build
274
+ Model::Spectrum.new(
275
+ type: @type,
276
+ params: parse_params(@params_str),
277
+ peaks: parse_peaks(@body_str)
278
+ )
279
+ end
280
+
281
+ private
282
+
283
+ def parse_params(str)
284
+ return {} unless str
285
+
286
+ str.split(',').each_with_object({}) do |pair, memo|
287
+ key, val = pair.strip.split('=', 2)
288
+ memo[key] = val&.strip if key
289
+ end
290
+ end
291
+
292
+ def parse_peaks(str)
293
+ return [] unless str
294
+
295
+ validate_peaks(str)
296
+ str.split("\n").filter_map { |line| parse_peak(line.strip) }
297
+ end
298
+
299
+ def validate_peaks(str)
300
+ str.split("\n").each_with_index do |line, idx|
301
+ stripped = line.strip
302
+ next if stripped.empty?
303
+ next if stripped.include?(':')
304
+
305
+ raise AsciiChem::ParseError,
306
+ "spectrum peak on line #{idx + 1} is missing ':' separator: #{stripped.inspect}"
307
+ end
308
+ end
309
+
310
+ def parse_peak(line)
311
+ return nil if line.empty?
312
+
313
+ assignment = nil
314
+ match = line.match(/"([^"]*)"/)
315
+ if match
316
+ assignment = match[1]
317
+ line = line.sub(/"[^"]*"/, '').strip
318
+ end
319
+
320
+ pos, rest = line.split(':', 2)
321
+ tokens = rest&.strip&.split(/\s+/) || []
322
+
323
+ Model::Spectrum::Peak.new(
324
+ position: pos&.strip,
325
+ intensity: tokens[0],
326
+ multiplicity: tokens[1],
327
+ assignment: assignment
328
+ )
329
+ end
330
+ end
331
+
332
+ # Builds a Calculation from grammar captures.
333
+ # Params: "method/basis" string. Body: key-value lines.
334
+ class CalculationBuilder
335
+ include BuilderHelpers
336
+ def initialize(params_str, body_str)
337
+ @params_str = strip_value(params_str)
338
+ @body_str = strip_value(body_str)
339
+ end
340
+
341
+ def build
342
+ method, basis = parse_method_basis(@params_str)
343
+ Model::Calculation.new(
344
+ method: method,
345
+ basis: basis,
346
+ properties: parse_properties(@body_str)
347
+ )
348
+ end
349
+
350
+ private
351
+
352
+ def parse_method_basis(str)
353
+ return [nil, nil] unless str
354
+
355
+ parts = str.split('/', 2)
356
+ [parts[0]&.strip, parts[1]&.strip]
357
+ end
358
+
359
+ def parse_properties(str)
360
+ return [] unless str
361
+
362
+ validate_properties(str)
363
+ str.split("\n").filter_map do |line|
364
+ line = line.strip
365
+ next nil if line.empty?
366
+
367
+ key, rest = line.split(':', 2)
368
+ next nil unless key
369
+
370
+ tokens = rest&.strip&.split(/\s+/) || []
371
+ Model::Calculation::Property.new(
372
+ title: key.strip,
373
+ value: tokens[0],
374
+ units: tokens[1]
375
+ )
376
+ end
377
+ end
378
+
379
+ def validate_properties(str)
380
+ str.split("\n").each_with_index do |line, idx|
381
+ stripped = line.strip
382
+ next if stripped.empty?
383
+ next if stripped.include?(':')
384
+
385
+ raise AsciiChem::ParseError,
386
+ "calculation property on line #{idx + 1} is missing ':' separator: #{stripped.inspect}"
387
+ end
388
+ end
389
+ end
390
+
391
+ # Builds a ZMatrix from grammar captures.
392
+ # Each body line: atom [ref1 distance] [ref2 angle] [ref3 dihedral]
393
+ class ZMatrixBuilder
394
+ include BuilderHelpers
395
+ def initialize(body_str)
396
+ @body_str = strip_value(body_str)
397
+ end
398
+
399
+ def build
400
+ Model::ZMatrix.new(rows: parse_rows(@body_str))
401
+ end
402
+
403
+ private
404
+
405
+ def parse_rows(str)
406
+ return [] unless str
407
+
408
+ str.split("\n").filter_map { |line| parse_row(line.strip) }
409
+ end
410
+
411
+ def parse_row(line)
412
+ return nil if line.empty?
413
+
414
+ tokens = line.split(/\s+/)
415
+ Model::ZMatrix::ZRow.new(
416
+ atom: tokens[0],
417
+ ref1: tokens[1],
418
+ distance: tokens[2],
419
+ ref2: tokens[3],
420
+ angle: tokens[4],
421
+ ref3: tokens[5],
422
+ dihedral: tokens[6]
423
+ )
424
+ end
425
+ end
426
+
427
+ # Builds a Mechanism from grammar captures.
428
+ # Each body line: key: value (step1: reaction, spectator: ion)
429
+ class MechanismBuilder
430
+ include BuilderHelpers
431
+ def initialize(body_str)
432
+ @body_str = strip_value(body_str)
433
+ end
434
+
435
+ def build
436
+ steps = []
437
+ spectators = []
438
+ parse_entries(@body_str).each do |key, value|
439
+ if key == 'spectator'
440
+ spectators.concat(value.split(/\s+/).map(&:strip))
441
+ else
442
+ steps << Model::Mechanism::Step.new(label: key, reaction: value)
443
+ end
444
+ end
445
+ Model::Mechanism.new(steps: steps, spectators: spectators)
446
+ end
447
+
448
+ private
449
+
450
+ def parse_entries(str)
451
+ return [] unless str
452
+
453
+ validate_entries(str)
454
+ str.split("\n").filter_map do |line|
455
+ line = line.strip
456
+ next nil if line.empty?
457
+
458
+ key, val = line.split(':', 2)
459
+ next nil unless key && val
460
+
461
+ [key.strip, val.strip]
462
+ end
463
+ end
464
+
465
+ def validate_entries(str)
466
+ str.split("\n").each_with_index do |line, idx|
467
+ stripped = line.strip
468
+ next if stripped.empty?
469
+ next if stripped.include?(':')
470
+
471
+ raise AsciiChem::ParseError,
472
+ "mechanism entry on line #{idx + 1} is missing ':' separator: #{stripped.inspect}"
473
+ end
474
+ end
475
+ end
476
+
477
+ # Strips the surrounding `"..."` quotes from a quoted text match.
478
+ # Used by both `text_run` and `group_text_run` rules so the
479
+ # model never carries the delimiters — the formatter re-adds them
480
+ # on output.
481
+ module TextNormaliser
482
+ def self.strip_quotes(s)
483
+ s.start_with?('"') && s.end_with?('"') ? s[1..-2] : s
484
+ end
485
+ end
486
+
487
+ # Applies `@key("value")` molecule annotations to a built
488
+ # Model::Molecule. The annotation type determines which field is
489
+ # set: name → names[], inchi/smiles/cas → identifiers[], etc.
490
+ class AnnotationApplicator
491
+ IDENTIFIER_TYPES = %w[inchi smiles cas iupac cid chebi].freeze
492
+
493
+ def initialize(molecule, annotations)
494
+ @molecule = molecule
495
+ @annotations = annotations
496
+ end
497
+
498
+ def apply
499
+ @annotations.each { |ann| apply_one(ann) }
500
+ end
501
+
502
+ private
503
+
504
+ def apply_one(ann)
505
+ if ann[:meta_key]
506
+ @molecule.metadata << Model::Molecule::Meta.new(
507
+ name: ann[:meta_key].to_s,
508
+ content: ann[:meta_value].to_s
509
+ )
510
+ return
511
+ end
512
+ type = ann[:ann_type].to_s
513
+ value = ann[:ann_value].to_s
514
+ case type
515
+ when 'name'
516
+ @molecule.names << Model::Name.new(content: value)
517
+ when 'title'
518
+ @molecule.title = value
519
+ when 'formula'
520
+ @molecule.formulas << Model::Molecule::Formula.new(concise: value)
521
+ when 'label'
522
+ @molecule.labels << Model::Molecule::Label.new(value: value)
523
+ when *IDENTIFIER_TYPES
524
+ @molecule.identifiers << Model::Identifier.new(value: value, convention: type)
525
+ else
526
+ @molecule.properties << Model::Molecule::Property.new(title: type, value: value)
527
+ end
528
+ end
529
+ end
530
+
531
+ # Lifts the inner nodes of a `formula` capture into a flat array.
532
+ class FormulaNormaliser
533
+ def initialize(subtree)
534
+ @subtree = subtree
535
+ end
536
+
537
+ def to_a
538
+ case @subtree
539
+ when Array then @subtree
540
+ when nil then []
541
+ else [@subtree]
542
+ end
543
+ end
544
+ end
545
+
546
+ # Coefficient is captured as `{ value: "2" }`; we want just the
547
+ # string. nil when no coefficient was present.
548
+ class CoefficientNormaliser
549
+ def initialize(node)
550
+ @node = node
551
+ end
552
+
553
+ def to_s
554
+ return nil if @node.nil?
555
+
556
+ v = @node.is_a?(Hash) ? @node[:value] : @node
557
+ v&.to_s
558
+ end
559
+ end
560
+
561
+ # Multiplicity is captured as `_2` (marker + digits) or nil. Strip
562
+ # the leading underscore.
563
+ class MultiplicityNormaliser
564
+ def initialize(node)
565
+ @node = node
566
+ end
567
+
568
+ def to_s
569
+ return nil if @node.nil?
570
+
571
+ s = @node.to_s
572
+ s = s[1..] if s.start_with?('_')
573
+ s
574
+ end
575
+ end
576
+
577
+ # Builds an Atom, normalising nil/empty strings and pulling apart
578
+ # composite superscripts (charge vs oxidation state vs raw). Exactly
579
+ # one of { charge, oxidation_state, superscript } is set on the
580
+ # resulting atom — they are mutually exclusive views of the
581
+ # superscript position.
582
+ class AtomBuilder
583
+ # Construct from a parse-tree hash. The grammar wraps atoms in
584
+ # `.as(:atom)`, so the transform receives one hash with any of
585
+ # these keys present: `:element`, `:isotope`, `:subscript`,
586
+ # `:superscript`, `:lone_pairs`, `:radical_electrons`,
587
+ # `:ring_closures`. Absent keys default to nil. Lewis markers
588
+ # (`:lone_pairs`, `:radical_electrons`) are captured as
589
+ # colon/dot strings whose length is the count.
590
+ def self.from_parse_tree(attrs)
591
+ hash = attrs.is_a?(Hash) ? attrs : {}
592
+ new(
593
+ hash[:element],
594
+ isotope: hash[:isotope],
595
+ subscript: hash[:subscript],
596
+ superscript: hash[:superscript],
597
+ lone_pairs: lewis_count(hash[:lone_pairs]),
598
+ radical_electrons: lewis_count(hash[:radical_electrons]),
599
+ ring_closures: ring_closures_string(hash[:ring_closures]),
600
+ x2: float_or_nil(hash[:x2]),
601
+ y2: float_or_nil(hash[:y2]),
602
+ z2: float_or_nil(hash[:z2]),
603
+ atom_parity: hash[:atom_parity]&.to_s,
604
+ spin_multiplicity: hash[:spin_multiplicity]&.to_s,
605
+ atom_title: hash[:atom_title]&.to_s,
606
+ x_fract: float_or_nil(hash[:x_fract]),
607
+ y_fract: float_or_nil(hash[:y_fract]),
608
+ z_fract: float_or_nil(hash[:z_fract])
609
+ )
610
+ end
611
+
612
+ def self.float_or_nil(value)
613
+ return nil if value.nil?
614
+
615
+ value.to_s.to_f
616
+ end
617
+ private_class_method :float_or_nil
618
+
619
+ # Convert a Lewis marker (string of `:` or `.`) to its count.
620
+ # nil/empty → nil.
621
+ def self.lewis_count(value)
622
+ return nil if value.nil?
623
+
624
+ length = value.to_s.length
625
+ length.positive? ? length : nil
626
+ end
627
+ private_class_method :lewis_count
628
+
629
+ # Normalise the ring-closures capture to a string or nil.
630
+ # parslet delivers the matched digit string or nil if `.maybe`
631
+ # produced nothing.
632
+ def self.ring_closures_string(value)
633
+ s = value.to_s
634
+ s.empty? ? nil : s
635
+ end
636
+ private_class_method :ring_closures_string
637
+
638
+ def initialize(element, isotope: nil, subscript: nil, superscript: nil,
639
+ lone_pairs: nil, radical_electrons: nil,
640
+ ring_closures: nil,
641
+ x2: nil, y2: nil, z2: nil, atom_parity: nil,
642
+ spin_multiplicity: nil, atom_title: nil,
643
+ x_fract: nil, y_fract: nil, z_fract: nil)
644
+ @element = element
645
+ @isotope = isotope
646
+ @subscript = subscript
647
+ @superscript = superscript
648
+ @lone_pairs = lone_pairs
649
+ @radical_electrons = radical_electrons
650
+ @ring_closures = ring_closures
651
+ @x2 = x2
652
+ @y2 = y2
653
+ @z2 = z2
654
+ @atom_parity = atom_parity
655
+ @spin_multiplicity = spin_multiplicity
656
+ @atom_title = atom_title
657
+ @x_fract = x_fract
658
+ @y_fract = y_fract
659
+ @z_fract = z_fract
660
+ end
661
+
662
+ def build
663
+ charge = detected_charge
664
+ oxidation = detected_oxidation_state
665
+ Model::Atom.new(
666
+ element: @element.to_s,
667
+ isotope: strip_marker(@isotope),
668
+ subscript: strip_marker(@subscript, '_'),
669
+ superscript: raw_superscript(charge, oxidation),
670
+ charge: charge,
671
+ oxidation_state: oxidation,
672
+ lone_pairs: positive_int(@lone_pairs),
673
+ radical_electrons: positive_int(@radical_electrons),
674
+ ring_closures: @ring_closures,
675
+ x2: @x2,
676
+ y2: @y2,
677
+ z2: @z2,
678
+ atom_parity: @atom_parity,
679
+ spin_multiplicity: @spin_multiplicity,
680
+ atom_title: @atom_title,
681
+ x_fract: @x_fract,
682
+ y_fract: @y_fract,
683
+ z_fract: @z_fract
684
+ )
685
+ end
686
+
687
+ private
688
+
689
+ def positive_int(value)
690
+ return nil if value.nil?
691
+
692
+ n = value.to_i
693
+ n.positive? ? n : nil
694
+ end
695
+
696
+ # Returns the raw superscript only when it isn't a charge or
697
+ # oxidation state (otherwise those carry the info).
698
+ def raw_superscript(charge, oxidation)
699
+ return nil if charge || oxidation
700
+
701
+ strip_marker(@superscript, '^')
702
+ end
703
+
704
+ def detected_charge
705
+ s = strip_marker(@superscript, '^')
706
+ return nil unless s
707
+
708
+ match = s.match(/\A(?<n>\d*)(?<sign>[+-])\z/) ||
709
+ s.match(/\A(?<sign>[+-])(?<n>\d*)\z/)
710
+ return nil unless match
711
+
712
+ n = match[:n]
713
+ sign = match[:sign]
714
+ n.empty? ? sign : "#{n}#{sign}"
715
+ end
716
+
717
+ def detected_oxidation_state
718
+ s = strip_marker(@superscript, '^')
719
+ return nil unless s
720
+
721
+ match = s.match(/\A\(([IVXLCDM]+)\)\z/)
722
+ match && match[1]
723
+ end
724
+
725
+ # Strip a leading marker char (`^` or `_`) from the captured
726
+ # value. nil/empty -> nil.
727
+ def strip_marker(value, marker = nil)
728
+ return nil if value.nil?
729
+
730
+ s = value.to_s
731
+ s = s[1..] if marker && s.start_with?(marker)
732
+ s = s[1..] if s.start_with?('^', '_')
733
+ s.empty? ? nil : s
734
+ end
735
+ end
736
+
737
+ # Builds a Reaction, mapping the arrow hash into arrow kind and
738
+ # conditions.
739
+ class ReactionBuilder
740
+ ARROW_KINDS = {
741
+ '<=>' => :equilibrium,
742
+ '<->' => :resonance,
743
+ '->' => :forward,
744
+ '<-' => :reverse
745
+ }.freeze
746
+
747
+ def initialize(reactants, arrow, products)
748
+ @reactants = reactants
749
+ @arrow = arrow
750
+ @products = products
751
+ end
752
+
753
+ def build
754
+ Model::Reaction.new(
755
+ reactants: as_terms(@reactants),
756
+ products: as_terms(@products),
757
+ arrow: kind,
758
+ conditions: conditions
759
+ )
760
+ end
761
+
762
+ def kind
763
+ ARROW_KINDS.fetch(@arrow[:kind].to_s, :forward)
764
+ end
765
+
766
+ def conditions
767
+ above = condition_text(@arrow[:above])
768
+ below = condition_text(@arrow[:below])
769
+ return nil unless above || below
770
+
771
+ Model::Reaction::Conditions.new(above: above, below: below)
772
+ end
773
+
774
+ def condition_text(node)
775
+ return nil if node.nil?
776
+
777
+ text = node.is_a?(Hash) ? node[:text] : node
778
+ text&.to_s
779
+ end
780
+
781
+ def as_terms(side)
782
+ case side
783
+ when Array then side
784
+ when nil then []
785
+ else [side]
786
+ end
787
+ end
788
+ end
789
+
790
+ # Builds a ReactionCascade. Parslet delivers the cascade shape in
791
+ # one of two forms depending on how the grammar's sequence
792
+ # flattened:
793
+ # - Array of segment hashes:
794
+ # [{ first: <Reaction> }, { arrow:, products: }, ...]
795
+ # - Single hash with array values:
796
+ # { first: <Reaction>, arrow: [...], products: [...] }
797
+ # Normalise to a canonical form before building.
798
+ class CascadeBuilder
799
+ def initialize(data)
800
+ @first, @tail = canonicalise(data)
801
+ end
802
+
803
+ def build
804
+ steps = [@first]
805
+ @tail.each do |arrow, products|
806
+ prev_products = steps.last.products
807
+ steps << ReactionBuilder.new(prev_products, arrow, products).build
808
+ end
809
+ Model::ReactionCascade.new(steps: steps)
810
+ end
811
+
812
+ private
813
+
814
+ def canonicalise(data)
815
+ # The grammar wraps the whole leg repeat in one :segments
816
+ # capture (electron-config pattern), which every engine
817
+ # arrays; parslet additionally arrays the first/segments
818
+ # pair. Both reduce to [first, [[arrow, products], ...]].
819
+ hash = data.is_a?(Array) ? data.find { |s| s.is_a?(Hash) && s.key?(:first) } : data
820
+ first = hash[:first]
821
+ [first, wrap(hash[:segments]).map { |seg| [seg[:arrow], seg[:products]] }]
822
+ end
823
+
824
+ def wrap(value)
825
+
826
+
827
+ return [] if value.nil?
828
+
829
+ value.is_a?(Array) ? value : [value]
830
+ end
831
+ end
832
+
833
+ # Maps the captured stereo letter to the model's stereo symbol.
834
+ module StereoNormaliser
835
+ def self.normalise(letter)
836
+ Model::Molecule::STEREO_LETTERS.fetch(letter.to_s, :unknown)
837
+ end
838
+ end
839
+
840
+ # Maps the literal bracket character to the model's bracket symbol.
841
+ module Group
842
+ def self.bracket_kind(char)
843
+ case char
844
+ when '(' then :paren
845
+ when '[' then :square
846
+ when '{' then :brace
847
+ else :paren
848
+ end
849
+ end
850
+ end
851
+
852
+ RULES
853
+ end
854
+ end
855
+ end