asciichem 0.3.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 (68) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +21 -0
  3. data/.gitignore +14 -0
  4. data/.rubocop.yml +50 -0
  5. data/ARCHITECTURE.adoc +239 -0
  6. data/CHANGELOG.md +17 -0
  7. data/CLAUDE.md +318 -0
  8. data/Gemfile +14 -0
  9. data/LICENSE +24 -0
  10. data/README.adoc +55 -0
  11. data/RELEASING.md +102 -0
  12. data/Rakefile +8 -0
  13. data/asciichem.gemspec +39 -0
  14. data/benchmarks/RESULTS.md +49 -0
  15. data/benchmarks/benchmark.rb +106 -0
  16. data/exe/asciichem +6 -0
  17. data/lib/asciichem/cli.rb +96 -0
  18. data/lib/asciichem/cml/extensions.rb +360 -0
  19. data/lib/asciichem/cml/group_extensions.rb +290 -0
  20. data/lib/asciichem/cml/translator.rb +69 -0
  21. data/lib/asciichem/cml.rb +40 -0
  22. data/lib/asciichem/errors.rb +9 -0
  23. data/lib/asciichem/formatter/base.rb +21 -0
  24. data/lib/asciichem/formatter/html.rb +111 -0
  25. data/lib/asciichem/formatter/latex.rb +173 -0
  26. data/lib/asciichem/formatter/mathml.rb +309 -0
  27. data/lib/asciichem/formatter/structural_svg.rb +286 -0
  28. data/lib/asciichem/formatter/svg.rb +141 -0
  29. data/lib/asciichem/formatter/text.rb +143 -0
  30. data/lib/asciichem/formatter.rb +43 -0
  31. data/lib/asciichem/grammar.rb +344 -0
  32. data/lib/asciichem/greek.rb +80 -0
  33. data/lib/asciichem/layout.rb +313 -0
  34. data/lib/asciichem/linter/balance_check.rb +103 -0
  35. data/lib/asciichem/linter/base.rb +52 -0
  36. data/lib/asciichem/linter/bracket_balance_check.rb +44 -0
  37. data/lib/asciichem/linter/diagnostic.rb +16 -0
  38. data/lib/asciichem/linter/element_validation_check.rb +39 -0
  39. data/lib/asciichem/linter/isotope_sanity_check.rb +47 -0
  40. data/lib/asciichem/linter/registry.rb +31 -0
  41. data/lib/asciichem/linter/unclosed_ring_check.rb +27 -0
  42. data/lib/asciichem/linter/valence_check.rb +91 -0
  43. data/lib/asciichem/linter.rb +40 -0
  44. data/lib/asciichem/model/atom.rb +73 -0
  45. data/lib/asciichem/model/bond.rb +41 -0
  46. data/lib/asciichem/model/electron_configuration.rb +36 -0
  47. data/lib/asciichem/model/embedded_math.rb +26 -0
  48. data/lib/asciichem/model/formula.rb +31 -0
  49. data/lib/asciichem/model/group.rb +50 -0
  50. data/lib/asciichem/model/identifier.rb +14 -0
  51. data/lib/asciichem/model/molecule.rb +70 -0
  52. data/lib/asciichem/model/name.rb +15 -0
  53. data/lib/asciichem/model/node.rb +100 -0
  54. data/lib/asciichem/model/reaction.rb +49 -0
  55. data/lib/asciichem/model/reaction_cascade.rb +33 -0
  56. data/lib/asciichem/model/text.rb +23 -0
  57. data/lib/asciichem/model.rb +23 -0
  58. data/lib/asciichem/model_adapter/from_canonical.rb +272 -0
  59. data/lib/asciichem/model_adapter/to_canonical.rb +429 -0
  60. data/lib/asciichem/model_adapter.rb +56 -0
  61. data/lib/asciichem/parser.rb +36 -0
  62. data/lib/asciichem/periodic_table.rb +112 -0
  63. data/lib/asciichem/ring_bonds.rb +68 -0
  64. data/lib/asciichem/transform.rb +513 -0
  65. data/lib/asciichem/version.rb +5 -0
  66. data/lib/asciichem/xml_builder.rb +9 -0
  67. data/lib/asciichem.rb +34 -0
  68. metadata +197 -0
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ # Derives ring bonds from atoms with matching `ring_closures` digits.
5
+ # Single source of truth for the "find ring bond pairs" algorithm —
6
+ # used by the Layout walker (to emit additional edges), the
7
+ # ModelAdapter walker (to emit additional canonical bonds), and the
8
+ # UnclosedRingCheck linter (to flag unmatched digits).
9
+ #
10
+ # Algorithm: walk atoms in source order. For each digit on each atom,
11
+ # if we've seen it before (open ring), the current atom closes the
12
+ # ring — yield a RingBond pairing them, and clear the open record.
13
+ # Otherwise, record the current atom as the ring's opener.
14
+ #
15
+ # Multiple digits on one atom (`C12`) open/close multiple rings in
16
+ # parallel. Digit "0" through "9" are supported in any order.
17
+ module RingBonds
18
+ RingBond = Struct.new(:digit, :from_atom, :to_atom, keyword_init: true)
19
+
20
+ # Yields each RingBond to the block. Walks the molecule in source
21
+ # order so `from_atom` always precedes `to_atom`.
22
+ def self.each_in(molecule)
23
+ open_rings = {}
24
+ walk_atoms(molecule) do |atom|
25
+ next unless atom.ring_closures
26
+
27
+ atom.ring_closures.to_s.each_char do |digit|
28
+ if open_rings.key?(digit)
29
+ yield RingBond.new(digit: digit, from_atom: open_rings[digit], to_atom: atom)
30
+ open_rings.delete(digit)
31
+ else
32
+ open_rings[digit] = atom
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ # Returns atoms whose ring_closures digits have no matching partner.
39
+ # Used by the UnclosedRingCheck linter.
40
+ def self.unclosed_atoms(molecule)
41
+ open_rings = {}
42
+ unclosed = []
43
+ walk_atoms(molecule) do |atom|
44
+ next unless atom.ring_closures
45
+
46
+ atom.ring_closures.to_s.each_char do |digit|
47
+ if open_rings.key?(digit)
48
+ open_rings.delete(digit)
49
+ else
50
+ open_rings[digit] = atom
51
+ end
52
+ end
53
+ end
54
+ open_rings.each_value { |atom| unclosed << atom }
55
+ unclosed
56
+ end
57
+
58
+ def self.walk_atoms(node, &block)
59
+ case node
60
+ when AsciiChem::Model::Atom
61
+ block.call(node)
62
+ when AsciiChem::Model::Molecule, AsciiChem::Model::Group
63
+ node.nodes.each { |child| walk_atoms(child, &block) }
64
+ end
65
+ end
66
+ private_class_method :walk_atoms
67
+ end
68
+ end
@@ -0,0 +1,513 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parslet'
4
+
5
+ module AsciiChem
6
+ # Converts a parse tree (from AsciiChem::Grammar) into a tree of
7
+ # AsciiChem::Model instances.
8
+ #
9
+ # The transform contains minimal logic — it maps hash shapes to
10
+ # constructor calls. Anything semantically tricky (e.g. distinguishing
11
+ # isotope prefix from suffix charge) is encoded in the grammar, not
12
+ # here.
13
+ class Transform < Parslet::Transform
14
+ # -- top level --------------------------------------------------------
15
+
16
+ rule(formula: subtree(:subtree)) do
17
+ nodes = FormulaNormaliser.new(subtree).to_a
18
+ Model::Formula.new(nodes: nodes)
19
+ end
20
+
21
+ # -- text -------------------------------------------------------------
22
+
23
+ rule(text_run: simple(:text)) do
24
+ Model::Text.new(content: TextNormaliser.strip_quotes(text.to_s))
25
+ end
26
+
27
+ rule(group_text_run: simple(:text)) do
28
+ Model::Text.new(content: TextNormaliser.strip_quotes(text.to_s))
29
+ end
30
+
31
+ # -- embedded math ---------------------------------------------------
32
+
33
+ rule(math_source: simple(:source)) do
34
+ formula = Plurimath::Asciimath.new(source.to_s).to_formula
35
+ Model::EmbeddedMath.new(formula: formula, source: source.to_s)
36
+ end
37
+
38
+ # -- atoms -----------------------------------------------------------
39
+ #
40
+ # One rule covers every atom shape. The grammar wraps atoms in
41
+ # `.as(:atom)`, so the transform sees `{ atom: { element:...,
42
+ # isotope:..., subscript:..., superscript:..., lone_pairs:...,
43
+ # radical_electrons:... } }` with any of the optional keys absent.
44
+ # AtomBuilder.from_parse_tree normalises the variations.
45
+ rule(atom: subtree(:attrs)) do
46
+ AtomBuilder.from_parse_tree(attrs).build
47
+ end
48
+
49
+ # -- bonds ------------------------------------------------------------
50
+
51
+ rule(quadruple: simple(:_q)) { Model::Bond.new(kind: :quadruple) }
52
+ rule(triple: simple(:_t)) { Model::Bond.new(kind: :triple) }
53
+ rule(wedge: simple(:_w)) { Model::Bond.new(kind: :wedge) }
54
+ rule(hash: simple(:_h)) { Model::Bond.new(kind: :hash) }
55
+ rule(dative: simple(:_d)) { Model::Bond.new(kind: :dative) }
56
+ rule(wavy: simple(:_wv)) { Model::Bond.new(kind: :wavy) }
57
+ rule(double: simple(:_d2)) { Model::Bond.new(kind: :double) }
58
+ rule(single: simple(:_s)) { Model::Bond.new(kind: :single) }
59
+
60
+ # -- molecules -------------------------------------------------------
61
+
62
+ rule(stereo: simple(:letter),
63
+ coefficient: subtree(:coef),
64
+ units: subtree(:units)) do
65
+ Model::Molecule.new(
66
+ coefficient: CoefficientNormaliser.new(coef).to_s,
67
+ nodes: Array(units),
68
+ stereo: StereoNormaliser.normalise(letter)
69
+ )
70
+ end
71
+
72
+ rule(stereo: simple(:letter), units: subtree(:units)) do
73
+ Model::Molecule.new(
74
+ nodes: Array(units),
75
+ stereo: StereoNormaliser.normalise(letter)
76
+ )
77
+ end
78
+
79
+ rule(coefficient: subtree(:coef), units: subtree(:units)) do
80
+ Model::Molecule.new(
81
+ coefficient: CoefficientNormaliser.new(coef).to_s,
82
+ nodes: Array(units)
83
+ )
84
+ end
85
+
86
+ rule(units: subtree(:units)) do
87
+ Model::Molecule.new(nodes: Array(units))
88
+ end
89
+
90
+ # -- annotated molecules -------------------------------------------
91
+ #
92
+ # An annotated molecule wraps a built Model::Molecule with
93
+ # `@key("value")` metadata annotations. The grammar produces
94
+ # `{ mol: <Molecule>, annotations: [{ann_type:, ann_value:}, ...] }`.
95
+ # The transform receives the already-built Molecule (parslet
96
+ # transforms bottom-up) and applies the annotations.
97
+
98
+ rule(mol: simple(:mol), annotations: subtree(:anns)) do
99
+ AnnotationApplicator.new(mol, Array(anns)).apply
100
+ mol
101
+ end
102
+
103
+ # -- groups ----------------------------------------------------------
104
+
105
+ rule(open_bracket: simple(:open),
106
+ group_nodes: subtree(:nodes),
107
+ close_bracket: simple(:_close),
108
+ multiplicity: subtree(:mult)) do
109
+ Model::Group.new(
110
+ nodes: Array(nodes),
111
+ multiplicity: MultiplicityNormaliser.new(mult).to_s,
112
+ bracket: Group.bracket_kind(open.to_s)
113
+ )
114
+ end
115
+
116
+ # -- reactions -------------------------------------------------------
117
+
118
+ rule(reactants: subtree(:reactants),
119
+ arrow: subtree(:arrow),
120
+ products: subtree(:products)) do
121
+ ReactionBuilder.new(reactants, arrow, products).build
122
+ end
123
+
124
+ # -- reaction cascades ------------------------------------------------
125
+ #
126
+ # The grammar wraps a chain in `{ cascade: { first: <Reaction>,
127
+ # arrow:..., products:... (repeated) } }`. Fold into a single
128
+ # ReactionCascade with steps[0] = first and each subsequent step
129
+ # built from the previous step's products as reactants.
130
+
131
+ rule(cascade: subtree(:data)) do
132
+ CascadeBuilder.new(data).build
133
+ end
134
+
135
+ # -- electron configuration ------------------------------------------
136
+
137
+ # Inner rule: each `{orbital:..., occupancy:...}` hash becomes a
138
+ # `[orbital, occupancy]` string pair. The outer rule then collects
139
+ # these into an ElectronConfiguration.
140
+ rule(orbital: simple(:orb), occupancy: simple(:occ)) do
141
+ [orb.to_s, occ.to_s]
142
+ end
143
+
144
+ rule(electron_config: subtree(:raw_pairs)) do
145
+ pairs = Array(raw_pairs).map { |pair| Array(pair) }
146
+ Model::ElectronConfiguration.new(orbitals: pairs)
147
+ end
148
+
149
+ # -- internal helpers ------------------------------------------------
150
+
151
+ # Strips the surrounding `"..."` quotes from a quoted text match.
152
+ # Used by both `text_run` and `group_text_run` rules so the
153
+ # model never carries the delimiters — the formatter re-adds them
154
+ # on output.
155
+ module TextNormaliser
156
+ def self.strip_quotes(s)
157
+ s.start_with?('"') && s.end_with?('"') ? s[1..-2] : s
158
+ end
159
+ end
160
+
161
+ # Applies `@key("value")` molecule annotations to a built
162
+ # Model::Molecule. The annotation type determines which field is
163
+ # set: name → names[], inchi/smiles/cas → identifiers[], etc.
164
+ class AnnotationApplicator
165
+ IDENTIFIER_TYPES = %w[inchi smiles cas iupac cid chebi].freeze
166
+
167
+ def initialize(molecule, annotations)
168
+ @molecule = molecule
169
+ @annotations = annotations
170
+ end
171
+
172
+ def apply
173
+ @annotations.each { |ann| apply_one(ann) }
174
+ end
175
+
176
+ private
177
+
178
+ def apply_one(ann)
179
+ if ann[:meta_key]
180
+ @molecule.metadata << { name: ann[:meta_key].to_s,
181
+ content: ann[:meta_value].to_s }
182
+ return
183
+ end
184
+ type = ann[:ann_type].to_s
185
+ value = ann[:ann_value].to_s
186
+ case type
187
+ when 'name'
188
+ @molecule.names << Model::Name.new(content: value)
189
+ when 'title'
190
+ @molecule.title = value
191
+ when 'formula'
192
+ @molecule.formulas << { concise: value }
193
+ when 'label'
194
+ @molecule.labels << { value: value }
195
+ when *IDENTIFIER_TYPES
196
+ @molecule.identifiers << Model::Identifier.new(value: value, convention: type)
197
+ else
198
+ @molecule.properties << { title: type, value: value }
199
+ end
200
+ end
201
+ end
202
+
203
+ # Lifts the inner nodes of a `formula` capture into a flat array.
204
+ class FormulaNormaliser
205
+ def initialize(subtree)
206
+ @subtree = subtree
207
+ end
208
+
209
+ def to_a
210
+ case @subtree
211
+ when Array then @subtree
212
+ when nil then []
213
+ else [@subtree]
214
+ end
215
+ end
216
+ end
217
+
218
+ # Coefficient is captured as `{ value: "2" }`; we want just the
219
+ # string. nil when no coefficient was present.
220
+ class CoefficientNormaliser
221
+ def initialize(node)
222
+ @node = node
223
+ end
224
+
225
+ def to_s
226
+ return nil if @node.nil?
227
+
228
+ v = @node.is_a?(Hash) ? @node[:value] : @node
229
+ v&.to_s
230
+ end
231
+ end
232
+
233
+ # Multiplicity is captured as `_2` (marker + digits) or nil. Strip
234
+ # the leading underscore.
235
+ class MultiplicityNormaliser
236
+ def initialize(node)
237
+ @node = node
238
+ end
239
+
240
+ def to_s
241
+ return nil if @node.nil?
242
+
243
+ s = @node.to_s
244
+ s = s[1..] if s.start_with?('_')
245
+ s
246
+ end
247
+ end
248
+
249
+ # Builds an Atom, normalising nil/empty strings and pulling apart
250
+ # composite superscripts (charge vs oxidation state vs raw). Exactly
251
+ # one of { charge, oxidation_state, superscript } is set on the
252
+ # resulting atom — they are mutually exclusive views of the
253
+ # superscript position.
254
+ class AtomBuilder
255
+ # Construct from a parse-tree hash. The grammar wraps atoms in
256
+ # `.as(:atom)`, so the transform receives one hash with any of
257
+ # these keys present: `:element`, `:isotope`, `:subscript`,
258
+ # `:superscript`, `:lone_pairs`, `:radical_electrons`,
259
+ # `:ring_closures`. Absent keys default to nil. Lewis markers
260
+ # (`:lone_pairs`, `:radical_electrons`) are captured as
261
+ # colon/dot strings whose length is the count.
262
+ def self.from_parse_tree(attrs)
263
+ hash = attrs.is_a?(Hash) ? attrs : {}
264
+ new(
265
+ hash[:element],
266
+ isotope: hash[:isotope],
267
+ subscript: hash[:subscript],
268
+ superscript: hash[:superscript],
269
+ lone_pairs: lewis_count(hash[:lone_pairs]),
270
+ radical_electrons: lewis_count(hash[:radical_electrons]),
271
+ ring_closures: ring_closures_string(hash[:ring_closures]),
272
+ x2: float_or_nil(hash[:x2]),
273
+ y2: float_or_nil(hash[:y2]),
274
+ z2: float_or_nil(hash[:z2]),
275
+ atom_parity: hash[:atom_parity]&.to_s
276
+ )
277
+ end
278
+
279
+ def self.float_or_nil(value)
280
+ return nil if value.nil?
281
+
282
+ value.to_s.to_f
283
+ end
284
+ private_class_method :float_or_nil
285
+
286
+ # Convert a Lewis marker (string of `:` or `.`) to its count.
287
+ # nil/empty → nil.
288
+ def self.lewis_count(value)
289
+ return nil if value.nil?
290
+
291
+ length = value.to_s.length
292
+ length.positive? ? length : nil
293
+ end
294
+ private_class_method :lewis_count
295
+
296
+ # Normalise the ring-closures capture to a string or nil.
297
+ # parslet delivers the matched digit string or nil if `.maybe`
298
+ # produced nothing.
299
+ def self.ring_closures_string(value)
300
+ s = value.to_s
301
+ s.empty? ? nil : s
302
+ end
303
+ private_class_method :ring_closures_string
304
+
305
+ def initialize(element, isotope: nil, subscript: nil, superscript: nil,
306
+ lone_pairs: nil, radical_electrons: nil,
307
+ ring_closures: nil,
308
+ x2: nil, y2: nil, z2: nil, atom_parity: nil)
309
+ @element = element
310
+ @isotope = isotope
311
+ @subscript = subscript
312
+ @superscript = superscript
313
+ @lone_pairs = lone_pairs
314
+ @radical_electrons = radical_electrons
315
+ @ring_closures = ring_closures
316
+ @x2 = x2
317
+ @y2 = y2
318
+ @z2 = z2
319
+ @atom_parity = atom_parity
320
+ end
321
+
322
+ def build
323
+ charge = detected_charge
324
+ oxidation = detected_oxidation_state
325
+ Model::Atom.new(
326
+ element: @element.to_s,
327
+ isotope: strip_marker(@isotope),
328
+ subscript: strip_marker(@subscript, '_'),
329
+ superscript: raw_superscript(charge, oxidation),
330
+ charge: charge,
331
+ oxidation_state: oxidation,
332
+ lone_pairs: positive_int(@lone_pairs),
333
+ radical_electrons: positive_int(@radical_electrons),
334
+ ring_closures: @ring_closures,
335
+ x2: @x2,
336
+ y2: @y2,
337
+ z2: @z2,
338
+ atom_parity: @atom_parity
339
+ )
340
+ end
341
+
342
+ private
343
+
344
+ def positive_int(value)
345
+ return nil if value.nil?
346
+
347
+ n = value.to_i
348
+ n.positive? ? n : nil
349
+ end
350
+
351
+ # Returns the raw superscript only when it isn't a charge or
352
+ # oxidation state (otherwise those carry the info).
353
+ def raw_superscript(charge, oxidation)
354
+ return nil if charge || oxidation
355
+
356
+ strip_marker(@superscript, '^')
357
+ end
358
+
359
+ def detected_charge
360
+ s = strip_marker(@superscript, '^')
361
+ return nil unless s
362
+
363
+ match = s.match(/\A(?<n>\d*)(?<sign>[+-])\z/) ||
364
+ s.match(/\A(?<sign>[+-])(?<n>\d*)\z/)
365
+ return nil unless match
366
+
367
+ n = match[:n]
368
+ sign = match[:sign]
369
+ n.empty? ? sign : "#{n}#{sign}"
370
+ end
371
+
372
+ def detected_oxidation_state
373
+ s = strip_marker(@superscript, '^')
374
+ return nil unless s
375
+
376
+ match = s.match(/\A\(([IVXLCDM]+)\)\z/)
377
+ match && match[1]
378
+ end
379
+
380
+ # Strip a leading marker char (`^` or `_`) from the captured
381
+ # value. nil/empty -> nil.
382
+ def strip_marker(value, marker = nil)
383
+ return nil if value.nil?
384
+
385
+ s = value.to_s
386
+ s = s[1..] if marker && s.start_with?(marker)
387
+ s = s[1..] if s.start_with?('^', '_')
388
+ s.empty? ? nil : s
389
+ end
390
+ end
391
+
392
+ # Builds a Reaction, mapping the arrow hash into arrow kind and
393
+ # conditions.
394
+ class ReactionBuilder
395
+ ARROW_KINDS = {
396
+ '<=>' => :equilibrium,
397
+ '<->' => :resonance,
398
+ '->' => :forward,
399
+ '<-' => :reverse
400
+ }.freeze
401
+
402
+ def initialize(reactants, arrow, products)
403
+ @reactants = reactants
404
+ @arrow = arrow
405
+ @products = products
406
+ end
407
+
408
+ def build
409
+ Model::Reaction.new(
410
+ reactants: as_terms(@reactants),
411
+ products: as_terms(@products),
412
+ arrow: kind,
413
+ conditions: conditions
414
+ )
415
+ end
416
+
417
+ def kind
418
+ ARROW_KINDS.fetch(@arrow[:kind].to_s, :forward)
419
+ end
420
+
421
+ def conditions
422
+ above = condition_text(@arrow[:above])
423
+ below = condition_text(@arrow[:below])
424
+ return nil unless above || below
425
+
426
+ Model::Reaction::Conditions.new(above: above, below: below)
427
+ end
428
+
429
+ def condition_text(node)
430
+ return nil if node.nil?
431
+
432
+ text = node.is_a?(Hash) ? node[:text] : node
433
+ text&.to_s
434
+ end
435
+
436
+ def as_terms(side)
437
+ case side
438
+ when Array then side
439
+ when nil then []
440
+ else [side]
441
+ end
442
+ end
443
+ end
444
+
445
+ # Builds a ReactionCascade. Parslet delivers the cascade shape in
446
+ # one of two forms depending on how the grammar's sequence
447
+ # flattened:
448
+ # - Array of segment hashes:
449
+ # [{ first: <Reaction> }, { arrow:, products: }, ...]
450
+ # - Single hash with array values:
451
+ # { first: <Reaction>, arrow: [...], products: [...] }
452
+ # Normalise to a canonical form before building.
453
+ class CascadeBuilder
454
+ def initialize(data)
455
+ @first, @tail = canonicalise(data)
456
+ end
457
+
458
+ def build
459
+ steps = [@first]
460
+ @tail.each do |arrow, products|
461
+ prev_products = steps.last.products
462
+ steps << ReactionBuilder.new(prev_products, arrow, products).build
463
+ end
464
+ Model::ReactionCascade.new(steps: steps)
465
+ end
466
+
467
+ private
468
+
469
+ def canonicalise(data)
470
+ if data.is_a?(Array)
471
+ canonicalise_array(data)
472
+ else
473
+ canonicalise_hash(data)
474
+ end
475
+ end
476
+
477
+ def canonicalise_array(arr)
478
+ first = arr.find { |s| s.is_a?(Hash) && s.key?(:first) }[:first]
479
+ tail = arr
480
+ .select { |s| s.is_a?(Hash) && s.key?(:arrow) }
481
+ .map { |s| [s[:arrow], s[:products]] }
482
+ [first, tail]
483
+ end
484
+
485
+ def canonicalise_hash(hash)
486
+ first = hash[:first]
487
+ arrows = Array(hash[:arrow])
488
+ products = Array(hash[:products])
489
+ tail = arrows.zip(products)
490
+ [first, tail]
491
+ end
492
+ end
493
+
494
+ # Maps the captured stereo letter to the model's stereo symbol.
495
+ module StereoNormaliser
496
+ def self.normalise(letter)
497
+ Model::Molecule::STEREO_LETTERS.fetch(letter.to_s, :unknown)
498
+ end
499
+ end
500
+
501
+ # Maps the literal bracket character to the model's bracket symbol.
502
+ module Group
503
+ def self.bracket_kind(char)
504
+ case char
505
+ when '(' then :paren
506
+ when '[' then :square
507
+ when '{' then :brace
508
+ else :paren
509
+ end
510
+ end
511
+ end
512
+ end
513
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ VERSION = "0.3.0"
5
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Internal XML helper. Currently a thin marker module kept for parity
4
+ # with future formatters that need XML construction without Nokogiri
5
+ # (e.g. JRuby). The Mathml formatter uses Nokogiri directly.
6
+ module AsciiChem
7
+ module XmlBuilder
8
+ end
9
+ end
data/lib/asciichem.rb ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parslet"
4
+ require "plurimath"
5
+
6
+ # AsciiChem is an ASCII syntax for representing chemistry.
7
+ #
8
+ # Top-level entry points:
9
+ # AsciiChem.parse(text) # => AsciiChem::Model::Formula
10
+ # AsciiChem::Cli.start # CLI dispatch
11
+ module AsciiChem
12
+ autoload :Cli, "asciichem/cli"
13
+ autoload :Cml, "asciichem/cml"
14
+ autoload :Error, "asciichem/errors"
15
+ autoload :ParseError, "asciichem/errors"
16
+ autoload :FormatError, "asciichem/errors"
17
+ autoload :Formatter, "asciichem/formatter"
18
+ autoload :Grammar, "asciichem/grammar"
19
+ autoload :Greek, "asciichem/greek"
20
+ autoload :Layout, "asciichem/layout"
21
+ autoload :Linter, "asciichem/linter"
22
+ autoload :Model, "asciichem/model"
23
+ autoload :ModelAdapter, "asciichem/model_adapter"
24
+ autoload :Parser, "asciichem/parser"
25
+ autoload :PeriodicTable, "asciichem/periodic_table"
26
+ autoload :RingBonds, "asciichem/ring_bonds"
27
+ autoload :Transform, "asciichem/transform"
28
+ autoload :VERSION, "asciichem/version"
29
+ autoload :XmlBuilder, "asciichem/xml_builder"
30
+
31
+ def self.parse(text)
32
+ Parser.new(text).parse
33
+ end
34
+ end