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,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Model
5
+ # A chemical reaction: reactants, an arrow, and products. Conditions
6
+ # render above and below the arrow per IUPAC.
7
+ class Reaction < Node
8
+ Conditions = Struct.new(:above, :below, keyword_init: true)
9
+
10
+ ARROWS = {
11
+ forward: { ascii: "->", mathml_entity: "→" },
12
+ reverse: { ascii: "<-", mathml_entity: "←" },
13
+ equilibrium: { ascii: "<=>", mathml_entity: "⇌" },
14
+ resonance: { ascii: "<->", mathml_entity: "↔" }
15
+ }.freeze
16
+
17
+ attr_accessor :reactants, :products, :arrow, :conditions
18
+
19
+ def initialize(reactants:, products:, arrow: :forward, conditions: nil)
20
+ @reactants = reactants
21
+ @products = products
22
+ @arrow = arrow
23
+ @conditions = conditions || Conditions.new
24
+ end
25
+
26
+ def value_attributes
27
+ { reactants: reactants, products: products, arrow: arrow,
28
+ conditions: conditions }
29
+ end
30
+
31
+ def children
32
+ reactants + products
33
+ end
34
+
35
+ def arrow_ascii
36
+ ARROWS.fetch(arrow).fetch(:ascii)
37
+ end
38
+
39
+ def arrow_entity
40
+ ARROWS.fetch(arrow).fetch(:mathml_entity)
41
+ end
42
+
43
+ def to_s
44
+ "Reaction(#{reactants.map(&:to_s).join(' + ')} " \
45
+ "#{arrow_ascii} #{products.map(&:to_s).join(' + ')})"
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Model
5
+ # A multi-step reaction cascade: an ordered list of Reaction
6
+ # objects where each step's products are the next step's
7
+ # reactants. Source spelling is a chain of arrows:
8
+ #
9
+ # A -> B -> C -> D
10
+ #
11
+ # Single-step reactions stay as `Reaction`; only multi-step
12
+ # sequences promote to `ReactionCascade`.
13
+ class ReactionCascade < Node
14
+ attr_accessor :steps
15
+
16
+ def initialize(steps:)
17
+ @steps = steps
18
+ end
19
+
20
+ def value_attributes
21
+ { steps: steps }
22
+ end
23
+
24
+ def children
25
+ steps
26
+ end
27
+
28
+ def to_s
29
+ "ReactionCascade(#{steps.map(&:to_s).join(' >> ')})"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Model
5
+ # A run of plain text — operators, whitespace, anything the grammar
6
+ # doesn't promote to a typed node.
7
+ class Text < Node
8
+ attr_accessor :content
9
+
10
+ def initialize(content:)
11
+ @content = content
12
+ end
13
+
14
+ def value_attributes
15
+ { content: content }
16
+ end
17
+
18
+ def to_s
19
+ "Text(#{content.inspect})"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ # Semantic model. Every parse produces a tree of Model instances, and
5
+ # every formatter consumes the same tree. Formatters visit via
6
+ # double-dispatch (`node.accept(formatter)` -> `formatter.visit_<class>`),
7
+ # keeping both sides open for extension.
8
+ module Model
9
+ autoload :Atom, "asciichem/model/atom"
10
+ autoload :Bond, "asciichem/model/bond"
11
+ autoload :ElectronConfiguration, "asciichem/model/electron_configuration"
12
+ autoload :EmbeddedMath, "asciichem/model/embedded_math"
13
+ autoload :Formula, "asciichem/model/formula"
14
+ autoload :Group, "asciichem/model/group"
15
+ autoload :Identifier, "asciichem/model/identifier"
16
+ autoload :Molecule, "asciichem/model/molecule"
17
+ autoload :Name, "asciichem/model/name"
18
+ autoload :Node, "asciichem/model/node"
19
+ autoload :Reaction, "asciichem/model/reaction"
20
+ autoload :ReactionCascade, "asciichem/model/reaction_cascade"
21
+ autoload :Text, "asciichem/model/text"
22
+ end
23
+ end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "chemicalml"
4
+
5
+ module AsciiChem
6
+ module ModelAdapter
7
+ # Chemicalml::Model -> AsciiChem::Model. Walks the canonical tree
8
+ # and rebuilds an AsciiChem::Model::Formula. Pure transformation;
9
+ # no I/O.
10
+ #
11
+ # Mapping rules:
12
+ #
13
+ # - `Chemicalml::Model::Document` -> `Formula`.
14
+ # - `Chemicalml::Model::Molecule` -> `Molecule`. Atoms become
15
+ # AsciiChem atoms with
16
+ # subscript=count. Bonds
17
+ # re-insert between their
18
+ # endpoint positions.
19
+ # - `Chemicalml::Model::Atom` -> `Atom` (element, isotope,
20
+ # charge, lone pairs,
21
+ # radical electrons).
22
+ # - `Chemicalml::Model::Bond` -> `Bond` (kind enum).
23
+ # - `Chemicalml::Model::Reaction` -> `Reaction`.
24
+ # - `Chemicalml::Model::ReactionList` -> `ReactionCascade`.
25
+ #
26
+ # Round-trip note: the canonical model is richer than AsciiChem's
27
+ # (3D coordinates, metadata, etc.). Those fields are dropped on the
28
+ # way back. AsciiChem-specific constructs (Lewis markers, embedded
29
+ # math) round-trip when they ride in the canonical Atom's
30
+ # lone_pairs / radical_electrons fields.
31
+ class FromCanonical
32
+ def initialize(document)
33
+ @document = document
34
+ end
35
+
36
+ def build
37
+ molecules = @document.molecules.map { |m| molecule_from_canonical(m) }
38
+ reactions = @document.reactions.map { |r| reaction_from_canonical(r) }
39
+ cascades = @document.reaction_lists.map { |l| reaction_list_from_canonical(l) }
40
+ AsciiChem::Model::Formula.new(nodes: molecules + reactions + cascades)
41
+ end
42
+
43
+ private
44
+
45
+ # -- Molecules --------------------------------------------------
46
+
47
+ def molecule_from_canonical(molecule)
48
+ builder = MoleculeRebuilder.new(molecule)
49
+ AsciiChem::Model::Molecule.new(
50
+ nodes: builder.nodes,
51
+ coefficient: molecule.count,
52
+ names: extract_names(molecule.names),
53
+ identifiers: extract_identifiers(molecule.identifiers),
54
+ title: molecule.title,
55
+ formulas: extract_formulas(molecule.formulas),
56
+ properties: extract_properties(molecule.properties),
57
+ labels: extract_labels(molecule.labels)
58
+ )
59
+ end
60
+
61
+ def extract_names(canonical_names)
62
+ return [] if canonical_names.nil? || canonical_names.empty?
63
+
64
+ canonical_names.map do |n|
65
+ AsciiChem::Model::Name.new(
66
+ content: n.content,
67
+ convention: n.convention,
68
+ dict_ref: n.dict_ref
69
+ )
70
+ end
71
+ end
72
+
73
+ def extract_identifiers(canonical_identifiers)
74
+ return [] if canonical_identifiers.nil? || canonical_identifiers.empty?
75
+
76
+ canonical_identifiers.map do |i|
77
+ AsciiChem::Model::Identifier.new(
78
+ value: i.value,
79
+ convention: i.convention,
80
+ dict_ref: i.dict_ref
81
+ )
82
+ end
83
+ end
84
+
85
+ def extract_formulas(canonical_formulas)
86
+ return [] if canonical_formulas.nil? || canonical_formulas.empty?
87
+
88
+ canonical_formulas.map do |f|
89
+ {
90
+ concise: f.concise,
91
+ inline: f.inline,
92
+ formal_charge: f.formal_charge,
93
+ count: f.count,
94
+ title: f.title,
95
+ convention: f.convention,
96
+ dict_ref: f.dict_ref
97
+ }
98
+ end
99
+ end
100
+
101
+ def extract_properties(canonical_properties)
102
+ return [] if canonical_properties.nil? || canonical_properties.empty?
103
+
104
+ canonical_properties.map do |p|
105
+ {
106
+ title: p.title,
107
+ value: extract_scalar_value(p.value),
108
+ dict_ref: p.dict_ref,
109
+ convention: p.convention
110
+ }
111
+ end
112
+ end
113
+
114
+ def extract_scalar_value(value)
115
+ return nil if value.nil?
116
+ return value.value if value.is_a?(Chemicalml::Model::Scalar)
117
+
118
+ value.to_s
119
+ end
120
+
121
+ def extract_labels(canonical_labels)
122
+ return [] if canonical_labels.nil? || canonical_labels.empty?
123
+
124
+ canonical_labels.map do |l|
125
+ {
126
+ value: l.value,
127
+ dict_ref: l.dict_ref,
128
+ convention: l.convention
129
+ }
130
+ end
131
+ end
132
+
133
+ # -- Reactions --------------------------------------------------
134
+
135
+ def reaction_from_canonical(reaction)
136
+ AsciiChem::Model::Reaction.new(
137
+ reactants: reactants_from_canonical(reaction.reactant_list),
138
+ products: products_from_canonical(reaction.product_list),
139
+ arrow: reaction.arrow,
140
+ conditions: conditions_from_canonical(reaction)
141
+ )
142
+ end
143
+
144
+ def reaction_list_from_canonical(list)
145
+ AsciiChem::Model::ReactionCascade.new(
146
+ steps: list.reactions.map { |r| reaction_from_canonical(r) }
147
+ )
148
+ end
149
+
150
+ def reactants_from_canonical(list)
151
+ return [] unless list
152
+
153
+ list.reactants.map { |r| molecule_from_canonical(r.substance.molecule) }
154
+ end
155
+
156
+ def products_from_canonical(list)
157
+ return [] unless list
158
+
159
+ list.products.map { |p| molecule_from_canonical(p.substance.molecule) }
160
+ end
161
+
162
+ def conditions_from_canonical(reaction)
163
+ above = reaction.conditions_above
164
+ below = reaction.conditions_below
165
+ return nil unless above || below
166
+
167
+ AsciiChem::Model::Reaction::Conditions.new(above: above, below: below)
168
+ end
169
+
170
+ # Rebuilds an AsciiChem::Model::Molecule's node list from a
171
+ # canonical molecule. Bonds in the canonical model reference
172
+ # atom IDs; AsciiChem bonds are positional, sitting between two
173
+ # adjacent atoms. The rebuilder builds an ID-to-position map and
174
+ # inserts each bond just before its later endpoint, in
175
+ # descending later-position order so earlier insertions don't
176
+ # shift pending positions.
177
+ class MoleculeRebuilder
178
+ attr_reader :nodes
179
+
180
+ def initialize(molecule)
181
+ @molecule = molecule
182
+ @position_by_atom_id = {}
183
+ molecule.atoms.each_with_index do |atom, idx|
184
+ @position_by_atom_id[atom.id] = idx
185
+ end
186
+ @nodes = build_nodes
187
+ end
188
+
189
+ private
190
+
191
+ def build_nodes
192
+ atoms = @molecule.atoms.map { |a| atom_from_canonical(a) }
193
+ return atoms if @molecule.bonds.empty?
194
+
195
+ insert_bonds(atoms)
196
+ end
197
+
198
+ def atom_from_canonical(atom)
199
+ AsciiChem::Model::Atom.new(
200
+ element: atom.element,
201
+ isotope: atom.isotope,
202
+ subscript: subscript_from_count(atom.count),
203
+ charge: atom.formal_charge,
204
+ lone_pairs: atom.lone_pairs,
205
+ radical_electrons: atom.radical_electrons,
206
+ **extract_coordinates(atom)
207
+ )
208
+ end
209
+
210
+ # Read 2D or 3D coordinates from canonical atom. Prefer 3D
211
+ # (x3/y3/z3) if present; fall back to 2D (x2/y2).
212
+ def extract_coordinates(atom)
213
+ if atom.x3 && atom.y3
214
+ { x2: atom.x3.to_f, y2: atom.y3.to_f,
215
+ z2: atom.z3&.to_f }
216
+ elsif atom.x2 && atom.y2
217
+ { x2: atom.x2.to_f, y2: atom.y2.to_f }
218
+ else
219
+ {}
220
+ end
221
+ end
222
+
223
+ def subscript_from_count(count)
224
+ return nil if count.nil? || count.to_s == "1"
225
+
226
+ count.to_s
227
+ end
228
+
229
+ def insert_bonds(atoms)
230
+ result = atoms.dup
231
+ # Insert in descending position order so earlier insertions
232
+ # don't shift the indices of pending ones.
233
+ bonds_with_pos = @molecule.bonds.map { |b| [b, later_position(b)] }
234
+ bonds_with_pos.sort_by { |(_, pos)| -pos }.each do |bond, pos|
235
+ # Ring bonds connect non-adjacent atoms. They're represented
236
+ # by the ring_closures digit carried via aci: extension on
237
+ # the atoms, not as a positional bond in the node list.
238
+ next if ring_bond?(bond)
239
+
240
+ result.insert(pos, AsciiChem::Model::Bond.new(kind: bond.kind))
241
+ end
242
+ result
243
+ end
244
+
245
+ # Position of the later endpoint of the bond — i.e. where the
246
+ # bond marker should sit in the rebuilt linear sequence.
247
+ def later_position(bond)
248
+ positions = bond.atom_refs.map { |id| @position_by_atom_id[id] }.compact
249
+ return 0 if positions.empty?
250
+
251
+ positions.max
252
+ end
253
+
254
+ # A ring bond connects atoms that are not positionally
255
+ # adjacent — its endpoints span a gap. Such bonds are
256
+ # represented by ring_closures digits, not positional markers.
257
+ def ring_bond?(bond)
258
+ positions = bond.atom_refs.map { |id| @position_by_atom_id[id] }.compact
259
+ return false if positions.length < 2
260
+
261
+ positions.max - positions.min > 1
262
+ end
263
+
264
+ def first_position(bond)
265
+ positions = bond.atom_refs.map { |id| @position_by_atom_id[id] }.compact
266
+ positions.min || 0
267
+ end
268
+ end
269
+ private_constant :MoleculeRebuilder
270
+ end
271
+ end
272
+ end