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.
@@ -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