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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +21 -0
  3. data/.gitignore +14 -0
  4. data/.rubocop.yml +50 -0
  5. data/ARCHITECTURE.adoc +239 -0
  6. data/CHANGELOG.md +17 -0
  7. data/CLAUDE.md +318 -0
  8. data/Gemfile +14 -0
  9. data/LICENSE +24 -0
  10. data/README.adoc +55 -0
  11. data/RELEASING.md +102 -0
  12. data/Rakefile +8 -0
  13. data/asciichem.gemspec +39 -0
  14. data/benchmarks/RESULTS.md +49 -0
  15. data/benchmarks/benchmark.rb +106 -0
  16. data/exe/asciichem +6 -0
  17. data/lib/asciichem/cli.rb +96 -0
  18. data/lib/asciichem/cml/extensions.rb +360 -0
  19. data/lib/asciichem/cml/group_extensions.rb +290 -0
  20. data/lib/asciichem/cml/translator.rb +69 -0
  21. data/lib/asciichem/cml.rb +40 -0
  22. data/lib/asciichem/errors.rb +9 -0
  23. data/lib/asciichem/formatter/base.rb +21 -0
  24. data/lib/asciichem/formatter/html.rb +111 -0
  25. data/lib/asciichem/formatter/latex.rb +173 -0
  26. data/lib/asciichem/formatter/mathml.rb +309 -0
  27. data/lib/asciichem/formatter/structural_svg.rb +286 -0
  28. data/lib/asciichem/formatter/svg.rb +141 -0
  29. data/lib/asciichem/formatter/text.rb +143 -0
  30. data/lib/asciichem/formatter.rb +43 -0
  31. data/lib/asciichem/grammar.rb +344 -0
  32. data/lib/asciichem/greek.rb +80 -0
  33. data/lib/asciichem/layout.rb +313 -0
  34. data/lib/asciichem/linter/balance_check.rb +103 -0
  35. data/lib/asciichem/linter/base.rb +52 -0
  36. data/lib/asciichem/linter/bracket_balance_check.rb +44 -0
  37. data/lib/asciichem/linter/diagnostic.rb +16 -0
  38. data/lib/asciichem/linter/element_validation_check.rb +39 -0
  39. data/lib/asciichem/linter/isotope_sanity_check.rb +47 -0
  40. data/lib/asciichem/linter/registry.rb +31 -0
  41. data/lib/asciichem/linter/unclosed_ring_check.rb +27 -0
  42. data/lib/asciichem/linter/valence_check.rb +91 -0
  43. data/lib/asciichem/linter.rb +40 -0
  44. data/lib/asciichem/model/atom.rb +73 -0
  45. data/lib/asciichem/model/bond.rb +41 -0
  46. data/lib/asciichem/model/electron_configuration.rb +36 -0
  47. data/lib/asciichem/model/embedded_math.rb +26 -0
  48. data/lib/asciichem/model/formula.rb +31 -0
  49. data/lib/asciichem/model/group.rb +50 -0
  50. data/lib/asciichem/model/identifier.rb +14 -0
  51. data/lib/asciichem/model/molecule.rb +70 -0
  52. data/lib/asciichem/model/name.rb +15 -0
  53. data/lib/asciichem/model/node.rb +100 -0
  54. data/lib/asciichem/model/reaction.rb +49 -0
  55. data/lib/asciichem/model/reaction_cascade.rb +33 -0
  56. data/lib/asciichem/model/text.rb +23 -0
  57. data/lib/asciichem/model.rb +23 -0
  58. data/lib/asciichem/model_adapter/from_canonical.rb +272 -0
  59. data/lib/asciichem/model_adapter/to_canonical.rb +429 -0
  60. data/lib/asciichem/model_adapter.rb +56 -0
  61. data/lib/asciichem/parser.rb +36 -0
  62. data/lib/asciichem/periodic_table.rb +112 -0
  63. data/lib/asciichem/ring_bonds.rb +68 -0
  64. data/lib/asciichem/transform.rb +513 -0
  65. data/lib/asciichem/version.rb +5 -0
  66. data/lib/asciichem/xml_builder.rb +9 -0
  67. data/lib/asciichem.rb +34 -0
  68. 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