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