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,429 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "chemicalml"
4
+
5
+ module AsciiChem
6
+ module ModelAdapter
7
+ # AsciiChem::Model -> Chemicalml::Model. Walks the AsciiChem tree
8
+ # and builds a canonical document. Pure transformation; no I/O.
9
+ #
10
+ # Mapping rules:
11
+ #
12
+ # - `Formula` -> `Chemicalml::Model::Document`.
13
+ # - `Molecule` -> `Chemicalml::Model::Molecule`. Inner atoms
14
+ # collect IDs; bonds reference consecutive
15
+ # IDs. Groups flatten with their multiplicity
16
+ # applied to each contained atom's count.
17
+ # - `Atom` -> `Chemicalml::Model::Atom` (element,
18
+ # isotope, charge, count, lone pairs,
19
+ # radical electrons).
20
+ # - `Bond` -> `Chemicalml::Model::Bond` (kind, refs).
21
+ # - `Reaction` -> `Chemicalml::Model::Reaction`.
22
+ # - `ReactionCascade` -> `Chemicalml::Model::ReactionList`.
23
+ # - `ElectronConfiguration`, `EmbeddedMath`, `Text` -> skipped
24
+ # (no canonical representation yet; a future extension can carry
25
+ # them as namespaced metadata).
26
+ class ToCanonical
27
+ attr_reader :atom_mapping, :groups
28
+
29
+ def initialize(formula)
30
+ @formula = formula
31
+ @ids = IdRegistry.new
32
+ @atom_mapping = {}
33
+ # Map of canonical molecule ID -> array of GroupRecord.
34
+ # Keyed by molecule ID because aci:group elements live inside
35
+ # their parent <molecule>, and multiple molecules can exist
36
+ # (top-level + reactants + products).
37
+ @groups = Hash.new { |hash, key| hash[key] = [] }
38
+ @molecule_id_stack = []
39
+ end
40
+
41
+ def build
42
+ molecules = []
43
+ reactions = []
44
+ reaction_lists = []
45
+ @formula.nodes.each do |node|
46
+ case node
47
+ when AsciiChem::Model::Molecule
48
+ molecules << molecule_to_canonical(node)
49
+ when AsciiChem::Model::Reaction
50
+ reactions << reaction_to_canonical(node)
51
+ when AsciiChem::Model::ReactionCascade
52
+ reaction_lists << reaction_cascade_to_canonical(node)
53
+ end
54
+ end
55
+ Chemicalml::Model::Document.new(
56
+ molecules: molecules,
57
+ reactions: reactions,
58
+ reaction_lists: reaction_lists
59
+ )
60
+ end
61
+
62
+ private
63
+
64
+ # -- Molecules --------------------------------------------------
65
+
66
+ def molecule_to_canonical(molecule)
67
+ walker = MoleculeWalker.new(@ids)
68
+ atoms, bonds = walker.walk(molecule)
69
+ @atom_mapping.merge!(walker.atom_mapping)
70
+ canonical_molecule = Chemicalml::Model::Molecule.new(
71
+ id: @ids.next(:molecule),
72
+ atoms: atoms,
73
+ bonds: bonds,
74
+ count: molecule.coefficient,
75
+ formal_charge: total_formal_charge(atoms),
76
+ names: map_names(molecule.names),
77
+ identifiers: map_identifiers(molecule.identifiers),
78
+ title: molecule.title,
79
+ formulas: map_formulas(molecule.formulas),
80
+ properties: map_properties(molecule.properties),
81
+ labels: map_labels(molecule.labels)
82
+ )
83
+ @groups[canonical_molecule.id].concat(walker.groups) unless walker.groups.empty?
84
+ canonical_molecule
85
+ end
86
+
87
+ def map_names(names)
88
+ return [] if names.nil? || names.empty?
89
+
90
+ names.map do |n|
91
+ Chemicalml::Model::Name.new(
92
+ content: n.content,
93
+ convention: n.convention,
94
+ dict_ref: n.dict_ref
95
+ )
96
+ end
97
+ end
98
+
99
+ def map_identifiers(identifiers)
100
+ return [] if identifiers.nil? || identifiers.empty?
101
+
102
+ identifiers.map do |i|
103
+ Chemicalml::Model::Identifier.new(
104
+ value: i.value,
105
+ convention: i.convention,
106
+ dict_ref: i.dict_ref
107
+ )
108
+ end
109
+ end
110
+
111
+ def map_formulas(formulas)
112
+ return [] if formulas.nil? || formulas.empty?
113
+
114
+ formulas.map do |f|
115
+ Chemicalml::Model::Formula.new(
116
+ concise: f[:concise],
117
+ inline: f[:inline],
118
+ formal_charge: f[:formal_charge],
119
+ count: f[:count],
120
+ title: f[:title],
121
+ convention: f[:convention],
122
+ dict_ref: f[:dict_ref]
123
+ )
124
+ end
125
+ end
126
+
127
+ def map_properties(properties)
128
+ return [] if properties.nil? || properties.empty?
129
+
130
+ properties.map do |p|
131
+ Chemicalml::Model::Property.new(
132
+ title: p[:title],
133
+ value: build_scalar(p[:value]),
134
+ dict_ref: p[:dict_ref],
135
+ convention: p[:convention]
136
+ )
137
+ end
138
+ end
139
+
140
+ def build_scalar(value)
141
+ return nil if value.nil?
142
+
143
+ Chemicalml::Model::Scalar.new(
144
+ value: value.to_s,
145
+ dict_ref: nil
146
+ )
147
+ end
148
+
149
+ def map_labels(labels)
150
+ return [] if labels.nil? || labels.empty?
151
+
152
+ labels.map do |l|
153
+ Chemicalml::Model::Label.new(
154
+ value: l[:value],
155
+ dict_ref: l[:dict_ref],
156
+ convention: l[:convention]
157
+ )
158
+ end
159
+ end
160
+
161
+ def total_formal_charge(canonical_atoms)
162
+ charges = canonical_atoms.map { |a| parse_charge(a.formal_charge) }.compact
163
+ return nil if charges.empty?
164
+
165
+ sum = charges.sum
166
+ sum.positive? ? "+#{sum}" : sum.to_s
167
+ end
168
+
169
+ def parse_charge(value)
170
+ return nil if value.nil? || value.to_s.empty?
171
+
172
+ match = value.to_s.match(/\A(?<n>\d*)(?<sign>[+-])\z/) ||
173
+ value.to_s.match(/\A(?<sign>[+-])(?<n>\d*)\z/)
174
+ return nil unless match
175
+
176
+ n = match[:n].empty? ? 1 : match[:n].to_i
177
+ match[:sign] == "+" ? n : -n
178
+ end
179
+
180
+ # -- Reactions --------------------------------------------------
181
+
182
+ def reaction_to_canonical(reaction)
183
+ Chemicalml::Model::Reaction.new(
184
+ id: @ids.next(:reaction),
185
+ reactant_list: reactant_list_to_canonical(reaction.reactants),
186
+ product_list: product_list_to_canonical(reaction.products),
187
+ arrow: reaction.arrow,
188
+ conditions_above: conditions_above(reaction),
189
+ conditions_below: conditions_below(reaction),
190
+ title: reaction.arrow.to_s,
191
+ type: reaction.arrow.to_s
192
+ )
193
+ end
194
+
195
+ def reaction_cascade_to_canonical(cascade)
196
+ Chemicalml::Model::ReactionList.new(
197
+ reactions: cascade.steps.map { |s| reaction_to_canonical(s) }
198
+ )
199
+ end
200
+
201
+ def reactant_list_to_canonical(reactants)
202
+ Chemicalml::Model::ReactantList.new(
203
+ reactants: reactants.map { |m| participant_to_canonical(m, :reactant) }
204
+ )
205
+ end
206
+
207
+ def product_list_to_canonical(products)
208
+ Chemicalml::Model::ProductList.new(
209
+ products: products.map { |m| participant_to_canonical(m, :product) }
210
+ )
211
+ end
212
+
213
+ def participant_to_canonical(molecule, role)
214
+ Chemicalml::Model::Reactant.new(
215
+ substance: Chemicalml::Model::Substance.new(
216
+ molecule: molecule_to_canonical(molecule),
217
+ role: role
218
+ )
219
+ )
220
+ end
221
+
222
+ def conditions_above(reaction)
223
+ reaction.conditions&.above
224
+ end
225
+
226
+ def conditions_below(reaction)
227
+ reaction.conditions&.below
228
+ end
229
+
230
+ # Walks an AsciiChem::Model::Molecule and produces an ordered
231
+ # atom list + a bond list with proper atomRefs. The walker is
232
+ # the single source of truth for atom ID assignment — bonds,
233
+ # reactants, and products all reference the IDs it produces.
234
+ # Also exposes `atom_mapping` (canonical_id => source Atom) so
235
+ # downstream consumers (CML extension serializer, debug tools)
236
+ # can match canonical atoms back to their AsciiChem source, and
237
+ # `groups` (per-molecule list of group structure records) so the
238
+ # aci: extension can preserve grouping through CML round-trip.
239
+ class MoleculeWalker
240
+ attr_reader :atom_mapping, :groups
241
+
242
+ def initialize(ids)
243
+ @ids = ids
244
+ @atoms = []
245
+ @bonds = []
246
+ @atom_mapping = {}
247
+ @atom_id_by_object_id = {}
248
+ @groups = []
249
+ @group_stack = []
250
+ @pending_bond_kind = nil
251
+ @last_atom_id = nil
252
+ end
253
+
254
+ def walk(molecule)
255
+ walk_nodes(molecule.nodes, multiplier: nil)
256
+ emit_ring_bonds(molecule)
257
+ [@atoms, @bonds]
258
+ end
259
+
260
+ # Emit canonical bonds for each ring-closure pair on atoms
261
+ # in this molecule. The walker has already assigned IDs to
262
+ # every atom; RingBonds.each_in yields pairs which we look up
263
+ # in @atom_id_by_object_id to translate Ruby object identity
264
+ # into canonical atom IDs. Skips pairs that already have a
265
+ # positional bond between them (degenerate case like `C1-C1`).
266
+ def emit_ring_bonds(molecule)
267
+ existing_pair_keys = @bonds.map { |b| pair_key(b.atom_refs) }.to_set
268
+ AsciiChem::RingBonds.each_in(molecule) do |ring_bond|
269
+ from_id = @atom_id_by_object_id[ring_bond.from_atom.object_id]
270
+ to_id = @atom_id_by_object_id[ring_bond.to_atom.object_id]
271
+ next unless from_id && to_id
272
+
273
+ key = pair_key([from_id, to_id])
274
+ next if existing_pair_keys.include?(key)
275
+
276
+ @bonds << Chemicalml::Model::Bond.new(
277
+ id: @ids.next(:bond),
278
+ atom_refs: [from_id, to_id],
279
+ kind: :single
280
+ )
281
+ existing_pair_keys << key
282
+ end
283
+ end
284
+
285
+ # Order-independent key for a pair of atom IDs, so a-b and b-a
286
+ # compare equal.
287
+ def pair_key(refs)
288
+ refs.sort.join(":")
289
+ end
290
+
291
+ private
292
+
293
+ def walk_nodes(nodes, multiplier:)
294
+ nodes.each do |node|
295
+ case node
296
+ when AsciiChem::Model::Atom
297
+ emit_atom(node, multiplier)
298
+ when AsciiChem::Model::Bond
299
+ @pending_bond_kind = node.kind
300
+ when AsciiChem::Model::Group
301
+ enter_group(node, multiplier)
302
+ when AsciiChem::Model::Molecule
303
+ walk_nodes(node.nodes, multiplier: combine(multiplier, node.coefficient))
304
+ end
305
+ end
306
+ end
307
+
308
+ def enter_group(group, multiplier)
309
+ record = GroupRecord.new(
310
+ id: @ids.next(:group),
311
+ multiplicity: group.multiplicity,
312
+ bracket: group.bracket,
313
+ atom_ids: []
314
+ )
315
+ @groups << record
316
+ @group_stack.push(record)
317
+ walk_nodes(group.nodes, multiplier: combine(multiplier, group.multiplicity))
318
+ @group_stack.pop
319
+ end
320
+
321
+ def emit_atom(atom, multiplier)
322
+ id = @ids.next(:atom)
323
+ @atom_mapping[id] = atom
324
+ @atom_id_by_object_id[atom.object_id] = id
325
+ @group_stack.each { |record| record.atom_ids << id }
326
+ attrs = {
327
+ id: id,
328
+ element: atom.element,
329
+ isotope: atom.isotope,
330
+ formal_charge: atom.charge,
331
+ count: effective_count(atom, multiplier),
332
+ lone_pairs: atom.lone_pairs,
333
+ radical_electrons: atom.radical_electrons
334
+ }
335
+ merge_coordinates(attrs, atom)
336
+ @atoms << Chemicalml::Model::Atom.new(**attrs)
337
+ emit_pending_bond(id) if @pending_bond_kind && @last_atom_id
338
+ @last_atom_id = id
339
+ @pending_bond_kind = nil
340
+ end
341
+
342
+ def emit_pending_bond(next_atom_id)
343
+ @bonds << Chemicalml::Model::Bond.new(
344
+ id: @ids.next(:bond),
345
+ atom_refs: [@last_atom_id, next_atom_id],
346
+ kind: @pending_bond_kind,
347
+ bond_stereo: bond_stereo_for(@pending_bond_kind)
348
+ )
349
+ end
350
+
351
+ # CML bondStereo codes for stereo bonds. Wedge (coming out of
352
+ # page) → "W"; hash (going into page) → "H". Other bond kinds
353
+ # have no stereo meaning.
354
+ BOND_STEREO_CODES = { wedge: "W", hash: "H" }.freeze
355
+
356
+ def bond_stereo_for(kind)
357
+ code = BOND_STEREO_CODES[kind]
358
+ return nil unless code
359
+
360
+ Chemicalml::Model::BondStereo.new(value: code)
361
+ end
362
+
363
+ def effective_count(atom, multiplier)
364
+ sub = atom.subscript
365
+ return combine(multiplier, sub) if multiplier
366
+ return sub if sub
367
+
368
+ nil
369
+ end
370
+
371
+ # Map AsciiChem 2D/3D coordinates onto canonical atom attrs.
372
+ # If z2 is present, it's 3D → x3/y3/z3. Otherwise 2D → x2/y2.
373
+ def merge_coordinates(attrs, atom)
374
+ return unless atom.x2 && atom.y2
375
+
376
+ if atom.z2
377
+ attrs[:x3] = atom.x2
378
+ attrs[:y3] = atom.y2
379
+ attrs[:z3] = atom.z2
380
+ else
381
+ attrs[:x2] = atom.x2
382
+ attrs[:y2] = atom.y2
383
+ end
384
+ end
385
+
386
+ def combine(left, right)
387
+ lv = integer_or_nil(left)
388
+ rv = integer_or_nil(right)
389
+ return nil unless lv || rv
390
+
391
+ (lv || 1) * (rv || 1)
392
+ end
393
+
394
+ def integer_or_nil(value)
395
+ return nil if value.nil? || value.to_s.empty?
396
+
397
+ Integer(value, exception: false)
398
+ end
399
+ end
400
+ private_constant :MoleculeWalker
401
+
402
+ # Captures the structure of an AsciiChem::Model::Group during
403
+ # the canonical walk. The walker assigns IDs to atoms in source
404
+ # order; the GroupRecord lists which atoms were inside the group
405
+ # (transitively, for nested groups). Used by `Cml::GroupExtensions`
406
+ # to preserve grouping through the canonical model.
407
+ GroupRecord = Struct.new(:id, :multiplicity, :bracket, :atom_ids, keyword_init: true)
408
+ private_constant :GroupRecord
409
+
410
+ # Per-build ID registry. Issues stable IDs within a single
411
+ # translation pass (a1, a2, b1, m1, r1, ...) so re-running the
412
+ # adapter on equivalent input yields byte-equal output.
413
+ class IdRegistry
414
+ PREFIXES = { atom: "a", bond: "b", molecule: "m",
415
+ reaction: "r", group: "g" }.freeze
416
+
417
+ def initialize
418
+ @counters = Hash.new(0)
419
+ end
420
+
421
+ def next(kind)
422
+ @counters[kind] += 1
423
+ "#{PREFIXES.fetch(kind)}#{@counters[kind]}"
424
+ end
425
+ end
426
+ private_constant :IdRegistry
427
+ end
428
+ end
429
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ # Adapter between AsciiChem::Model (the chemistry-semantic tree the
5
+ # text parser produces) and Chemicalml::Model (the format-agnostic
6
+ # canonical hub every chemistry interchange format speaks).
7
+ #
8
+ # The adapter is bidirectional and pure — no I/O, no wire format
9
+ # concerns. Composed with `Chemicalml::Cml::Translator` it forms the
10
+ # AsciiChem <-> CML pipeline:
11
+ #
12
+ # AsciiChem::Model <-> ModelAdapter <-> Chemicalml::Model
13
+ # ^
14
+ # |
15
+ # Chemicalml::Cml::Translator
16
+ # |
17
+ # v
18
+ # Chemicalml::Cml::* (wire)
19
+ #
20
+ # Adding a new field to either model means updating this adapter's
21
+ # mapping rules; the parsers, formatters, and wire classes stay
22
+ # independent.
23
+ module ModelAdapter
24
+ autoload :ToCanonical, "asciichem/model_adapter/to_canonical"
25
+ autoload :FromCanonical, "asciichem/model_adapter/from_canonical"
26
+
27
+ # Result of a translation that also exposes the per-atom mapping
28
+ # (canonical_atom_id => AsciiChem::Model::Atom) and the per-molecule
29
+ # group structure (molecule_id => Array<GroupRecord>). Used by
30
+ # callers that need to track per-atom side data through the
31
+ # canonical pipeline (e.g. the CML extension namespace).
32
+ Translation = Struct.new(:document, :atom_mapping, :groups, keyword_init: true)
33
+
34
+ def self.to_canonical(formula)
35
+ ToCanonical.new(formula).build
36
+ end
37
+
38
+ # Returns the canonical document plus the per-atom mapping and
39
+ # per-molecule group structure. Use this when you need to know
40
+ # which canonical atom corresponds to which AsciiChem::Model::Atom
41
+ # or which atoms were originally grouped (e.g. for extension data).
42
+ def self.to_canonical_with_mapping(formula)
43
+ builder = ToCanonical.new(formula)
44
+ document = builder.build
45
+ Translation.new(
46
+ document: document,
47
+ atom_mapping: builder.atom_mapping,
48
+ groups: builder.groups
49
+ )
50
+ end
51
+
52
+ def self.from_canonical(document)
53
+ FromCanonical.new(document).build
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ class Parser
5
+ attr_reader :text
6
+
7
+ def initialize(text)
8
+ @text = text.to_s
9
+ end
10
+
11
+ def parse
12
+ tree = AsciiChem::Grammar.new.parse(text)
13
+ AsciiChem::Transform.new.apply(tree)
14
+ rescue Parslet::ParseFailed => e
15
+ raise AsciiChem::ParseError, format_error(e)
16
+ end
17
+
18
+ private
19
+
20
+ def format_error(error)
21
+ pos = error.message.match(/char (\d+)/)
22
+ char = pos ? pos[1].to_i : 0
23
+ snippet = text[[char - 10, 0].max, 20] || text
24
+ pointer = " " * (char - [char - 10, 0].max) + "^"
25
+ expected = extract_expected(error.message)
26
+ "Parse error at char #{char}: expected #{expected}\n" \
27
+ " ...#{snippet}...\n" \
28
+ " #{pointer}"
29
+ end
30
+
31
+ def extract_expected(message)
32
+ match = message.match(/Expected:? (.+?)(?:\s+at|\s*$)/)
33
+ match ? match[1].strip : "valid AsciiChem syntax"
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ # Periodic table as a single source of truth for element data.
5
+ # Used by linter checks (element validation, isotope sanity, valence)
6
+ # and any future code that needs chemically-grounded defaults.
7
+ #
8
+ # Adding a new field (e.g. covalent radius for layout, common
9
+ # oxidation states for redox checks) means adding one column to
10
+ # `Element` and populating it across `ELEMENTS` — no other code
11
+ # changes.
12
+ module PeriodicTable
13
+ Element = Struct.new(:symbol, :atomic_number, :max_valence, keyword_init: true)
14
+
15
+ # Subset covering the elements chemistry most commonly deals with.
16
+ # Each entry: symbol => Element. Adding more elements is one
17
+ # line per element; the linter picks them up automatically.
18
+ ELEMENTS = {
19
+ # Period 1
20
+ "H" => Element.new(symbol: "H", atomic_number: 1, max_valence: 1),
21
+ "He" => Element.new(symbol: "He", atomic_number: 2, max_valence: 0),
22
+ # Period 2
23
+ "Li" => Element.new(symbol: "Li", atomic_number: 3, max_valence: 1),
24
+ "Be" => Element.new(symbol: "Be", atomic_number: 4, max_valence: 2),
25
+ "B" => Element.new(symbol: "B", atomic_number: 5, max_valence: 3),
26
+ "C" => Element.new(symbol: "C", atomic_number: 6, max_valence: 4),
27
+ "N" => Element.new(symbol: "N", atomic_number: 7, max_valence: 3),
28
+ "O" => Element.new(symbol: "O", atomic_number: 8, max_valence: 2),
29
+ "F" => Element.new(symbol: "F", atomic_number: 9, max_valence: 1),
30
+ "Ne" => Element.new(symbol: "Ne", atomic_number: 10, max_valence: 0),
31
+ # Period 3
32
+ "Na" => Element.new(symbol: "Na", atomic_number: 11, max_valence: 1),
33
+ "Mg" => Element.new(symbol: "Mg", atomic_number: 12, max_valence: 2),
34
+ "Al" => Element.new(symbol: "Al", atomic_number: 13, max_valence: 3),
35
+ "Si" => Element.new(symbol: "Si", atomic_number: 14, max_valence: 4),
36
+ "P" => Element.new(symbol: "P", atomic_number: 15, max_valence: 5),
37
+ "S" => Element.new(symbol: "S", atomic_number: 16, max_valence: 6),
38
+ "Cl" => Element.new(symbol: "Cl", atomic_number: 17, max_valence: 1),
39
+ "Ar" => Element.new(symbol: "Ar", atomic_number: 18, max_valence: 0),
40
+ # Period 4 (common)
41
+ "K" => Element.new(symbol: "K", atomic_number: 19, max_valence: 1),
42
+ "Ca" => Element.new(symbol: "Ca", atomic_number: 20, max_valence: 2),
43
+ "Sc" => Element.new(symbol: "Sc", atomic_number: 21, max_valence: 3),
44
+ "Ti" => Element.new(symbol: "Ti", atomic_number: 22, max_valence: 4),
45
+ "V" => Element.new(symbol: "V", atomic_number: 23, max_valence: 5),
46
+ "Cr" => Element.new(symbol: "Cr", atomic_number: 24, max_valence: 6),
47
+ "Mn" => Element.new(symbol: "Mn", atomic_number: 25, max_valence: 7),
48
+ "Fe" => Element.new(symbol: "Fe", atomic_number: 26, max_valence: 6),
49
+ "Co" => Element.new(symbol: "Co", atomic_number: 27, max_valence: 4),
50
+ "Ni" => Element.new(symbol: "Ni", atomic_number: 28, max_valence: 4),
51
+ "Cu" => Element.new(symbol: "Cu", atomic_number: 29, max_valence: 4),
52
+ "Zn" => Element.new(symbol: "Zn", atomic_number: 30, max_valence: 2),
53
+ "Ga" => Element.new(symbol: "Ga", atomic_number: 31, max_valence: 3),
54
+ "Ge" => Element.new(symbol: "Ge", atomic_number: 32, max_valence: 4),
55
+ "As" => Element.new(symbol: "As", atomic_number: 33, max_valence: 3),
56
+ "Se" => Element.new(symbol: "Se", atomic_number: 34, max_valence: 2),
57
+ "Br" => Element.new(symbol: "Br", atomic_number: 35, max_valence: 1),
58
+ "Kr" => Element.new(symbol: "Kr", atomic_number: 36, max_valence: 0),
59
+ # Period 5 (common)
60
+ "Rb" => Element.new(symbol: "Rb", atomic_number: 37, max_valence: 1),
61
+ "Sr" => Element.new(symbol: "Sr", atomic_number: 38, max_valence: 2),
62
+ "Y" => Element.new(symbol: "Y", atomic_number: 39, max_valence: 3),
63
+ "Zr" => Element.new(symbol: "Zr", atomic_number: 40, max_valence: 4),
64
+ "Nb" => Element.new(symbol: "Nb", atomic_number: 41, max_valence: 5),
65
+ "Mo" => Element.new(symbol: "Mo", atomic_number: 42, max_valence: 6),
66
+ "Tc" => Element.new(symbol: "Tc", atomic_number: 43, max_valence: 6),
67
+ "Ru" => Element.new(symbol: "Ru", atomic_number: 44, max_valence: 6),
68
+ "Rh" => Element.new(symbol: "Rh", atomic_number: 45, max_valence: 6),
69
+ "Pd" => Element.new(symbol: "Pd", atomic_number: 46, max_valence: 4),
70
+ "Ag" => Element.new(symbol: "Ag", atomic_number: 47, max_valence: 4),
71
+ "Cd" => Element.new(symbol: "Cd", atomic_number: 48, max_valence: 2),
72
+ "In" => Element.new(symbol: "In", atomic_number: 49, max_valence: 3),
73
+ "Sn" => Element.new(symbol: "Sn", atomic_number: 50, max_valence: 4),
74
+ "Sb" => Element.new(symbol: "Sb", atomic_number: 51, max_valence: 3),
75
+ "Te" => Element.new(symbol: "Te", atomic_number: 52, max_valence: 2),
76
+ "I" => Element.new(symbol: "I", atomic_number: 53, max_valence: 1),
77
+ "Xe" => Element.new(symbol: "Xe", atomic_number: 54, max_valence: 0),
78
+ # Period 6 (common)
79
+ "Cs" => Element.new(symbol: "Cs", atomic_number: 55, max_valence: 1),
80
+ "Ba" => Element.new(symbol: "Ba", atomic_number: 56, max_valence: 2),
81
+ "W" => Element.new(symbol: "W", atomic_number: 74, max_valence: 6),
82
+ "Pt" => Element.new(symbol: "Pt", atomic_number: 78, max_valence: 4),
83
+ "Au" => Element.new(symbol: "Au", atomic_number: 79, max_valence: 6),
84
+ "Hg" => Element.new(symbol: "Hg", atomic_number: 80, max_valence: 2),
85
+ "Tl" => Element.new(symbol: "Tl", atomic_number: 81, max_valence: 3),
86
+ "Pb" => Element.new(symbol: "Pb", atomic_number: 82, max_valence: 4),
87
+ "Bi" => Element.new(symbol: "Bi", atomic_number: 83, max_valence: 3),
88
+ # Period 7 (common)
89
+ "U" => Element.new(symbol: "U", atomic_number: 92, max_valence: 6)
90
+ }.freeze
91
+
92
+ def self.element(symbol)
93
+ ELEMENTS[symbol.to_s]
94
+ end
95
+
96
+ def self.known?(symbol)
97
+ ELEMENTS.key?(symbol.to_s)
98
+ end
99
+
100
+ def self.atomic_number(symbol)
101
+ element(symbol.to_s)&.atomic_number
102
+ end
103
+
104
+ def self.max_valence(symbol)
105
+ element(symbol.to_s)&.max_valence
106
+ end
107
+
108
+ def self.symbols
109
+ ELEMENTS.keys
110
+ end
111
+ end
112
+ end