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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +21 -0
- data/.gitignore +14 -0
- data/.rubocop.yml +50 -0
- data/ARCHITECTURE.adoc +239 -0
- data/CHANGELOG.md +17 -0
- data/CLAUDE.md +318 -0
- data/Gemfile +14 -0
- data/LICENSE +24 -0
- data/README.adoc +55 -0
- data/RELEASING.md +102 -0
- data/Rakefile +8 -0
- data/asciichem.gemspec +39 -0
- data/benchmarks/RESULTS.md +49 -0
- data/benchmarks/benchmark.rb +106 -0
- data/exe/asciichem +6 -0
- data/lib/asciichem/cli.rb +96 -0
- data/lib/asciichem/cml/extensions.rb +360 -0
- data/lib/asciichem/cml/group_extensions.rb +290 -0
- data/lib/asciichem/cml/translator.rb +69 -0
- data/lib/asciichem/cml.rb +40 -0
- data/lib/asciichem/errors.rb +9 -0
- data/lib/asciichem/formatter/base.rb +21 -0
- data/lib/asciichem/formatter/html.rb +111 -0
- data/lib/asciichem/formatter/latex.rb +173 -0
- data/lib/asciichem/formatter/mathml.rb +309 -0
- data/lib/asciichem/formatter/structural_svg.rb +286 -0
- data/lib/asciichem/formatter/svg.rb +141 -0
- data/lib/asciichem/formatter/text.rb +143 -0
- data/lib/asciichem/formatter.rb +43 -0
- data/lib/asciichem/grammar.rb +344 -0
- data/lib/asciichem/greek.rb +80 -0
- data/lib/asciichem/layout.rb +313 -0
- data/lib/asciichem/linter/balance_check.rb +103 -0
- data/lib/asciichem/linter/base.rb +52 -0
- data/lib/asciichem/linter/bracket_balance_check.rb +44 -0
- data/lib/asciichem/linter/diagnostic.rb +16 -0
- data/lib/asciichem/linter/element_validation_check.rb +39 -0
- data/lib/asciichem/linter/isotope_sanity_check.rb +47 -0
- data/lib/asciichem/linter/registry.rb +31 -0
- data/lib/asciichem/linter/unclosed_ring_check.rb +27 -0
- data/lib/asciichem/linter/valence_check.rb +91 -0
- data/lib/asciichem/linter.rb +40 -0
- data/lib/asciichem/model/atom.rb +73 -0
- data/lib/asciichem/model/bond.rb +41 -0
- data/lib/asciichem/model/electron_configuration.rb +36 -0
- data/lib/asciichem/model/embedded_math.rb +26 -0
- data/lib/asciichem/model/formula.rb +31 -0
- data/lib/asciichem/model/group.rb +50 -0
- data/lib/asciichem/model/identifier.rb +14 -0
- data/lib/asciichem/model/molecule.rb +70 -0
- data/lib/asciichem/model/name.rb +15 -0
- data/lib/asciichem/model/node.rb +100 -0
- data/lib/asciichem/model/reaction.rb +49 -0
- data/lib/asciichem/model/reaction_cascade.rb +33 -0
- data/lib/asciichem/model/text.rb +23 -0
- data/lib/asciichem/model.rb +23 -0
- data/lib/asciichem/model_adapter/from_canonical.rb +272 -0
- data/lib/asciichem/model_adapter/to_canonical.rb +429 -0
- data/lib/asciichem/model_adapter.rb +56 -0
- data/lib/asciichem/parser.rb +36 -0
- data/lib/asciichem/periodic_table.rb +112 -0
- data/lib/asciichem/ring_bonds.rb +68 -0
- data/lib/asciichem/transform.rb +513 -0
- data/lib/asciichem/version.rb +5 -0
- data/lib/asciichem/xml_builder.rb +9 -0
- data/lib/asciichem.rb +34 -0
- 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,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
|