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,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
# AsciiMath-style Greek word → Unicode symbol table. Used by the
|
|
5
|
+
# grammar and transform to accept typed Greek words (`alpha`, `Delta`,
|
|
6
|
+
# etc.) alongside Unicode characters (`α`, `Δ`).
|
|
7
|
+
#
|
|
8
|
+
# Single source of truth — the stereo grammar, the conditions
|
|
9
|
+
# translator, and any future site that needs Greek letters all read
|
|
10
|
+
# from this hash. Adding a new word is one entry here, not edits
|
|
11
|
+
# scattered across the grammar.
|
|
12
|
+
module Greek
|
|
13
|
+
LOWERCASE = {
|
|
14
|
+
"alpha" => "α",
|
|
15
|
+
"beta" => "β",
|
|
16
|
+
"gamma" => "γ",
|
|
17
|
+
"delta" => "δ",
|
|
18
|
+
"epsilon" => "ε",
|
|
19
|
+
"zeta" => "ζ",
|
|
20
|
+
"eta" => "η",
|
|
21
|
+
"theta" => "θ",
|
|
22
|
+
"iota" => "ι",
|
|
23
|
+
"kappa" => "κ",
|
|
24
|
+
"lambda" => "λ",
|
|
25
|
+
"mu" => "μ",
|
|
26
|
+
"nu" => "ν",
|
|
27
|
+
"xi" => "ξ",
|
|
28
|
+
"omicron" => "ο",
|
|
29
|
+
"pi" => "π",
|
|
30
|
+
"rho" => "ρ",
|
|
31
|
+
"sigma" => "σ",
|
|
32
|
+
"tau" => "τ",
|
|
33
|
+
"upsilon" => "υ",
|
|
34
|
+
"phi" => "φ",
|
|
35
|
+
"chi" => "χ",
|
|
36
|
+
"psi" => "ψ",
|
|
37
|
+
"omega" => "ω"
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
UPPERCASE = {
|
|
41
|
+
"Alpha" => "Α",
|
|
42
|
+
"Beta" => "Β",
|
|
43
|
+
"Gamma" => "Γ",
|
|
44
|
+
"Delta" => "Δ",
|
|
45
|
+
"Epsilon" => "Ε",
|
|
46
|
+
"Zeta" => "Ζ",
|
|
47
|
+
"Eta" => "Η",
|
|
48
|
+
"Theta" => "Θ",
|
|
49
|
+
"Iota" => "Ι",
|
|
50
|
+
"Kappa" => "Κ",
|
|
51
|
+
"Lambda" => "Λ",
|
|
52
|
+
"Mu" => "Μ",
|
|
53
|
+
"Nu" => "Ν",
|
|
54
|
+
"Xi" => "Ξ",
|
|
55
|
+
"Omicron" => "Ο",
|
|
56
|
+
"Pi" => "Π",
|
|
57
|
+
"Rho" => "Ρ",
|
|
58
|
+
"Sigma" => "Σ",
|
|
59
|
+
"Tau" => "Τ",
|
|
60
|
+
"Upsilon" => "Υ",
|
|
61
|
+
"Phi" => "Φ",
|
|
62
|
+
"Chi" => "Χ",
|
|
63
|
+
"Psi" => "Ψ",
|
|
64
|
+
"Omega" => "Ω"
|
|
65
|
+
}.freeze
|
|
66
|
+
|
|
67
|
+
ALL = LOWERCASE.merge(UPPERCASE).freeze
|
|
68
|
+
|
|
69
|
+
# Translate every Greek word in `text` to its Unicode symbol.
|
|
70
|
+
# Non-word substrings pass through unchanged. Longest-word-first
|
|
71
|
+
# so "eta" doesn't shadow "beta" (which contains "eta" as a suffix).
|
|
72
|
+
def self.translate(text)
|
|
73
|
+
return text if text.nil? || text.empty?
|
|
74
|
+
|
|
75
|
+
sorted_words = ALL.keys.sort_by(&:length).reverse
|
|
76
|
+
pattern = /#{sorted_words.map { |w| Regexp.escape(w) }.join("|")}/
|
|
77
|
+
text.gsub(pattern) { |match| ALL.fetch(match) }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'elkrb'
|
|
4
|
+
|
|
5
|
+
module AsciiChem
|
|
6
|
+
# 2D structural layout for molecules. Walks an
|
|
7
|
+
# `AsciiChem::Model::Molecule`, builds an elkrb graph (atoms as
|
|
8
|
+
# nodes, bonds as edges), runs a layout algorithm, and returns a
|
|
9
|
+
# `Layout::Result` ready for SVG rendering.
|
|
10
|
+
#
|
|
11
|
+
# Three concerns, MECE:
|
|
12
|
+
#
|
|
13
|
+
# - `MoleculeWalker` — walks the AsciiChem tree, assigns stable IDs,
|
|
14
|
+
# produces a neutral atom+bond list. Knows nothing about elkrb.
|
|
15
|
+
# - `GraphBuilder` — converts the walker's neutral list into an
|
|
16
|
+
# elkrb graph. Knows nothing about AsciiChem::Model.
|
|
17
|
+
# - `ResultExtractor` — maps elkrb's laid-out positions back onto
|
|
18
|
+
# the walker's neutral list, producing a `Layout::Result`.
|
|
19
|
+
#
|
|
20
|
+
# Each is independently testable. New algorithms (ring detection,
|
|
21
|
+
# stereo placement) slot in as additional visitors over the same
|
|
22
|
+
# `Layout::Result`; no edits to the walker or extractor.
|
|
23
|
+
module Layout
|
|
24
|
+
ATOM_WIDTH = 40.0
|
|
25
|
+
ATOM_HEIGHT = 30.0
|
|
26
|
+
PADDING = 20.0
|
|
27
|
+
|
|
28
|
+
PositionedAtom = Struct.new(:id, :element, :x, :y, :charge, :isotope,
|
|
29
|
+
:multiplicity, keyword_init: true)
|
|
30
|
+
PositionedBond = Struct.new(:id, :kind, :from_id, :to_id, keyword_init: true)
|
|
31
|
+
|
|
32
|
+
# Layout result: positioned atoms and bonds ready for rendering.
|
|
33
|
+
Result = Struct.new(:atoms, :bonds, :width, :height, keyword_init: true) do
|
|
34
|
+
def atoms_by_id
|
|
35
|
+
@atoms_by_id ||= atoms.to_h { |a| [a.id, a] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def empty?
|
|
39
|
+
atoms.empty?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Empty result returned when layout is not applicable (e.g.
|
|
44
|
+
# molecule has no atoms). Keeps callers from special-casing nil.
|
|
45
|
+
def self.empty_result
|
|
46
|
+
Result.new(atoms: [], bonds: [], width: 0.0, height: 0.0)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Compute 2D positions for a molecule's atoms and bonds.
|
|
50
|
+
# Returns a `Layout::Result`. Algorithm defaults to `layered`
|
|
51
|
+
# (Sugiyama-style hierarchical) which is deterministic across
|
|
52
|
+
# runs — essential for visual regression testing. Pass
|
|
53
|
+
# `algorithm: "force"` for organic-looking layouts, with the
|
|
54
|
+
# caveat that output may vary between runs.
|
|
55
|
+
def self.layout(molecule, algorithm: 'layered')
|
|
56
|
+
walker = MoleculeWalker.new(molecule)
|
|
57
|
+
walk = walker.walk
|
|
58
|
+
return empty_result if walk.atoms.empty?
|
|
59
|
+
|
|
60
|
+
# If all atoms carry pre-positioned coordinates (e.g. from CML
|
|
61
|
+
# x2/y2 attributes), skip elkrb and use the provided positions
|
|
62
|
+
# directly. This preserves the molecule's original geometry when
|
|
63
|
+
# round-tripping through CML.
|
|
64
|
+
return pre_positioned_result(walk) if all_positioned?(walk)
|
|
65
|
+
|
|
66
|
+
graph = GraphBuilder.new(walk).build(algorithm: algorithm)
|
|
67
|
+
laid_out = Elkrb.layout(graph, algorithm: algorithm)
|
|
68
|
+
ResultExtractor.new(laid_out, walk).extract
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.all_positioned?(walk)
|
|
72
|
+
walk.atoms.any? && walk.atoms.all? { |a| a.x2 && a.y2 }
|
|
73
|
+
end
|
|
74
|
+
private_class_method :all_positioned?
|
|
75
|
+
|
|
76
|
+
# Build a Layout::Result directly from WalkAtoms that already
|
|
77
|
+
# carry x2/y2 coordinates. No elkrb involved.
|
|
78
|
+
def self.pre_positioned_result(walk)
|
|
79
|
+
atoms = walk.atoms.map do |wa|
|
|
80
|
+
PositionedAtom.new(
|
|
81
|
+
id: wa.id,
|
|
82
|
+
element: wa.element,
|
|
83
|
+
x: wa.x2.to_f,
|
|
84
|
+
y: wa.y2.to_f,
|
|
85
|
+
charge: wa.charge,
|
|
86
|
+
isotope: wa.isotope,
|
|
87
|
+
multiplicity: wa.multiplicity
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
bonds = walk.bonds.map do |wb|
|
|
91
|
+
PositionedBond.new(id: wb.id, kind: wb.kind,
|
|
92
|
+
from_id: wb.from_id, to_id: wb.to_id)
|
|
93
|
+
end
|
|
94
|
+
max_x = atoms.map(&:x).max || 0.0
|
|
95
|
+
max_y = atoms.map(&:y).max || 0.0
|
|
96
|
+
Result.new(atoms: atoms, bonds: bonds,
|
|
97
|
+
width: max_x + PADDING,
|
|
98
|
+
height: max_y + PADDING)
|
|
99
|
+
end
|
|
100
|
+
private_class_method :pre_positioned_result
|
|
101
|
+
|
|
102
|
+
# Walks an AsciiChem::Model::Molecule in source order, assigning
|
|
103
|
+
# deterministic IDs and emitting atoms + bonds. Pure; no elkrb
|
|
104
|
+
# dependency.
|
|
105
|
+
class MoleculeWalker
|
|
106
|
+
attr_reader :atoms, :bonds
|
|
107
|
+
|
|
108
|
+
def initialize(molecule)
|
|
109
|
+
@molecule = molecule
|
|
110
|
+
@atoms = []
|
|
111
|
+
@bonds = []
|
|
112
|
+
@atom_counter = IdCounter.new('a')
|
|
113
|
+
@bond_counter = IdCounter.new('b')
|
|
114
|
+
@pending_bond = nil
|
|
115
|
+
@last_atom_id = nil
|
|
116
|
+
@atom_id_by_object_id = {}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Returns a WalkResult — the neutral atom+bond list with
|
|
120
|
+
# element/charge/etc preserved from the source molecule.
|
|
121
|
+
def walk
|
|
122
|
+
walk_nodes(@molecule.nodes)
|
|
123
|
+
emit_ring_bonds
|
|
124
|
+
WalkResult.new(atoms: @atoms, bonds: @bonds)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Emit a bond for each ring-closure pair. Uses
|
|
128
|
+
# `AsciiChem::RingBonds.each_in` so the algorithm is shared
|
|
129
|
+
# with the CML adapter and the linter.
|
|
130
|
+
def emit_ring_bonds
|
|
131
|
+
AsciiChem::RingBonds.each_in(@molecule) do |ring_bond|
|
|
132
|
+
from_id = @atom_id_by_object_id[ring_bond.from_atom.object_id]
|
|
133
|
+
to_id = @atom_id_by_object_id[ring_bond.to_atom.object_id]
|
|
134
|
+
next unless from_id && to_id
|
|
135
|
+
|
|
136
|
+
@bonds << WalkBond.new(
|
|
137
|
+
id: @bond_counter.next,
|
|
138
|
+
kind: :single,
|
|
139
|
+
from_id: from_id,
|
|
140
|
+
to_id: to_id
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def walk_nodes(nodes)
|
|
148
|
+
nodes.each do |node|
|
|
149
|
+
case node
|
|
150
|
+
when AsciiChem::Model::Atom
|
|
151
|
+
emit_atom(node)
|
|
152
|
+
when AsciiChem::Model::Bond
|
|
153
|
+
@pending_bond = node
|
|
154
|
+
when AsciiChem::Model::Group, AsciiChem::Model::Molecule
|
|
155
|
+
walk_nodes(node.nodes)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def emit_atom(atom)
|
|
161
|
+
id = @atom_counter.next
|
|
162
|
+
@atom_id_by_object_id[atom.object_id] = id
|
|
163
|
+
@atoms << WalkAtom.new(
|
|
164
|
+
id: id,
|
|
165
|
+
element: atom.element,
|
|
166
|
+
charge: atom.charge,
|
|
167
|
+
isotope: atom.isotope,
|
|
168
|
+
multiplicity: atom.subscript,
|
|
169
|
+
x2: atom.x2,
|
|
170
|
+
y2: atom.y2
|
|
171
|
+
)
|
|
172
|
+
emit_pending_bond(id) if @pending_bond && @last_atom_id
|
|
173
|
+
@last_atom_id = id
|
|
174
|
+
@pending_bond = nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def emit_pending_bond(next_atom_id)
|
|
178
|
+
@bonds << WalkBond.new(
|
|
179
|
+
id: @bond_counter.next,
|
|
180
|
+
kind: @pending_bond.kind,
|
|
181
|
+
from_id: @last_atom_id,
|
|
182
|
+
to_id: next_atom_id
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
private_constant :MoleculeWalker
|
|
187
|
+
|
|
188
|
+
# Neutral walker output. Decouples the walker from both elkrb and
|
|
189
|
+
# the canonical model — the same walk could feed any future
|
|
190
|
+
# graph-based renderer.
|
|
191
|
+
WalkAtom = Struct.new(:id, :element, :charge, :isotope, :multiplicity, :x2, :y2, keyword_init: true)
|
|
192
|
+
WalkBond = Struct.new(:id, :kind, :from_id, :to_id, keyword_init: true)
|
|
193
|
+
WalkResult = Struct.new(:atoms, :bonds, keyword_init: true)
|
|
194
|
+
private_constant :WalkAtom, :WalkBond, :WalkResult
|
|
195
|
+
|
|
196
|
+
# Builds an elkrb graph from a walker's neutral output. Knows
|
|
197
|
+
# only about elkrb's Node/Edge/Graph classes and the Layout
|
|
198
|
+
# dimension constants.
|
|
199
|
+
class GraphBuilder
|
|
200
|
+
def initialize(walk)
|
|
201
|
+
@walk = walk
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def build(algorithm:)
|
|
205
|
+
children = @walk.atoms.map { |a| elkrb_node(a) }
|
|
206
|
+
edges = @walk.bonds.map { |b| elkrb_edge(b) }
|
|
207
|
+
Elkrb::Graph::Graph.new(
|
|
208
|
+
id: 'root',
|
|
209
|
+
children: children,
|
|
210
|
+
edges: edges,
|
|
211
|
+
layout_options: Elkrb::Graph::LayoutOptions.new(algorithm: algorithm)
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private
|
|
216
|
+
|
|
217
|
+
def elkrb_node(atom)
|
|
218
|
+
Elkrb::Graph::Node.new(
|
|
219
|
+
id: atom.id,
|
|
220
|
+
width: Layout::ATOM_WIDTH,
|
|
221
|
+
height: Layout::ATOM_HEIGHT,
|
|
222
|
+
properties: { 'element' => atom.element,
|
|
223
|
+
'charge' => atom.charge,
|
|
224
|
+
'isotope' => atom.isotope,
|
|
225
|
+
'multiplicity' => atom.multiplicity }
|
|
226
|
+
)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def elkrb_edge(bond)
|
|
230
|
+
Elkrb::Graph::Edge.new(
|
|
231
|
+
id: bond.id,
|
|
232
|
+
sources: [bond.from_id],
|
|
233
|
+
targets: [bond.to_id],
|
|
234
|
+
properties: { 'kind' => bond.kind.to_s }
|
|
235
|
+
)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
private_constant :GraphBuilder
|
|
239
|
+
|
|
240
|
+
# Maps an elkrb laid-out graph back onto the walker's neutral
|
|
241
|
+
# atom+bond list, producing a `Layout::Result`. Positions come
|
|
242
|
+
# from elkrb; everything else (element, charge, kind) comes from
|
|
243
|
+
# the walker — elkrb doesn't carry chemistry semantics.
|
|
244
|
+
class ResultExtractor
|
|
245
|
+
def initialize(laid_out, walk)
|
|
246
|
+
@laid_out = laid_out
|
|
247
|
+
@walk = walk
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def extract
|
|
251
|
+
position_by_id = build_position_map
|
|
252
|
+
atoms = build_positioned_atoms(position_by_id)
|
|
253
|
+
bonds = build_positioned_bonds
|
|
254
|
+
Layout::Result.new(
|
|
255
|
+
atoms: atoms,
|
|
256
|
+
bonds: bonds,
|
|
257
|
+
width: @laid_out.width.to_f + Layout::PADDING,
|
|
258
|
+
height: @laid_out.height.to_f + Layout::PADDING
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
private
|
|
263
|
+
|
|
264
|
+
def build_position_map
|
|
265
|
+
@laid_out.children.to_h do |node|
|
|
266
|
+
[node.id, [node.x.to_f, node.y.to_f]]
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def build_positioned_atoms(position_by_id)
|
|
271
|
+
@walk.atoms.map do |walk_atom|
|
|
272
|
+
x, y = position_by_id.fetch(walk_atom.id, [0.0, 0.0])
|
|
273
|
+
Layout::PositionedAtom.new(
|
|
274
|
+
id: walk_atom.id,
|
|
275
|
+
element: walk_atom.element,
|
|
276
|
+
x: x,
|
|
277
|
+
y: y,
|
|
278
|
+
charge: walk_atom.charge,
|
|
279
|
+
isotope: walk_atom.isotope,
|
|
280
|
+
multiplicity: walk_atom.multiplicity
|
|
281
|
+
)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def build_positioned_bonds
|
|
286
|
+
@walk.bonds.map do |walk_bond|
|
|
287
|
+
Layout::PositionedBond.new(
|
|
288
|
+
id: walk_bond.id,
|
|
289
|
+
kind: walk_bond.kind,
|
|
290
|
+
from_id: walk_bond.from_id,
|
|
291
|
+
to_id: walk_bond.to_id
|
|
292
|
+
)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
private_constant :ResultExtractor
|
|
297
|
+
|
|
298
|
+
# Per-build ID counter. Format: `prefix + N` (e.g. "a1", "b2").
|
|
299
|
+
# Walker instances are per-translation so IDs reset cleanly.
|
|
300
|
+
class IdCounter
|
|
301
|
+
def initialize(prefix)
|
|
302
|
+
@prefix = prefix
|
|
303
|
+
@counter = 0
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def next
|
|
307
|
+
@counter += 1
|
|
308
|
+
"#{@prefix}#{@counter}"
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
private_constant :IdCounter
|
|
312
|
+
end
|
|
313
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Linter
|
|
5
|
+
# Verifies that a reaction is stoichiometrically balanced — the
|
|
6
|
+
# atom counts on the reactants side equal those on the products
|
|
7
|
+
# side. Coefficients and group multiplicities are applied.
|
|
8
|
+
#
|
|
9
|
+
# The check ignores electrons (`e^-`), conditions, and the arrow
|
|
10
|
+
# kind. It treats unknown constructs conservatively: any molecule
|
|
11
|
+
# it can't enumerate atoms for is skipped, not flagged.
|
|
12
|
+
class BalanceCheck < Base
|
|
13
|
+
register :balance
|
|
14
|
+
|
|
15
|
+
def run(formula)
|
|
16
|
+
diagnostics = []
|
|
17
|
+
walk(formula) do |node|
|
|
18
|
+
next unless node.is_a?(AsciiChem::Model::Reaction)
|
|
19
|
+
|
|
20
|
+
check_reaction(node, diagnostics)
|
|
21
|
+
end
|
|
22
|
+
diagnostics
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def check_reaction(reaction, diagnostics)
|
|
28
|
+
reactants = count_side(reaction.reactants)
|
|
29
|
+
products = count_side(reaction.products)
|
|
30
|
+
return if reactants.nil? || products.nil?
|
|
31
|
+
return if reactants == products
|
|
32
|
+
|
|
33
|
+
diffs = compute_diffs(reactants, products)
|
|
34
|
+
diagnostics << error(
|
|
35
|
+
"Reaction is not balanced: #{diffs}",
|
|
36
|
+
node: reaction
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns a Hash mapping element symbol to count, or nil if any
|
|
41
|
+
# molecule on the side couldn't be enumerated.
|
|
42
|
+
def count_side(molecules)
|
|
43
|
+
counts = Hash.new(0)
|
|
44
|
+
molecules.each do |molecule|
|
|
45
|
+
mol_counts = count_molecule(molecule)
|
|
46
|
+
return nil if mol_counts.nil?
|
|
47
|
+
|
|
48
|
+
mol_counts.each { |element, n| counts[element] += n }
|
|
49
|
+
end
|
|
50
|
+
counts
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def count_molecule(molecule)
|
|
54
|
+
coefficient = molecule.coefficient.to_i
|
|
55
|
+
coefficient = 1 if coefficient.zero?
|
|
56
|
+
raw = enumerate(molecule, 1)
|
|
57
|
+
return nil if raw.nil?
|
|
58
|
+
|
|
59
|
+
raw.transform_values { |v| v * coefficient }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Recursive enumeration with a current multiplier.
|
|
63
|
+
def enumerate(node, multiplier)
|
|
64
|
+
case node
|
|
65
|
+
when AsciiChem::Model::Atom
|
|
66
|
+
sub = (node.subscript || "1").to_i
|
|
67
|
+
sub = 1 if sub.zero?
|
|
68
|
+
{ node.element => sub * multiplier }
|
|
69
|
+
when AsciiChem::Model::Molecule
|
|
70
|
+
enumerate_many(node.nodes, multiplier)
|
|
71
|
+
when AsciiChem::Model::Group
|
|
72
|
+
mult = (node.multiplicity || "1").to_i
|
|
73
|
+
mult = 1 if mult.zero?
|
|
74
|
+
enumerate_many(node.nodes, multiplier * mult)
|
|
75
|
+
else
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def enumerate_many(nodes, multiplier)
|
|
81
|
+
result = Hash.new(0)
|
|
82
|
+
nodes.each do |node|
|
|
83
|
+
sub = enumerate(node, multiplier)
|
|
84
|
+
return nil if sub.nil?
|
|
85
|
+
|
|
86
|
+
sub.each { |element, n| result[element] += n }
|
|
87
|
+
end
|
|
88
|
+
result
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def compute_diffs(reactants, products)
|
|
92
|
+
all_elements = (reactants.keys + products.keys).uniq.sort
|
|
93
|
+
all_elements.map do |element|
|
|
94
|
+
r = reactants.fetch(element, 0)
|
|
95
|
+
p = products.fetch(element, 0)
|
|
96
|
+
next if r == p
|
|
97
|
+
|
|
98
|
+
"#{element}: #{r} vs #{p}"
|
|
99
|
+
end.compact.join(", ")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Linter
|
|
5
|
+
# Base class for linter checks. Subclasses implement `run(formula)`
|
|
6
|
+
# and return an array of Diagnostic objects.
|
|
7
|
+
#
|
|
8
|
+
# Self-registration: subclasses call `register(:name)` inside their
|
|
9
|
+
# class body. The Linter module triggers every autoload at load
|
|
10
|
+
# time so each check file has a chance to register before any API
|
|
11
|
+
# is queried.
|
|
12
|
+
class Base
|
|
13
|
+
class << self
|
|
14
|
+
def register(name)
|
|
15
|
+
AsciiChem::Linter::Registry.add(name, self)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run(_formula)
|
|
20
|
+
raise NotImplementedError, "#{self.class} must implement #run"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
protected
|
|
24
|
+
|
|
25
|
+
def error(message, node: nil)
|
|
26
|
+
Diagnostic.new(severity: :error, message: message, node: node)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def warning(message, node: nil)
|
|
30
|
+
Diagnostic.new(severity: :warning, message: message, node: node)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def info(message, node: nil)
|
|
34
|
+
Diagnostic.new(severity: :info, message: message, node: node)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Depth-first walk over every node in the formula. Yields each
|
|
38
|
+
# node to the block. Uses `Node#children` — adding a new
|
|
39
|
+
# container class means defining `children` on it; no edits here.
|
|
40
|
+
def walk(formula)
|
|
41
|
+
return enum_for(:walk, formula) unless block_given?
|
|
42
|
+
|
|
43
|
+
walk_node(formula) { |c| yield c }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def walk_node(node)
|
|
47
|
+
yield node
|
|
48
|
+
node.children.each { |c| walk_node(c) { |x| yield x } }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Linter
|
|
5
|
+
# Verifies that group brackets are matched. The grammar already
|
|
6
|
+
# enforces this at parse time, but the linter adds defence in depth
|
|
7
|
+
# in case future grammar changes or direct model construction
|
|
8
|
+
# produce malformed trees.
|
|
9
|
+
class BracketBalanceCheck < Base
|
|
10
|
+
register :bracket_balance
|
|
11
|
+
|
|
12
|
+
def run(formula)
|
|
13
|
+
diagnostics = []
|
|
14
|
+
walk(formula) do |node|
|
|
15
|
+
next unless node.is_a?(AsciiChem::Model::Group)
|
|
16
|
+
|
|
17
|
+
case node.bracket
|
|
18
|
+
when :paren then check_pair(node, "(", ")", diagnostics)
|
|
19
|
+
when :square then check_pair(node, "[", "]", diagnostics)
|
|
20
|
+
when :brace then check_pair(node, "{", "}", diagnostics)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
diagnostics
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def check_pair(group, open_char, close_char, diagnostics)
|
|
29
|
+
if group.open_char != open_char
|
|
30
|
+
diagnostics << error(
|
|
31
|
+
"Group bracket kind #{group.bracket.inspect} expected opening #{open_char.inspect}, got #{group.open_char.inspect}",
|
|
32
|
+
node: group
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
return if group.close_char == close_char
|
|
36
|
+
|
|
37
|
+
diagnostics << error(
|
|
38
|
+
"Group bracket kind #{group.bracket.inspect} expected closing #{close_char.inspect}, got #{group.close_char.inspect}",
|
|
39
|
+
node: group
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Linter
|
|
5
|
+
Diagnostic = Struct.new(:severity, :message, :node, keyword_init: true) do
|
|
6
|
+
def to_s
|
|
7
|
+
context = node&.diagnostic_label
|
|
8
|
+
if context
|
|
9
|
+
"[#{severity}] #{context}: #{message}"
|
|
10
|
+
else
|
|
11
|
+
"[#{severity}] #{message}"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Linter
|
|
5
|
+
# Validates that each Atom's element symbol is in the periodic
|
|
6
|
+
# table. Catches typos like `Hx`, `Cy`, `Oq` that would otherwise
|
|
7
|
+
# silently parse and produce nonsense output. Uses
|
|
8
|
+
# `AsciiChem::PeriodicTable` as the single source of truth.
|
|
9
|
+
#
|
|
10
|
+
# Severity is `warning`, not `error` — the parser is total and
|
|
11
|
+
# might intentionally accept placeholder elements (e.g. `X` for
|
|
12
|
+
# "unknown halogen" in teaching contexts). The linter flags them
|
|
13
|
+
# for the user to confirm.
|
|
14
|
+
class ElementValidationCheck < Base
|
|
15
|
+
register :element_validation
|
|
16
|
+
|
|
17
|
+
def run(formula)
|
|
18
|
+
diagnostics = []
|
|
19
|
+
walk(formula) do |node|
|
|
20
|
+
next unless node.is_a?(AsciiChem::Model::Atom)
|
|
21
|
+
|
|
22
|
+
check_atom(node, diagnostics)
|
|
23
|
+
end
|
|
24
|
+
diagnostics
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def check_atom(atom, diagnostics)
|
|
30
|
+
return if AsciiChem::PeriodicTable.known?(atom.element)
|
|
31
|
+
|
|
32
|
+
diagnostics << warning(
|
|
33
|
+
"Unknown element symbol #{atom.element.inspect}; not in the periodic table",
|
|
34
|
+
node: atom
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Linter
|
|
5
|
+
# Sanity-checks isotope mass numbers against the element's atomic
|
|
6
|
+
# number. The isotope's mass must be ≥ the atomic number (a nucleus
|
|
7
|
+
# can't have fewer protons than its mass number suggests).
|
|
8
|
+
#
|
|
9
|
+
# Atomic-number table is limited to common elements; unknown
|
|
10
|
+
# elements produce an info diagnostic, not an error.
|
|
11
|
+
class IsotopeSanityCheck < Base
|
|
12
|
+
register :isotope_sanity
|
|
13
|
+
|
|
14
|
+
def run(formula)
|
|
15
|
+
diagnostics = []
|
|
16
|
+
walk(formula) do |node|
|
|
17
|
+
next unless node.is_a?(AsciiChem::Model::Atom)
|
|
18
|
+
next unless node.isotope
|
|
19
|
+
|
|
20
|
+
check_atom(node, diagnostics)
|
|
21
|
+
end
|
|
22
|
+
diagnostics
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def check_atom(atom, diagnostics)
|
|
28
|
+
z = AsciiChem::PeriodicTable.atomic_number(atom.element)
|
|
29
|
+
if z.nil?
|
|
30
|
+
diagnostics << info(
|
|
31
|
+
"Element #{atom.element.inspect} not in periodic table; skipping isotope check",
|
|
32
|
+
node: atom
|
|
33
|
+
)
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
mass = atom.isotope.to_i
|
|
38
|
+
return if mass >= z
|
|
39
|
+
|
|
40
|
+
diagnostics << error(
|
|
41
|
+
"Isotope mass #{mass} is less than atomic number #{z} for #{atom.element}",
|
|
42
|
+
node: atom
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|