asciichem 0.31.0 → 0.31.2
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 +31 -0
- data/Gemfile +1 -1
- data/asciichem.gemspec +1 -1
- data/lib/asciichem/engine/parg/asciichem.json +1 -1
- data/lib/asciichem/engine/parg_engine.rb +1 -1
- data/lib/asciichem/formatter/text.rb +6 -0
- data/lib/asciichem/grammar_rules.rb +10 -1
- data/lib/asciichem/linter/valence_check.rb +10 -42
- data/lib/asciichem/model/atom.rb +4 -1
- data/lib/asciichem/model/bond.rb +25 -17
- data/lib/asciichem/model/reaction.rb +18 -0
- data/lib/asciichem/perception.rb +117 -0
- data/lib/asciichem/reaction_roles.rb +43 -0
- data/lib/asciichem/resolver/opsin.rb +68 -0
- data/lib/asciichem/resolver.rb +1 -0
- data/lib/asciichem/smiles/parser.rb +67 -35
- data/lib/asciichem/smiles/writer.rb +99 -34
- data/lib/asciichem/structure/graph.rb +3 -2
- data/lib/asciichem/structure/linearizer.rb +6 -3
- data/lib/asciichem/transform_rules.rb +6 -1
- data/lib/asciichem/version.rb +1 -1
- data/lib/asciichem/wire/core.rb +4 -0
- data/lib/asciichem/wire_adapter.rb +8 -4
- data/lib/asciichem.rb +2 -0
- data/scripts/generate-conformance-cases +57 -0
- metadata +7 -3
data/lib/asciichem/model/bond.rb
CHANGED
|
@@ -4,40 +4,48 @@ module AsciiChem
|
|
|
4
4
|
module Model
|
|
5
5
|
# A bond between two adjacent nodes in a structural chain.
|
|
6
6
|
class Bond < Node
|
|
7
|
-
attr_accessor :kind
|
|
7
|
+
attr_accessor :kind, :direction
|
|
8
8
|
|
|
9
9
|
KINDS = {
|
|
10
|
-
single:
|
|
11
|
-
double:
|
|
12
|
-
triple:
|
|
13
|
-
quadruple: { ascii:
|
|
14
|
-
wedge:
|
|
15
|
-
hash:
|
|
16
|
-
dative:
|
|
17
|
-
wavy:
|
|
18
|
-
aromatic:
|
|
10
|
+
single: { ascii: '-', mathml_entity: '-' },
|
|
11
|
+
double: { ascii: '=', mathml_entity: '=' },
|
|
12
|
+
triple: { ascii: '#', mathml_entity: '≡' },
|
|
13
|
+
quadruple: { ascii: '##', mathml_entity: '≣' },
|
|
14
|
+
wedge: { ascii: '>-', mathml_entity: '↑' },
|
|
15
|
+
hash: { ascii: '-<', mathml_entity: '↓' },
|
|
16
|
+
dative: { ascii: '~>', mathml_entity: '→' },
|
|
17
|
+
wavy: { ascii: '~~', mathml_entity: '∼' },
|
|
18
|
+
aromatic: { ascii: ':', mathml_entity: ':' }
|
|
19
19
|
}.freeze
|
|
20
20
|
|
|
21
21
|
# CML wire order codes per bond kind. Single source of truth
|
|
22
22
|
# for both adapter directions — to_canonical emits, from_canonical
|
|
23
23
|
# parses. Adding a new bond kind = one entry here + one in KINDS.
|
|
24
24
|
CML_ORDER_CODES = {
|
|
25
|
-
single:
|
|
26
|
-
quadruple:
|
|
27
|
-
dative:
|
|
25
|
+
single: 'S', double: 'D', triple: 'T',
|
|
26
|
+
quadruple: 'Q', wedge: 'W', hash: 'H',
|
|
27
|
+
dative: 'A', wavy: 'V'
|
|
28
28
|
}.freeze
|
|
29
29
|
KIND_BY_CML_ORDER = CML_ORDER_CODES.invert.freeze
|
|
30
30
|
|
|
31
31
|
# Subset that carries stereo meaning (wedge = out of page,
|
|
32
32
|
# hash = into page). Used for <bondStereo> elements.
|
|
33
|
-
CML_STEREO_CODES = { wedge:
|
|
33
|
+
CML_STEREO_CODES = { wedge: 'W', hash: 'H' }.freeze
|
|
34
34
|
|
|
35
|
-
def initialize(kind: :single)
|
|
35
|
+
def initialize(kind: :single, direction: nil)
|
|
36
36
|
@kind = kind
|
|
37
|
+
@direction = direction
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# SMILES directional-bond orientation (`/` up, `\` down) on the
|
|
41
|
+
# single bonds flanking a stereogenic double bond. Orthogonal to
|
|
42
|
+
# the bond kind; only :single carries it.
|
|
43
|
+
def direction
|
|
44
|
+
@direction if kind == :single
|
|
37
45
|
end
|
|
38
46
|
|
|
39
47
|
def value_attributes
|
|
40
|
-
{ kind: kind }
|
|
48
|
+
{ kind: kind, direction: direction }
|
|
41
49
|
end
|
|
42
50
|
|
|
43
51
|
def ascii
|
|
@@ -49,7 +57,7 @@ module AsciiChem
|
|
|
49
57
|
end
|
|
50
58
|
|
|
51
59
|
def cml_order_code
|
|
52
|
-
CML_ORDER_CODES.fetch(kind,
|
|
60
|
+
CML_ORDER_CODES.fetch(kind, 'S')
|
|
53
61
|
end
|
|
54
62
|
|
|
55
63
|
def cml_stereo_code
|
|
@@ -29,6 +29,14 @@ module AsciiChem
|
|
|
29
29
|
conditions: conditions }
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Normalized stoichiometry (item 4): each side maps the species'
|
|
33
|
+
# coefficient-free canonical text to its integer coefficient
|
|
34
|
+
# (absent coefficient = 1). A species can appear on both sides.
|
|
35
|
+
def stoichiometry
|
|
36
|
+
{ reactants: side_stoichiometry(reactants),
|
|
37
|
+
products: side_stoichiometry(products) }
|
|
38
|
+
end
|
|
39
|
+
|
|
32
40
|
def children
|
|
33
41
|
reactants + products
|
|
34
42
|
end
|
|
@@ -59,6 +67,16 @@ module AsciiChem
|
|
|
59
67
|
"Reaction(#{reactants.map(&:to_s).join(' + ')} " \
|
|
60
68
|
"#{arrow_ascii} #{products.map(&:to_s).join(' + ')})"
|
|
61
69
|
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def side_stoichiometry(side)
|
|
74
|
+
side.each_with_object(Hash.new(0)) do |molecule, out|
|
|
75
|
+
coefficient = (molecule.coefficient || '1').to_i
|
|
76
|
+
coefficient = 1 if coefficient.zero?
|
|
77
|
+
out[molecule.to_text.sub(/\A\d+/, '')] += coefficient
|
|
78
|
+
end
|
|
79
|
+
end
|
|
62
80
|
end
|
|
63
81
|
end
|
|
64
82
|
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
# The perception pass (TODO.v2/13 item 2): chemistry that the SYNTAX
|
|
5
|
+
# leaves implicit, derived once here and consumed everywhere — the
|
|
6
|
+
# linter, the canonicalizer, renderers, and the SMILES writer.
|
|
7
|
+
#
|
|
8
|
+
# Everything is a pure function of the model; nothing mutates.
|
|
9
|
+
module Perception
|
|
10
|
+
# Default valences per element (the common ground-state values;
|
|
11
|
+
# multi-valent elements list their allowed valences in order).
|
|
12
|
+
DEFAULT_VALENCES = {
|
|
13
|
+
'H' => [1], 'B' => [3], 'C' => [4], 'N' => [3], 'O' => [2],
|
|
14
|
+
'F' => [1], 'Al' => [3], 'Si' => [4], 'P' => [3, 5],
|
|
15
|
+
'S' => [2, 4, 6], 'Cl' => [1], 'Br' => [1], 'I' => [1]
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
BOND_ORDER = {
|
|
19
|
+
single: 1, double: 2, triple: 3, quadruple: 4,
|
|
20
|
+
aromatic: 1, wedge: 1, hash: 1, dative: 1, wavy: 1
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Perception runs over the neutral graph pair from
|
|
26
|
+
# Structure::Graph.build: [atoms, edges].
|
|
27
|
+
|
|
28
|
+
# Neighbour indexes of an atom, in edge order.
|
|
29
|
+
def neighbours(atom_index, edges)
|
|
30
|
+
edges.each_with_object([]) do |edge, acc|
|
|
31
|
+
acc << edge.to if edge.from == atom_index
|
|
32
|
+
acc << edge.from if edge.to == atom_index
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Sum of explicit bond orders at an atom (wedge/hash/dative/wavy
|
|
37
|
+
# bonds order as single; aromatic orders as 1 — Hückel alternation
|
|
38
|
+
# is a ring-perception concern, not a bond-order sum concern).
|
|
39
|
+
def bond_order_sum(atom_index, edges)
|
|
40
|
+
edges.sum do |edge|
|
|
41
|
+
next 0 unless edge.from == atom_index || edge.to == atom_index
|
|
42
|
+
|
|
43
|
+
BOND_ORDER.fetch(edge.kind, 1)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Implicit hydrogens for an atom: the smallest default valence
|
|
48
|
+
# that the explicit bond order fits, minus the bond order.
|
|
49
|
+
# - Atoms that STATE hydrogens ([NH4+], [CH4]) return 0 — never
|
|
50
|
+
# add to what the source said.
|
|
51
|
+
# - Charged atoms return nil: charge algebra on valence is
|
|
52
|
+
# element-specific and deferred until the perception pass grows
|
|
53
|
+
# one (wrong implicit hydrogens are worse than none).
|
|
54
|
+
# - Elements without a table entry, or explicit orders exceeding
|
|
55
|
+
# every default, return nil — never invent.
|
|
56
|
+
def implicit_hydrogens(atom, bond_order_sum)
|
|
57
|
+
return 0 if atom.hydrogens
|
|
58
|
+
return nil if atom.charge
|
|
59
|
+
|
|
60
|
+
valences = DEFAULT_VALENCES[atom.element]
|
|
61
|
+
return nil if valences.nil?
|
|
62
|
+
|
|
63
|
+
fitting = valences.select { |v| v >= bond_order_sum }
|
|
64
|
+
return nil if fitting.empty?
|
|
65
|
+
|
|
66
|
+
fitting.min - bond_order_sum
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Six-membered cycles where every atom is sp2 (participates in a
|
|
70
|
+
# double bond) or aromatic-flagged — the practical fused-system
|
|
71
|
+
# rule: per-ring strict alternation can never see both rings of
|
|
72
|
+
# naphthalene (the shared bond is single), but per-atom sp2 does.
|
|
73
|
+
# Returns arrays of atom indexes, one per perceived ring, or an
|
|
74
|
+
# empty list. Full Hückel pi-electron counting is a later slice.
|
|
75
|
+
def aromatic_rings(atoms, edges)
|
|
76
|
+
cycles = ring_cycles(edges, max_size: 6, atom_count: atoms.length)
|
|
77
|
+
.select { |cycle| cycle.length == 6 }
|
|
78
|
+
cycles.select { |cycle| cycle.all? { |i| sp2?(i, atoms, edges) } }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def sp2?(atom_index, atoms, edges)
|
|
82
|
+
return true if atoms[atom_index].aromatic == true
|
|
83
|
+
|
|
84
|
+
edges.any? do |edge|
|
|
85
|
+
(edge.from == atom_index || edge.to == atom_index) && edge.kind == :double
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def bond_kind(a, b, edges)
|
|
90
|
+
edge = edges.find do |e|
|
|
91
|
+
(e.from == a && e.to == b) || (e.from == b && e.to == a)
|
|
92
|
+
end
|
|
93
|
+
edge&.kind
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Simple cycles up to max_size via DFS (molecules are small).
|
|
97
|
+
def ring_cycles(edges, max_size:, atom_count:)
|
|
98
|
+
found = []
|
|
99
|
+
atom_count.times do |start|
|
|
100
|
+
dfs_cycles(edges, start, start, [start], max_size, found)
|
|
101
|
+
end
|
|
102
|
+
found.uniq { |cycle| cycle.minmax }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def dfs_cycles(edges, start, current, path, max_size, found)
|
|
106
|
+
neighbours(current, edges).each do |nb|
|
|
107
|
+
next if nb == path[-2]
|
|
108
|
+
|
|
109
|
+
if nb == start
|
|
110
|
+
found << path.dup if path.length >= 3
|
|
111
|
+
elsif !path.include?(nb) && path.length < max_size
|
|
112
|
+
dfs_cycles(edges, start, nb, path + [nb], max_size, found)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
# Reaction-roles perception (TODO.v2/13 item 4, slice A): the
|
|
5
|
+
# chemistry-writing convention puts catalysts and light reagents
|
|
6
|
+
# ABOVE the arrow and solvents/conditions BELOW; the conditions
|
|
7
|
+
# text carries them free-form. This derives structured role hints
|
|
8
|
+
# from that convention — pure functions, nothing mutates. A full
|
|
9
|
+
# roles syntax (structured entries in the grammar) is the later
|
|
10
|
+
# slice.
|
|
11
|
+
module ReactionRoles
|
|
12
|
+
CATALYST_HINTS = %w[pd pt ni fe catalyst cat].freeze
|
|
13
|
+
SOLVENT_HINTS = %w[h2o thf dmso dmf etoh meoh toluene solvent sol water].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Returns { catalysts: [strings], solvents: [strings],
|
|
18
|
+
# reagents: [strings], other_above: [strings], other_below: [] }
|
|
19
|
+
# — entries split from the conditions text by whitespace, each
|
|
20
|
+
# classified by hint matching against the role vocabularies.
|
|
21
|
+
def roles_for(reaction)
|
|
22
|
+
conditions = reaction.conditions
|
|
23
|
+
above = split_entries(conditions&.above)
|
|
24
|
+
below = split_entries(conditions&.below)
|
|
25
|
+
{
|
|
26
|
+
catalysts: above.select { |e| matches?(e, CATALYST_HINTS) },
|
|
27
|
+
reagents: above.reject { |e| matches?(e, CATALYST_HINTS) },
|
|
28
|
+
solvents: below.select { |e| matches?(e, SOLVENT_HINTS) },
|
|
29
|
+
other_below: below.reject { |e| matches?(e, SOLVENT_HINTS) }
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def split_entries(text)
|
|
34
|
+
return [] if text.nil? || text.strip.empty?
|
|
35
|
+
|
|
36
|
+
text.strip.split(/[;,\s]+/).reject(&:empty?)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def matches?(entry, hints)
|
|
40
|
+
hints.any? { |hint| entry.to_s.downcase.include?(hint) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'net/https'
|
|
6
|
+
require 'uri'
|
|
7
|
+
|
|
8
|
+
module AsciiChem
|
|
9
|
+
module Resolver
|
|
10
|
+
# OPSIN source adapter (TODO.v2/13 item 3): IUPAC name → structure
|
|
11
|
+
# — name-to-structure perception for systematic names, the piece
|
|
12
|
+
# PubChem's name lookup cannot do (PubChem indexes a curated name
|
|
13
|
+
# list; OPSIN *derives* the structure from any systematic IUPAC
|
|
14
|
+
# name). Uses the public OPSIN web service
|
|
15
|
+
# (https://opsin.ch.cam.ac.uk), which answers with CML or SMILES.
|
|
16
|
+
#
|
|
17
|
+
# Citation profile: publisher University of Cambridge Centre for
|
|
18
|
+
# Molecular Informatics; per-access retrievedAt provenance.
|
|
19
|
+
class Opsin < Adapter
|
|
20
|
+
source_name :opsin
|
|
21
|
+
# Derivation only: 'name' is the curated-lookup convention
|
|
22
|
+
# (PubChem/Common Chemistry). OPSIN DERIVES structures from
|
|
23
|
+
# systematic names - reachable via 'iupac-name' so it can
|
|
24
|
+
# never preempt a lookup source.
|
|
25
|
+
supports 'iupac-name'
|
|
26
|
+
|
|
27
|
+
BASE = 'https://opsin.ch.cam.ac.uk/opsin'
|
|
28
|
+
private_constant :BASE
|
|
29
|
+
|
|
30
|
+
def fetch_record(value:, convention: nil, fetch:)
|
|
31
|
+
body = fetch.get("#{BASE}/#{CGI.escape(value.to_s)}.smiles")
|
|
32
|
+
return nil unless body
|
|
33
|
+
|
|
34
|
+
substance_from(body.strip)
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
raise Error, "opsin request failed: #{e.message}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def attribution
|
|
40
|
+
'OPSIN, University of Cambridge Centre for Molecular Informatics'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def substance_from(smiles)
|
|
46
|
+
return nil if smiles.empty?
|
|
47
|
+
|
|
48
|
+
provenance = default_provenance
|
|
49
|
+
Substance.new(
|
|
50
|
+
preferred_name: nil,
|
|
51
|
+
identifiers: [Identifier.new(value: smiles, convention: 'canonical-smiles',
|
|
52
|
+
provenance: provenance)],
|
|
53
|
+
properties: [],
|
|
54
|
+
structure: parse_structure(smiles)
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# OPSIN answers SMILES that may use features outside the
|
|
59
|
+
# supported subset (wildcards, exotic stereo); then the SMILES
|
|
60
|
+
# stays as an identifier and structure is nil — no silent loss.
|
|
61
|
+
def parse_structure(smiles)
|
|
62
|
+
AsciiChem.parse_smiles(smiles).nodes.first
|
|
63
|
+
rescue AsciiChem::ParseError
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/asciichem/resolver.rb
CHANGED
|
@@ -17,6 +17,7 @@ module AsciiChem
|
|
|
17
17
|
autoload :Adapter, "asciichem/resolver/adapter"
|
|
18
18
|
autoload :Cache, "asciichem/resolver/cache"
|
|
19
19
|
autoload :PubChem, "asciichem/resolver/pubchem"
|
|
20
|
+
autoload :Opsin, "asciichem/resolver/opsin"
|
|
20
21
|
autoload :CommonChemistry, "asciichem/resolver/common_chemistry"
|
|
21
22
|
autoload :Substance, "asciichem/resolver/substance"
|
|
22
23
|
|
|
@@ -22,7 +22,7 @@ module AsciiChem
|
|
|
22
22
|
# Returns a Model::Formula with one Molecule per dot component.
|
|
23
23
|
def parse
|
|
24
24
|
molecules = [component]
|
|
25
|
-
molecules << component while eat(
|
|
25
|
+
molecules << component while eat('.')
|
|
26
26
|
unless @pos == @source.length
|
|
27
27
|
raise ParseError,
|
|
28
28
|
"unexpected character #{peek.inspect} at position #{@pos} in #{@source.inspect}"
|
|
@@ -35,9 +35,11 @@ module AsciiChem
|
|
|
35
35
|
def component
|
|
36
36
|
@atoms = []
|
|
37
37
|
@adjacency = []
|
|
38
|
+
@edge_directions = {}
|
|
38
39
|
@open_rings = {}
|
|
39
40
|
@parent = nil
|
|
40
41
|
@pending_kind = nil
|
|
42
|
+
@pending_direction = nil
|
|
41
43
|
|
|
42
44
|
chain
|
|
43
45
|
|
|
@@ -48,7 +50,14 @@ module AsciiChem
|
|
|
48
50
|
|
|
49
51
|
edges = []
|
|
50
52
|
@adjacency.each_with_index do |neighbors, index|
|
|
51
|
-
neighbors.each
|
|
53
|
+
neighbors.each do |to, kind|
|
|
54
|
+
next unless to > index
|
|
55
|
+
|
|
56
|
+
edges << Structure::Graph::Edge.new(
|
|
57
|
+
from: index, to: to, kind: kind,
|
|
58
|
+
direction: @edge_directions[[index, to]]
|
|
59
|
+
)
|
|
60
|
+
end
|
|
52
61
|
end
|
|
53
62
|
Model::Molecule.new(nodes: Structure::Linearizer.new(atoms: @atoms, edges: edges).nodes)
|
|
54
63
|
end
|
|
@@ -58,42 +67,51 @@ module AsciiChem
|
|
|
58
67
|
def chain
|
|
59
68
|
atom_token
|
|
60
69
|
until eoc?
|
|
61
|
-
if peek ==
|
|
70
|
+
if peek == '('
|
|
62
71
|
branch
|
|
63
72
|
elsif bond_start?
|
|
64
|
-
kind = bond_token
|
|
65
|
-
if peek ==
|
|
66
|
-
branch(kind)
|
|
73
|
+
kind, direction = bond_token
|
|
74
|
+
if peek == '('
|
|
75
|
+
branch(kind, direction)
|
|
67
76
|
elsif digit_start?
|
|
68
|
-
ringbond(kind)
|
|
77
|
+
ringbond(kind, direction)
|
|
69
78
|
else
|
|
70
79
|
@pending_kind = kind
|
|
80
|
+
@pending_direction = direction
|
|
71
81
|
atom_token
|
|
72
82
|
end
|
|
73
83
|
elsif digit_start?
|
|
74
84
|
ringbond(nil)
|
|
75
85
|
else
|
|
76
86
|
@pending_kind = nil
|
|
87
|
+
@pending_direction = nil
|
|
77
88
|
atom_token
|
|
78
89
|
end
|
|
79
90
|
end
|
|
80
91
|
end
|
|
81
92
|
|
|
82
|
-
def branch(kind = nil)
|
|
83
|
-
expect(
|
|
93
|
+
def branch(kind = nil, direction = nil)
|
|
94
|
+
expect('(')
|
|
84
95
|
# The bond may lead the branch ("C-(O)") or open it ("C(=O)").
|
|
85
|
-
kind = bond_token if kind.nil? && bond_start?
|
|
96
|
+
kind, direction = bond_token if kind.nil? && bond_start?
|
|
86
97
|
saved_parent = @parent
|
|
87
98
|
saved_pending = @pending_kind
|
|
99
|
+
saved_direction = @pending_direction
|
|
88
100
|
@pending_kind = kind
|
|
101
|
+
@pending_direction = direction
|
|
89
102
|
chain
|
|
90
|
-
expect(
|
|
103
|
+
expect(')')
|
|
91
104
|
@parent = saved_parent
|
|
92
105
|
@pending_kind = saved_pending
|
|
106
|
+
@pending_direction = saved_direction
|
|
93
107
|
end
|
|
94
108
|
|
|
95
|
-
def ringbond(kind)
|
|
109
|
+
def ringbond(kind, direction = nil)
|
|
96
110
|
digit = ring_digit
|
|
111
|
+
if direction
|
|
112
|
+
raise ParseError,
|
|
113
|
+
"directional ring closures (#{direction}) are not representable in the model's ring-closure form"
|
|
114
|
+
end
|
|
97
115
|
if @open_rings.key?(digit)
|
|
98
116
|
opener = @open_rings.delete(digit)
|
|
99
117
|
# The model's ring-closure digits carry no bond kind, so an
|
|
@@ -115,9 +133,13 @@ module AsciiChem
|
|
|
115
133
|
index = @atoms.length
|
|
116
134
|
@atoms << atom
|
|
117
135
|
@adjacency[index] = {}
|
|
118
|
-
|
|
136
|
+
if @parent
|
|
137
|
+
add_edge(@parent, index, @pending_kind)
|
|
138
|
+
@edge_directions[[@parent, index].minmax] = @pending_direction if @pending_direction
|
|
139
|
+
end
|
|
119
140
|
@parent = index
|
|
120
141
|
@pending_kind = nil
|
|
142
|
+
@pending_direction = nil
|
|
121
143
|
end
|
|
122
144
|
|
|
123
145
|
def add_edge(from, to, explicit)
|
|
@@ -133,7 +155,7 @@ module AsciiChem
|
|
|
133
155
|
# -- tokens ----------------------------------------------------------
|
|
134
156
|
|
|
135
157
|
def read_atom
|
|
136
|
-
return bracket_atom if peek ==
|
|
158
|
+
return bracket_atom if peek == '['
|
|
137
159
|
|
|
138
160
|
two = @source[@pos, 2].to_s
|
|
139
161
|
if (symbol = ORGANIC.find { |s| two.start_with?(s) })
|
|
@@ -149,19 +171,20 @@ module AsciiChem
|
|
|
149
171
|
end
|
|
150
172
|
|
|
151
173
|
def bracket_atom
|
|
152
|
-
expect(
|
|
174
|
+
expect('[')
|
|
153
175
|
isotope = digits
|
|
154
176
|
symbol = bracket_symbol
|
|
155
|
-
|
|
177
|
+
chirality = read_chirality
|
|
156
178
|
hydrogens = hcount
|
|
157
179
|
charge = bracket_charge
|
|
158
180
|
skip_class
|
|
159
|
-
expect(
|
|
181
|
+
expect(']')
|
|
160
182
|
Model::Atom.new(
|
|
161
183
|
element: symbol.capitalize,
|
|
162
184
|
isotope: isotope,
|
|
163
185
|
charge: charge,
|
|
164
186
|
hydrogens: hydrogens,
|
|
187
|
+
chirality: chirality,
|
|
165
188
|
aromatic: symbol.match?(/\A[a-z]/) || nil
|
|
166
189
|
)
|
|
167
190
|
end
|
|
@@ -188,15 +211,23 @@ module AsciiChem
|
|
|
188
211
|
raise ParseError, "expected an element symbol at position #{@pos} in #{@source.inspect}"
|
|
189
212
|
end
|
|
190
213
|
|
|
191
|
-
|
|
192
|
-
|
|
214
|
+
# `@' (TH1) / `@@' (TH2): SMILES ordered-neighbour tetrahedral
|
|
215
|
+
# parity, stored verbatim on the atom — the writer recomputes
|
|
216
|
+
# the token for its own traversal order.
|
|
217
|
+
def read_chirality
|
|
218
|
+
return nil unless peek == '@'
|
|
193
219
|
|
|
194
|
-
|
|
195
|
-
|
|
220
|
+
if @source[@pos, 2] == '@@'
|
|
221
|
+
@pos += 2
|
|
222
|
+
'TH2'
|
|
223
|
+
else
|
|
224
|
+
@pos += 1
|
|
225
|
+
'TH1'
|
|
226
|
+
end
|
|
196
227
|
end
|
|
197
228
|
|
|
198
229
|
def hcount
|
|
199
|
-
return nil unless peek ==
|
|
230
|
+
return nil unless peek == 'H'
|
|
200
231
|
|
|
201
232
|
@pos += 1
|
|
202
233
|
count = digits
|
|
@@ -206,7 +237,7 @@ module AsciiChem
|
|
|
206
237
|
# "+" | "++" | "+n" | "-" | "--" | "-n" → number-then-sign.
|
|
207
238
|
def bracket_charge
|
|
208
239
|
sign = peek
|
|
209
|
-
return nil unless
|
|
240
|
+
return nil unless ['+', '-'].include?(sign)
|
|
210
241
|
|
|
211
242
|
@pos += 1
|
|
212
243
|
second = @source[@pos].to_s
|
|
@@ -221,32 +252,35 @@ module AsciiChem
|
|
|
221
252
|
end
|
|
222
253
|
|
|
223
254
|
def skip_class
|
|
224
|
-
return unless peek ==
|
|
255
|
+
return unless peek == ':'
|
|
225
256
|
|
|
226
257
|
@pos += 1
|
|
227
258
|
digits
|
|
228
259
|
end
|
|
229
260
|
|
|
261
|
+
# Returns [kind, direction]: `/' and `\' are DIRECTIONAL SINGLE
|
|
262
|
+
# bonds (SMILES E/Z stereo) — kind stays :single, the direction
|
|
263
|
+
# rides the model's Bond#direction.
|
|
230
264
|
def bond_token
|
|
231
265
|
ch = peek
|
|
232
|
-
kind = {
|
|
233
|
-
|
|
266
|
+
kind = { '-' => :single, '=' => :double, '#' => :triple, '$' => :quadruple,
|
|
267
|
+
':' => :aromatic }[ch]
|
|
234
268
|
unless kind
|
|
235
|
-
if
|
|
236
|
-
|
|
237
|
-
|
|
269
|
+
if ['/', '\\'].include?(ch)
|
|
270
|
+
@pos += 1
|
|
271
|
+
return [:single, ch == '/' ? :up : :down]
|
|
238
272
|
end
|
|
239
273
|
raise ParseError, "expected a bond or atom at position #{@pos} in #{@source.inspect}"
|
|
240
274
|
end
|
|
241
275
|
@pos += 1
|
|
242
|
-
kind
|
|
276
|
+
[kind, nil]
|
|
243
277
|
end
|
|
244
278
|
|
|
245
279
|
def ring_digit
|
|
246
|
-
if peek ==
|
|
280
|
+
if peek == '%'
|
|
247
281
|
@pos += 1
|
|
248
282
|
digit = @source[@pos, 2].to_s
|
|
249
|
-
raise ParseError,
|
|
283
|
+
raise ParseError, 'malformed %nn ring closure' unless digit.match?(/\A\d\d\z/)
|
|
250
284
|
|
|
251
285
|
@pos += 2
|
|
252
286
|
digit
|
|
@@ -291,9 +325,7 @@ module AsciiChem
|
|
|
291
325
|
end
|
|
292
326
|
|
|
293
327
|
def expect(char)
|
|
294
|
-
unless peek == char
|
|
295
|
-
raise ParseError, "expected #{char.inspect} at position #{@pos} in #{@source.inspect}"
|
|
296
|
-
end
|
|
328
|
+
raise ParseError, "expected #{char.inspect} at position #{@pos} in #{@source.inspect}" unless peek == char
|
|
297
329
|
|
|
298
330
|
@pos += 1
|
|
299
331
|
end
|