asciichem 0.20.0 → 0.22.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 +4 -4
- data/CHANGELOG.md +39 -1
- data/Gemfile +1 -0
- data/asciichem.gemspec +1 -1
- data/lib/asciichem/cli.rb +34 -5
- data/lib/asciichem/formatter/structural_svg.rb +8 -0
- data/lib/asciichem/model/atom.rb +8 -3
- data/lib/asciichem/model/bond.rb +2 -1
- data/lib/asciichem/model/node.rb +12 -0
- data/lib/asciichem/molfile/parser.rb +169 -0
- data/lib/asciichem/molfile/writer.rb +91 -0
- data/lib/asciichem/molfile.rb +26 -0
- data/lib/asciichem/smiles/parser.rb +302 -0
- data/lib/asciichem/smiles/writer.rb +210 -0
- data/lib/asciichem/smiles.rb +45 -0
- data/lib/asciichem/structure/graph.rb +62 -0
- data/lib/asciichem/structure/linearizer.rb +67 -0
- data/lib/asciichem/structure.rb +20 -0
- data/lib/asciichem/version.rb +1 -1
- data/lib/asciichem/wire/core.rb +4 -0
- data/lib/asciichem/wire_adapter.rb +9 -2
- data/lib/asciichem.rb +16 -0
- data/scripts/update-model-schemas.sh +26 -0
- metadata +14 -4
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Smiles
|
|
5
|
+
# Recursive-descent parser for the supported SMILES subset.
|
|
6
|
+
# SMILES is a linear grammar, so a direct parser is clearer than
|
|
7
|
+
# a parse-tree + transform round trip; it builds the model
|
|
8
|
+
# through the shared Structure::Linearizer.
|
|
9
|
+
#
|
|
10
|
+
# v1 subset (each deferral raises an actionable ParseError):
|
|
11
|
+
# chirality `@`/`@@`, E/Z bond directions `/` `\`, wildcard `*`,
|
|
12
|
+
# reaction atom maps, queries.
|
|
13
|
+
class Parser
|
|
14
|
+
ORGANIC = %w[Cl Br B C N O P S F I].freeze
|
|
15
|
+
AROMATIC = %w[se as b c n o p s].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(source)
|
|
18
|
+
@source = source
|
|
19
|
+
@pos = 0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Returns a Model::Formula with one Molecule per dot component.
|
|
23
|
+
def parse
|
|
24
|
+
molecules = [component]
|
|
25
|
+
molecules << component while eat(".")
|
|
26
|
+
unless @pos == @source.length
|
|
27
|
+
raise ParseError,
|
|
28
|
+
"unexpected character #{peek.inspect} at position #{@pos} in #{@source.inspect}"
|
|
29
|
+
end
|
|
30
|
+
Model::Formula.new(nodes: molecules)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def component
|
|
36
|
+
@atoms = []
|
|
37
|
+
@adjacency = []
|
|
38
|
+
@open_rings = {}
|
|
39
|
+
@parent = nil
|
|
40
|
+
@pending_kind = nil
|
|
41
|
+
|
|
42
|
+
chain
|
|
43
|
+
|
|
44
|
+
unless @open_rings.empty?
|
|
45
|
+
raise ParseError,
|
|
46
|
+
"unclosed ring bond digit(s): #{@open_rings.keys.sort.join(', ')} in #{@source.inspect}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
edges = []
|
|
50
|
+
@adjacency.each_with_index do |neighbors, index|
|
|
51
|
+
neighbors.each { |to, kind| edges << Structure::Graph::Edge.new(from: index, to: to, kind: kind) if to > index }
|
|
52
|
+
end
|
|
53
|
+
Model::Molecule.new(nodes: Structure::Linearizer.new(atoms: @atoms, edges: edges).nodes)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# chain := atom (bond? atom | branch | ringbond)*
|
|
57
|
+
# Bare adjacency (no bond token) means a default bond.
|
|
58
|
+
def chain
|
|
59
|
+
atom_token
|
|
60
|
+
until eoc?
|
|
61
|
+
if peek == "("
|
|
62
|
+
branch
|
|
63
|
+
elsif bond_start?
|
|
64
|
+
kind = bond_token
|
|
65
|
+
if peek == "("
|
|
66
|
+
branch(kind)
|
|
67
|
+
elsif digit_start?
|
|
68
|
+
ringbond(kind)
|
|
69
|
+
else
|
|
70
|
+
@pending_kind = kind
|
|
71
|
+
atom_token
|
|
72
|
+
end
|
|
73
|
+
elsif digit_start?
|
|
74
|
+
ringbond(nil)
|
|
75
|
+
else
|
|
76
|
+
@pending_kind = nil
|
|
77
|
+
atom_token
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def branch(kind = nil)
|
|
83
|
+
expect("(")
|
|
84
|
+
# The bond may lead the branch ("C-(O)") or open it ("C(=O)").
|
|
85
|
+
kind = bond_token if kind.nil? && bond_start?
|
|
86
|
+
saved_parent = @parent
|
|
87
|
+
saved_pending = @pending_kind
|
|
88
|
+
@pending_kind = kind
|
|
89
|
+
chain
|
|
90
|
+
expect(")")
|
|
91
|
+
@parent = saved_parent
|
|
92
|
+
@pending_kind = saved_pending
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def ringbond(kind)
|
|
96
|
+
digit = ring_digit
|
|
97
|
+
if @open_rings.key?(digit)
|
|
98
|
+
opener = @open_rings.delete(digit)
|
|
99
|
+
# The model's ring-closure digits carry no bond kind, so an
|
|
100
|
+
# explicit kind that differs from the default (aromatic
|
|
101
|
+
# between aromatic atoms, otherwise single) cannot be
|
|
102
|
+
# represented — reject rather than silently downgrade.
|
|
103
|
+
if kind && kind != default_kind(opener, @parent)
|
|
104
|
+
raise ParseError,
|
|
105
|
+
"bonded ring closures (#{kind}) are not representable in the model's ring-closure form"
|
|
106
|
+
end
|
|
107
|
+
add_edge(opener, @parent, kind)
|
|
108
|
+
else
|
|
109
|
+
@open_rings[digit] = @parent
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def atom_token
|
|
114
|
+
atom = read_atom
|
|
115
|
+
index = @atoms.length
|
|
116
|
+
@atoms << atom
|
|
117
|
+
@adjacency[index] = {}
|
|
118
|
+
add_edge(@parent, index, @pending_kind) if @parent
|
|
119
|
+
@parent = index
|
|
120
|
+
@pending_kind = nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def add_edge(from, to, explicit)
|
|
124
|
+
kind = explicit || default_kind(from, to)
|
|
125
|
+
@adjacency[from][to] = kind
|
|
126
|
+
@adjacency[to][from] = kind
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def default_kind(from, to)
|
|
130
|
+
@atoms[from].aromatic && @atoms[to].aromatic ? :aromatic : :single
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# -- tokens ----------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def read_atom
|
|
136
|
+
return bracket_atom if peek == "["
|
|
137
|
+
|
|
138
|
+
two = @source[@pos, 2].to_s
|
|
139
|
+
if (symbol = ORGANIC.find { |s| two.start_with?(s) })
|
|
140
|
+
@pos += symbol.length
|
|
141
|
+
return Model::Atom.new(element: symbol)
|
|
142
|
+
end
|
|
143
|
+
if (symbol = AROMATIC.find { |s| two.start_with?(s) } || AROMATIC.find { |s| peek == s })
|
|
144
|
+
@pos += symbol.length
|
|
145
|
+
return Model::Atom.new(element: symbol.capitalize, aromatic: true)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
raise ParseError, "unexpected character #{peek.inspect} at position #{@pos} in #{@source.inspect}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def bracket_atom
|
|
152
|
+
expect("[")
|
|
153
|
+
isotope = digits
|
|
154
|
+
symbol = bracket_symbol
|
|
155
|
+
reject_chirality
|
|
156
|
+
hydrogens = hcount
|
|
157
|
+
charge = bracket_charge
|
|
158
|
+
skip_class
|
|
159
|
+
expect("]")
|
|
160
|
+
Model::Atom.new(
|
|
161
|
+
element: symbol.capitalize,
|
|
162
|
+
isotope: isotope,
|
|
163
|
+
charge: charge,
|
|
164
|
+
hydrogens: hydrogens,
|
|
165
|
+
aromatic: symbol.match?(/\A[a-z]/) || nil
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
AROMATIC_TWO_CHAR = %w[se as].freeze
|
|
170
|
+
|
|
171
|
+
def bracket_symbol
|
|
172
|
+
two = @source[@pos, 2].to_s
|
|
173
|
+
if AROMATIC_TWO_CHAR.include?(two)
|
|
174
|
+
@pos += 2
|
|
175
|
+
return two
|
|
176
|
+
end
|
|
177
|
+
if @source[@pos] =~ /[bcnops]/
|
|
178
|
+
sym = @source[@pos]
|
|
179
|
+
@pos += 1
|
|
180
|
+
return sym
|
|
181
|
+
end
|
|
182
|
+
if @source[@pos] =~ /[A-Z]/
|
|
183
|
+
sym = @source[@pos]
|
|
184
|
+
sym += @source[@pos + 1] if @source[@pos + 1] =~ /[a-z]/
|
|
185
|
+
@pos += sym.length
|
|
186
|
+
return sym
|
|
187
|
+
end
|
|
188
|
+
raise ParseError, "expected an element symbol at position #{@pos} in #{@source.inspect}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def reject_chirality
|
|
192
|
+
return unless peek == "@"
|
|
193
|
+
|
|
194
|
+
token = @source[@pos, 2] == "@@" ? "'@@'" : "'@'"
|
|
195
|
+
raise ParseError, "chirality #{token} is not supported in the v1 subset"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def hcount
|
|
199
|
+
return nil unless peek == "H"
|
|
200
|
+
|
|
201
|
+
@pos += 1
|
|
202
|
+
count = digits
|
|
203
|
+
count ? count.to_i : 1
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# "+" | "++" | "+n" | "-" | "--" | "-n" → number-then-sign.
|
|
207
|
+
def bracket_charge
|
|
208
|
+
sign = peek
|
|
209
|
+
return nil unless sign == "+" || sign == "-"
|
|
210
|
+
|
|
211
|
+
@pos += 1
|
|
212
|
+
second = @source[@pos].to_s
|
|
213
|
+
count =
|
|
214
|
+
if second == sign
|
|
215
|
+
@pos += 1
|
|
216
|
+
2
|
|
217
|
+
elsif second =~ /[0-9]/
|
|
218
|
+
digits.to_i
|
|
219
|
+
end
|
|
220
|
+
count&.positive? ? "#{count}#{sign}" : sign
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def skip_class
|
|
224
|
+
return unless peek == ":"
|
|
225
|
+
|
|
226
|
+
@pos += 1
|
|
227
|
+
digits
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def bond_token
|
|
231
|
+
ch = peek
|
|
232
|
+
kind = { "-" => :single, "=" => :double, "#" => :triple, "$" => :quadruple,
|
|
233
|
+
":" => :aromatic }[ch]
|
|
234
|
+
unless kind
|
|
235
|
+
if ch == "/" || ch == "\\"
|
|
236
|
+
raise ParseError,
|
|
237
|
+
"bond direction #{ch.inspect} (E/Z stereo) is not supported in the v1 subset"
|
|
238
|
+
end
|
|
239
|
+
raise ParseError, "expected a bond or atom at position #{@pos} in #{@source.inspect}"
|
|
240
|
+
end
|
|
241
|
+
@pos += 1
|
|
242
|
+
kind
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def ring_digit
|
|
246
|
+
if peek == "%"
|
|
247
|
+
@pos += 1
|
|
248
|
+
digit = @source[@pos, 2].to_s
|
|
249
|
+
raise ParseError, "malformed %nn ring closure" unless digit.match?(/\A\d\d\z/)
|
|
250
|
+
|
|
251
|
+
@pos += 2
|
|
252
|
+
digit
|
|
253
|
+
else
|
|
254
|
+
d = peek
|
|
255
|
+
raise ParseError, "expected ring digit at position #{@pos}" unless d =~ /[0-9]/
|
|
256
|
+
|
|
257
|
+
@pos += 1
|
|
258
|
+
d
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# -- character helpers -------------------------------------------------
|
|
263
|
+
|
|
264
|
+
def peek
|
|
265
|
+
@source[@pos]
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def digits
|
|
269
|
+
start = @pos
|
|
270
|
+
@pos += 1 while @source[@pos] =~ /[0-9]/
|
|
271
|
+
@pos == start ? nil : @source[start...@pos]
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def bond_start?
|
|
275
|
+
%w[- = # $ : / \\].include?(peek)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def digit_start?
|
|
279
|
+
peek =~ /[0-9%]/
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def eoc?
|
|
283
|
+
peek.nil? || %w[. )].include?(peek)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def eat(char)
|
|
287
|
+
return false unless peek == char
|
|
288
|
+
|
|
289
|
+
@pos += 1
|
|
290
|
+
true
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def expect(char)
|
|
294
|
+
unless peek == char
|
|
295
|
+
raise ParseError, "expected #{char.inspect} at position #{@pos} in #{@source.inspect}"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
@pos += 1
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Smiles
|
|
5
|
+
# Model molecule → deterministic SMILES.
|
|
6
|
+
#
|
|
7
|
+
# Deterministic rules (not a canonical-rank algorithm):
|
|
8
|
+
# - DFS from the first atom.
|
|
9
|
+
# - At each atom: ring-closure digits first, then branches
|
|
10
|
+
# (sorted), then the continuation chain.
|
|
11
|
+
# - The continuation child is chosen by: single/aromatic bond
|
|
12
|
+
# first (multiple bonds become branches, the way chemists write
|
|
13
|
+
# SMILES), then the largest unvisited subtree, then the lowest
|
|
14
|
+
# index.
|
|
15
|
+
# - Ring digits are assigned in encounter order (1..9).
|
|
16
|
+
#
|
|
17
|
+
# The stability property `write(parse(write(x))) == write(x)` and
|
|
18
|
+
# structural round-tripping are spec'd. Wedge/hash/dative/wavy
|
|
19
|
+
# bonds and formula-only molecules raise.
|
|
20
|
+
class Writer
|
|
21
|
+
LOWERCASE_AROMATIC = %w[C N O S P B Se As].freeze
|
|
22
|
+
ORGANIC = %w[B C N O P S F Cl Br I].freeze
|
|
23
|
+
BOND_TOKENS = {
|
|
24
|
+
single: "-", double: "=", triple: "#", quadruple: "$", aromatic: ":"
|
|
25
|
+
}.freeze
|
|
26
|
+
CONTINUATION_BONDS = %i[single aromatic].freeze
|
|
27
|
+
|
|
28
|
+
def initialize(molecule)
|
|
29
|
+
@molecule = molecule
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def write
|
|
33
|
+
atoms, edges = Structure::Graph.build(@molecule)
|
|
34
|
+
# A single-atom component is valid SMILES (water "O", ions);
|
|
35
|
+
# multiple atoms with no bonds is a formula, not a structure.
|
|
36
|
+
if edges.empty? && atoms.length > 1
|
|
37
|
+
raise ParseError, "molecule has no bonds — a formula is not a structure"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@atoms = atoms
|
|
41
|
+
build_adjacency(edges)
|
|
42
|
+
@visited = {}
|
|
43
|
+
@digits = Array.new(atoms.length) { [] }
|
|
44
|
+
@digit_by_edge = {}
|
|
45
|
+
@next_digit = 1
|
|
46
|
+
# Emission builds ordered units (atoms, literals); ring digits
|
|
47
|
+
# attach to per-atom lists so both endpoints carry them
|
|
48
|
+
# without string surgery.
|
|
49
|
+
@units = []
|
|
50
|
+
@tree_children = Array.new(atoms.length) { [] }
|
|
51
|
+
@closures = Array.new(atoms.length) { [] }
|
|
52
|
+
@tree_size = Array.new(atoms.length, 1)
|
|
53
|
+
|
|
54
|
+
build_tree(0, nil)
|
|
55
|
+
compute_tree_size(0)
|
|
56
|
+
emit(0, nil, nil)
|
|
57
|
+
|
|
58
|
+
@units.map do |unit|
|
|
59
|
+
if unit.first == :atom
|
|
60
|
+
index = unit.last
|
|
61
|
+
atom_token(index) + @digits[index].join
|
|
62
|
+
else
|
|
63
|
+
unit.last
|
|
64
|
+
end
|
|
65
|
+
end.join
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def build_adjacency(edges)
|
|
71
|
+
@adjacency = Array.new(@atoms.length) { {} }
|
|
72
|
+
edges.each do |edge|
|
|
73
|
+
@adjacency[edge.from][edge.to] = edge.kind
|
|
74
|
+
@adjacency[edge.to][edge.from] = edge.kind
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Post-order subtree sizes within the DFS tree.
|
|
79
|
+
def compute_tree_size(index)
|
|
80
|
+
@tree_children[index].each { |child| compute_tree_size(child) }
|
|
81
|
+
@tree_size[index] = 1 + @tree_children[index].sum { |child| @tree_size[child] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# First pass: a deterministic DFS tree. Children are visited in
|
|
85
|
+
# index order; edges to already-visited atoms become closure
|
|
86
|
+
# digits. Recording the tree first means a branch can never
|
|
87
|
+
# walk around a ring and swallow the continuation atom.
|
|
88
|
+
def build_tree(index, parent)
|
|
89
|
+
@visited[index] = true
|
|
90
|
+
@adjacency[index].keys.sort.each do |nb|
|
|
91
|
+
if nb != parent && @visited[nb]
|
|
92
|
+
@closures[index] << nb
|
|
93
|
+
else
|
|
94
|
+
next if @visited[nb]
|
|
95
|
+
|
|
96
|
+
@tree_children[index] << nb
|
|
97
|
+
build_tree(nb, index)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def emit(index, parent, incoming_kind)
|
|
103
|
+
@units << [:literal, bond_token(incoming_kind, parent, index)] if parent
|
|
104
|
+
@units << [:atom, index]
|
|
105
|
+
@closures[index].each { |nb| ring_digit_for(index, nb) }
|
|
106
|
+
|
|
107
|
+
children = @tree_children[index]
|
|
108
|
+
return if children.empty?
|
|
109
|
+
|
|
110
|
+
ordered = children.sort_by { |child| continuation_rank(index, child) }
|
|
111
|
+
continuation, *branches = ordered
|
|
112
|
+
|
|
113
|
+
branches.each do |child|
|
|
114
|
+
@units << [:literal, "("]
|
|
115
|
+
emit(child, index, @adjacency[index][child])
|
|
116
|
+
@units << [:literal, ")"]
|
|
117
|
+
end
|
|
118
|
+
emit(continuation, index, @adjacency[index][continuation])
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Lower sorts first: single/aromatic bonds continue the chain,
|
|
122
|
+
# then larger subtrees, then the lexicographically smallest atom
|
|
123
|
+
# token (order-independent, so the canonical form does not
|
|
124
|
+
# depend on how the input happened to number the atoms).
|
|
125
|
+
def continuation_rank(index, child)
|
|
126
|
+
kind = @adjacency[index][child]
|
|
127
|
+
priority = CONTINUATION_BONDS.include?(kind) ? 0 : 1
|
|
128
|
+
[priority, -@tree_size[child], atom_token(child)]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def ring_digit_for(a, b)
|
|
132
|
+
key = [a, b].minmax
|
|
133
|
+
digit = @digit_by_edge[key]
|
|
134
|
+
return digit if digit
|
|
135
|
+
|
|
136
|
+
raise ParseError, "too many ring closures for SMILES output" if @next_digit > 9
|
|
137
|
+
|
|
138
|
+
digit = @next_digit.to_s
|
|
139
|
+
@next_digit += 1
|
|
140
|
+
@digit_by_edge[key] = digit
|
|
141
|
+
# Both atom tokens carry the digit; each endpoint's list
|
|
142
|
+
# receives it in encounter order.
|
|
143
|
+
@digits[key.first] << digit
|
|
144
|
+
@digits[key.last] << digit
|
|
145
|
+
digit
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def bond_token(kind, from, to)
|
|
150
|
+
token = BOND_TOKENS[kind]
|
|
151
|
+
raise ParseError, "#{kind} bonds have no SMILES form (v1 subset)" unless token
|
|
152
|
+
|
|
153
|
+
return token if kind != :single && kind != :aromatic
|
|
154
|
+
|
|
155
|
+
# Explicit only when the short form would change the meaning:
|
|
156
|
+
# a single bond between two aromatic atoms must be written;
|
|
157
|
+
# an aromatic bond is implicit between two aromatic atoms.
|
|
158
|
+
if kind == :single
|
|
159
|
+
aromatic?(from) && aromatic?(to) ? token : ""
|
|
160
|
+
else
|
|
161
|
+
aromatic?(from) && aromatic?(to) ? "" : token
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def atom_token(index)
|
|
166
|
+
@atom_tokens ||= {}
|
|
167
|
+
@atom_tokens[index] ||= begin
|
|
168
|
+
atom = @atoms[index]
|
|
169
|
+
aromatic = atom.aromatic == true
|
|
170
|
+
lowercase = aromatic && LOWERCASE_AROMATIC.include?(atom.element)
|
|
171
|
+
needs_bracket =
|
|
172
|
+
atom.charge || atom.isotope || atom.hydrogens ||
|
|
173
|
+
(!ORGANIC.include?(atom.element) && !lowercase) ||
|
|
174
|
+
(aromatic && !lowercase)
|
|
175
|
+
unless needs_bracket
|
|
176
|
+
bare_symbol(atom, lowercase)
|
|
177
|
+
else
|
|
178
|
+
symbol = lowercase ? atom.element.downcase : atom.element
|
|
179
|
+
token = +"["
|
|
180
|
+
token << atom.isotope if atom.isotope
|
|
181
|
+
token << symbol
|
|
182
|
+
if atom.hydrogens
|
|
183
|
+
token << "H"
|
|
184
|
+
token << atom.hydrogens.to_s if atom.hydrogens > 1
|
|
185
|
+
end
|
|
186
|
+
token << charge_suffix(atom.charge) if atom.charge
|
|
187
|
+
token << "]"
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def bare_symbol(atom, lowercase)
|
|
193
|
+
lowercase ? atom.element.downcase : atom.element
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The model's number-then-sign charge ("2+") → SMILES ("+2").
|
|
197
|
+
def charge_suffix(charge)
|
|
198
|
+
count = charge[/\A\d+/]
|
|
199
|
+
sign = charge[-1]
|
|
200
|
+
return sign unless count && count.to_i > 1
|
|
201
|
+
|
|
202
|
+
"#{sign}#{count}"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def aromatic?(index)
|
|
206
|
+
@atoms[index].aromatic == true
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "parslet"
|
|
4
|
+
|
|
5
|
+
module AsciiChem
|
|
6
|
+
# SMILES ingestion and emission for the semantic model
|
|
7
|
+
# (TODO.v2 09; TODO.impl 57). SMILES is an input syntax for the one
|
|
8
|
+
# semantic model — parsed molecules are ordinary `Model::Molecule`s
|
|
9
|
+
# with explicit bonds and ring closures, renderable by every
|
|
10
|
+
# formatter.
|
|
11
|
+
#
|
|
12
|
+
# v1 subset (each deferral has a rejecting spec with an actionable
|
|
13
|
+
# message): chirality `@`/`@@` and E/Z bond directions `/` `\` are
|
|
14
|
+
# not parsed; reaction atom maps, queries, and S-groups are out of
|
|
15
|
+
# scope.
|
|
16
|
+
module Smiles
|
|
17
|
+
autoload :Parser, "asciichem/smiles/parser"
|
|
18
|
+
autoload :Writer, "asciichem/smiles/writer"
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Parses a SMILES string into a Model::Formula with one
|
|
22
|
+
# Molecule per dot-disconnected component.
|
|
23
|
+
def parse(smiles)
|
|
24
|
+
Parser.new(smiles).parse
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Emits deterministic SMILES (DFS from the first atom,
|
|
28
|
+
# neighbours in creation order, ring digits assigned in
|
|
29
|
+
# encounter order). Not a canonical-rank algorithm; the
|
|
30
|
+
# stability property `write(parse(write(x))) == write(x)`
|
|
31
|
+
# holds and is spec'd.
|
|
32
|
+
def write(node)
|
|
33
|
+
case node
|
|
34
|
+
when AsciiChem::Model::Formula
|
|
35
|
+
node.nodes.map { |m| Writer.new(m).write }.join(".")
|
|
36
|
+
when AsciiChem::Model::Molecule
|
|
37
|
+
Writer.new(node).write
|
|
38
|
+
else
|
|
39
|
+
raise AsciiChem::ParseError,
|
|
40
|
+
"#{node.class} has no SMILES form (molecules and formulas of molecules only)"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Structure
|
|
5
|
+
# Model molecule → neutral graph. Bond tokens between consecutive
|
|
6
|
+
# atoms become edges (pending-bond semantics, same as the Layout
|
|
7
|
+
# walker); ring-closure digits become edges via AsciiChem::RingBonds.
|
|
8
|
+
module Graph
|
|
9
|
+
Edge = Struct.new(:from, :to, :kind, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
# Returns [atoms, edges] where atoms is the atom array in
|
|
13
|
+
# walk order and edges is an array of Edge with array indexes.
|
|
14
|
+
def build(molecule)
|
|
15
|
+
atoms = []
|
|
16
|
+
edges = []
|
|
17
|
+
index_by_object_id = {}
|
|
18
|
+
pending = nil
|
|
19
|
+
last = nil
|
|
20
|
+
|
|
21
|
+
walk = lambda do |nodes|
|
|
22
|
+
nodes.each do |node|
|
|
23
|
+
case node
|
|
24
|
+
when AsciiChem::Model::Atom
|
|
25
|
+
index = atoms.length
|
|
26
|
+
# Atoms override == with value equality (benzene's
|
|
27
|
+
# carbons are all equal), so identity is by object_id —
|
|
28
|
+
# same as the Layout walker.
|
|
29
|
+
index_by_object_id[node.object_id] = index
|
|
30
|
+
atoms << node
|
|
31
|
+
if pending && last
|
|
32
|
+
edges << Edge.new(from: last, to: index, kind: pending.kind)
|
|
33
|
+
end
|
|
34
|
+
last = index
|
|
35
|
+
pending = nil
|
|
36
|
+
when AsciiChem::Model::Bond
|
|
37
|
+
pending = node
|
|
38
|
+
when AsciiChem::Model::Group, AsciiChem::Model::Molecule
|
|
39
|
+
walk.call(node.nodes)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
walk.call(molecule.nodes)
|
|
44
|
+
|
|
45
|
+
AsciiChem::RingBonds.each_in(molecule) do |ring|
|
|
46
|
+
from = index_by_object_id[ring.from_atom.object_id]
|
|
47
|
+
to = index_by_object_id[ring.to_atom.object_id]
|
|
48
|
+
next unless from && to
|
|
49
|
+
|
|
50
|
+
# Ring-closure digits carry no kind; the default bond rule
|
|
51
|
+
# (aromatic between aromatic atoms, otherwise single)
|
|
52
|
+
# applies — the same rule the SMILES parser uses.
|
|
53
|
+
kind = ring.from_atom.aromatic && ring.to_atom.aromatic ? :aromatic : :single
|
|
54
|
+
edges << Edge.new(from: from, to: to, kind: kind)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
[atoms, edges]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Structure
|
|
5
|
+
# Adjacency → linear model nodes. Atoms are emitted in the
|
|
6
|
+
# caller's creation order (a DFS order for parsers that walk
|
|
7
|
+
# depth-first). An edge between consecutive atoms becomes a
|
|
8
|
+
# `Bond` token; every other edge becomes a pair of matching ring
|
|
9
|
+
# closure digits — exactly the mechanism AsciiChem's own ring
|
|
10
|
+
# syntax uses, so downstream walkers (`RingBonds`, Layout,
|
|
11
|
+
# ModelAdapter) see the graph with no new machinery.
|
|
12
|
+
#
|
|
13
|
+
# Ring digits are allocated 1..9 and reused only once the
|
|
14
|
+
# previous interval (first endpoint .. second endpoint, in atom
|
|
15
|
+
# order) has closed: `RingBonds` pairs digits by occurrence order
|
|
16
|
+
# across the molecule, so overlapping intervals must never share
|
|
17
|
+
# a digit.
|
|
18
|
+
class Linearizer
|
|
19
|
+
MAX_DIGIT = 9
|
|
20
|
+
|
|
21
|
+
def initialize(atoms:, edges:)
|
|
22
|
+
@atoms = atoms
|
|
23
|
+
@edges = edges
|
|
24
|
+
@close_at = Array.new(MAX_DIGIT, -1)
|
|
25
|
+
@digits_for = Array.new(@atoms.length) { "" }
|
|
26
|
+
@token_before = {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns the node array (Atoms and Bonds interleaved) and
|
|
30
|
+
# mutates the atoms' `ring_closures` with the allocated digits.
|
|
31
|
+
def nodes
|
|
32
|
+
@edges.each do |edge|
|
|
33
|
+
first, last = [edge.from, edge.to].minmax
|
|
34
|
+
if last - first == 1
|
|
35
|
+
@token_before[last] = edge.kind
|
|
36
|
+
else
|
|
37
|
+
digit = allocate_digit(first, last)
|
|
38
|
+
@digits_for[first] += digit
|
|
39
|
+
@digits_for[last] += digit
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
result = []
|
|
44
|
+
@atoms.each_with_index do |atom, index|
|
|
45
|
+
kind = @token_before[index]
|
|
46
|
+
result << AsciiChem::Model::Bond.new(kind: kind || :single) if index.positive? && kind
|
|
47
|
+
atom.ring_closures = @digits_for[index] unless @digits_for[index].empty?
|
|
48
|
+
result << atom
|
|
49
|
+
end
|
|
50
|
+
result
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def allocate_digit(first, last)
|
|
56
|
+
digit_index = (0...MAX_DIGIT).find { |d| @close_at[d] <= first }
|
|
57
|
+
unless digit_index
|
|
58
|
+
raise AsciiChem::ParseError,
|
|
59
|
+
"more than #{MAX_DIGIT} overlapping non-adjacent bonds — " \
|
|
60
|
+
"beyond the model's ring-closure digit capacity"
|
|
61
|
+
end
|
|
62
|
+
@close_at[digit_index] = last
|
|
63
|
+
(digit_index + 1).to_s
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
# Shared structure plumbing for the interchange formats (SMILES,
|
|
5
|
+
# molfile). Two MECE concerns:
|
|
6
|
+
#
|
|
7
|
+
# - `Structure::Graph.build(molecule)` — model molecule → neutral
|
|
8
|
+
# atom list + bond list (adjacency), using the same pending-bond
|
|
9
|
+
# walk semantics as the Layout walker plus `RingBonds` closure
|
|
10
|
+
# edges.
|
|
11
|
+
# - `Structure::Linearizer` — adjacency → linear model nodes (atoms
|
|
12
|
+
# in creation order, `Bond` tokens for consecutive edges, ring
|
|
13
|
+
# closure digits for non-consecutive edges). This is what lets an
|
|
14
|
+
# arbitrary graph from a database live inside the existing model
|
|
15
|
+
# and render through every existing formatter.
|
|
16
|
+
module Structure
|
|
17
|
+
autoload :Graph, "asciichem/structure/graph"
|
|
18
|
+
autoload :Linearizer, "asciichem/structure/linearizer"
|
|
19
|
+
end
|
|
20
|
+
end
|