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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6f9929cd67fc49ac37896dce9ebc173306615fb08b704fd96ddfa14c09189afd
|
|
4
|
+
data.tar.gz: dc7bc12b0914610807bdd168f18b2e4b1e281519bbd5698a5c7e08d660b49d83
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 13f7ec97ce27c022cfac8e9df9944ae9860c949d8bb8569995b113f846a4fb5a0052bf0d6def2374fcaa11b140187d5c38c73e8afb66b0ca77ff3f558f1a994a
|
|
7
|
+
data.tar.gz: 161b22b36e6eaeb599b7bbae7666c45574d2a17b4abf5127cdbc4fe9e3c8a558b8e8de00d0149c45a58bf4c062368fb79526ef70cdd9f10ad50a6d6cdd169982
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
ruby: ["3.3", "3.4"]
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: ruby/setup-ruby@v1
|
|
18
|
+
with:
|
|
19
|
+
ruby-version: ${{ matrix.ruby }}
|
|
20
|
+
bundler-cache: true
|
|
21
|
+
- run: bundle exec rspec
|
data/.gitignore
ADDED
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require:
|
|
2
|
+
- rubocop
|
|
3
|
+
|
|
4
|
+
AllCops:
|
|
5
|
+
NewCops: enable
|
|
6
|
+
TargetRubyVersion: 3.3
|
|
7
|
+
Exclude:
|
|
8
|
+
- "pkg/**/*"
|
|
9
|
+
- "vendor/**/*"
|
|
10
|
+
- "coverage/**/*"
|
|
11
|
+
|
|
12
|
+
# Parser grammars benefit from short methods; parslet DSL reads top-to-bottom.
|
|
13
|
+
Metrics/MethodLength:
|
|
14
|
+
Max: 40
|
|
15
|
+
Exclude:
|
|
16
|
+
- "lib/asciichem/parser.rb"
|
|
17
|
+
|
|
18
|
+
Metrics/ClassLength:
|
|
19
|
+
Max: 200
|
|
20
|
+
Exclude:
|
|
21
|
+
- "lib/asciichem/parser.rb"
|
|
22
|
+
- "lib/asciichem/transform.rb"
|
|
23
|
+
|
|
24
|
+
# Specs read better with non-trivial blocks.
|
|
25
|
+
Metrics/BlockLength:
|
|
26
|
+
Exclude:
|
|
27
|
+
- "spec/**/*"
|
|
28
|
+
- "*.gemspec"
|
|
29
|
+
|
|
30
|
+
# Visitor dispatch methods are short by design.
|
|
31
|
+
Style/Documentation:
|
|
32
|
+
Exclude:
|
|
33
|
+
- "lib/asciichem/formatter/*.rb"
|
|
34
|
+
- "lib/asciichem/model/*.rb"
|
|
35
|
+
|
|
36
|
+
# CML extension modules are cohesive by design — splitting them
|
|
37
|
+
# would harm readability. They each house one side-channel (atom
|
|
38
|
+
# attributes, top-level elements, group preservation) plus the
|
|
39
|
+
# four-method collect/inject/extract/restore cycle.
|
|
40
|
+
Metrics/ModuleLength:
|
|
41
|
+
Exclude:
|
|
42
|
+
- "lib/asciichem/cml/extensions.rb"
|
|
43
|
+
- "lib/asciichem/cml/group_extensions.rb"
|
|
44
|
+
|
|
45
|
+
# Short names for chemistry primitives (`el` for element, `iso`
|
|
46
|
+
# for isotope) match the grammar's term set.
|
|
47
|
+
Naming/MethodParameterName:
|
|
48
|
+
Exclude:
|
|
49
|
+
- "lib/asciichem/cml/group_extensions.rb"
|
|
50
|
+
- "lib/asciichem/transform.rb"
|
data/ARCHITECTURE.adoc
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
= AsciiChem Architecture
|
|
2
|
+
|
|
3
|
+
AsciiChem is an ASCII syntax for representing chemistry: atoms,
|
|
4
|
+
molecules, bonds, reactions, electron configurations, and embedded
|
|
5
|
+
mathematics. It parses text into a semantic model and renders the model
|
|
6
|
+
to multiple output formats.
|
|
7
|
+
|
|
8
|
+
This document is the durable reference for contributors. The
|
|
9
|
+
https://www.asciichem.org[site] is the user-facing spec; this file is
|
|
10
|
+
the implementation guide.
|
|
11
|
+
|
|
12
|
+
== Layers
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
┌─────────────┐
|
|
16
|
+
text ───► │ Grammar │ parslet parser, flat-hash tree
|
|
17
|
+
└──────┬──────┘
|
|
18
|
+
▼
|
|
19
|
+
┌─────────────┐
|
|
20
|
+
│ Transform │ tree → model, with builder helpers
|
|
21
|
+
└──────┬──────┘
|
|
22
|
+
▼
|
|
23
|
+
┌─────────────┐
|
|
24
|
+
│ Model │ Formula, Atom, Molecule, Group, Bond,
|
|
25
|
+
│ │ Reaction, ElectronConfiguration,
|
|
26
|
+
│ │ EmbeddedMath, Text
|
|
27
|
+
└──────┬──────┘
|
|
28
|
+
▼
|
|
29
|
+
┌──────────────────────────────────────────────┐
|
|
30
|
+
│ Formatter (visitor) │
|
|
31
|
+
│ ┌─────────┐ ┌──────┐ ┌───────┐ ┌─────┐ ┌───┐ │
|
|
32
|
+
│ │ Mathml │ │ Text │ │ HTML │ │ LaTeX │ … │ │
|
|
33
|
+
│ └─────────┘ └──────┘ └───────┘ └─────┘ └───┘ │
|
|
34
|
+
└──────────────────────────────────────────────┘
|
|
35
|
+
▼
|
|
36
|
+
output (string)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The model is the contract. Every formatter consumes the same tree; the
|
|
40
|
+
parser and transform never reach past it.
|
|
41
|
+
|
|
42
|
+
== The semantic fix
|
|
43
|
+
|
|
44
|
+
The defining difference from AsciiMath is captured at the parser/model
|
|
45
|
+
boundary, not in any one formatter.
|
|
46
|
+
|
|
47
|
+
[cols="1,2,2"]
|
|
48
|
+
|===
|
|
49
|
+
| Input | AsciiMath | AsciiChem
|
|
50
|
+
|
|
51
|
+
| `^14C`
|
|
52
|
+
| `<msup><mi></mi><mn>14</mn></msup><mi>C</mi>` (empty base + sibling)
|
|
53
|
+
| `Atom(element: "C", isotope: "14")` → `<msup><mi>C</mi><mn>14</mn></msup>`
|
|
54
|
+
|
|
55
|
+
| `Ca^2+`
|
|
56
|
+
| `<msup><mi>Ca</mi><mn>2+</mn></msup>` (charge as undifferentiated super)
|
|
57
|
+
| `Atom(element: "Ca", charge: "2+")` → `<msup><mi>Ca</mi><mrow><mn>2</mn><mo>+</mo></mrow></msup>`
|
|
58
|
+
|
|
59
|
+
| `1s^2 2s^2`
|
|
60
|
+
| `msub/msup` soup, no orbital concept
|
|
61
|
+
| `ElectronConfiguration(orbitals: [["1s","2"], ["2s","2"]])`
|
|
62
|
+
|===
|
|
63
|
+
|
|
64
|
+
The grammar enforces binding structurally. `prefixed_atom` consumes
|
|
65
|
+
`^digits element` as a single unit; a bare `^digits` with no following
|
|
66
|
+
element is rejected. The model carries the binding in a typed field.
|
|
67
|
+
|
|
68
|
+
== Model classes
|
|
69
|
+
|
|
70
|
+
[cols="1,3,3,3"]
|
|
71
|
+
|===
|
|
72
|
+
| Class | Role | Key fields | Invariants
|
|
73
|
+
|
|
74
|
+
| `Formula`
|
|
75
|
+
| Top-level container, root of every parse.
|
|
76
|
+
| `nodes: Array<Node>`
|
|
77
|
+
| `nodes` may be empty (e.g. parse error fallback).
|
|
78
|
+
|
|
79
|
+
| `Atom`
|
|
80
|
+
| A chemical atom.
|
|
81
|
+
| `element: String`, `isotope: String?`, `subscript: String?`, `superscript: String?`, `charge: String?`, `oxidation_state: String?`
|
|
82
|
+
| Exactly one of `{superscript, charge, oxidation_state}` is set; they are mutually exclusive views of the suffix super position.
|
|
83
|
+
|
|
84
|
+
| `Molecule`
|
|
85
|
+
| Ordered list of atoms/groups with optional stoichiometric coefficient.
|
|
86
|
+
| `nodes: Array<Atom \| Group \| Bond \| ...>`, `coefficient: String?`
|
|
87
|
+
| Multiplicity lives on inner atoms/groups, not on the molecule.
|
|
88
|
+
|
|
89
|
+
| `Group`
|
|
90
|
+
| Parenthesised sub-formula with outer multiplicity.
|
|
91
|
+
| `nodes: Array`, `multiplicity: String?`, `bracket: :paren \| :square \| :brace`
|
|
92
|
+
| Round-trip preserves bracket kind.
|
|
93
|
+
|
|
94
|
+
| `Bond`
|
|
95
|
+
| A bond between two adjacent nodes in a chain.
|
|
96
|
+
| `kind: :single \| :Double \| :triple \| :wedge \| :hash \| :dative \| :wavy`
|
|
97
|
+
| Endpoints are positional in the parent sequence.
|
|
98
|
+
|
|
99
|
+
| `Reaction`
|
|
100
|
+
| Reactants + arrow + products, with optional conditions.
|
|
101
|
+
| `reactants: Array<Molecule>`, `products: Array<Molecule>`, `arrow: :forward \| :reverse \| :equilibrium \| :resonance`, `conditions: Conditions?`
|
|
102
|
+
| Arrow kinds are exhaustive; adding a new kind is a single map entry.
|
|
103
|
+
|
|
104
|
+
| `ElectronConfiguration`
|
|
105
|
+
| Orbital occupancy plus optional term symbol.
|
|
106
|
+
| `orbitals: Array<[orbital, occupancy]>`, `term_symbol: TermSymbol?`
|
|
107
|
+
| Orbital labels match `^[0-9]+[spdfgh]$`.
|
|
108
|
+
|
|
109
|
+
| `EmbeddedMath`
|
|
110
|
+
| A Plurimath formula embedded in chemistry.
|
|
111
|
+
| `formula: Plurimath::Math::Formula`, `source: String`
|
|
112
|
+
| `source` is preserved verbatim for round-trip.
|
|
113
|
+
|
|
114
|
+
| `Text`
|
|
115
|
+
| Catch-all for runs not promoted to a typed node.
|
|
116
|
+
| `content: String`
|
|
117
|
+
| The grammar is total; unparseable fragments land here rather than failing.
|
|
118
|
+
|===
|
|
119
|
+
|
|
120
|
+
Every model class:
|
|
121
|
+
|
|
122
|
+
* Inherits from `AsciiChem::Model::Node`.
|
|
123
|
+
* Declares `value_attributes` returning the hash of fields that
|
|
124
|
+
participate in equality (avoids reflective instance variable access).
|
|
125
|
+
* Implements `accept(visitor)` via the base class, which dispatches to
|
|
126
|
+
`visitor.visit_<snake_name>(self)`.
|
|
127
|
+
|
|
128
|
+
== Extension points (OCP)
|
|
129
|
+
|
|
130
|
+
The system is open for extension in three dimensions; each dimension
|
|
131
|
+
requires edits in exactly one place.
|
|
132
|
+
|
|
133
|
+
=== Adding a new construct
|
|
134
|
+
|
|
135
|
+
1. Add a model class under `lib/asciichem/model/`, inheriting from
|
|
136
|
+
`Node`. Declare `value_attributes`.
|
|
137
|
+
2. Add an autoload entry in `lib/asciichem/model.rb`.
|
|
138
|
+
3. Add grammar productions in `lib/asciichem/grammar.rb`.
|
|
139
|
+
4. Add a transform rule in `lib/asciichem/transform.rb`.
|
|
140
|
+
5. Add `visit_<class>` methods to each formatter (the base class raises
|
|
141
|
+
`NotImplementedError` so gaps surface immediately).
|
|
142
|
+
|
|
143
|
+
No existing model/parser/formatter code is modified.
|
|
144
|
+
|
|
145
|
+
=== Adding a new output format
|
|
146
|
+
|
|
147
|
+
1. Create `lib/asciichem/formatter/<name>.rb` with a class inheriting
|
|
148
|
+
from `AsciiChem::Formatter::Base`.
|
|
149
|
+
2. Implement one `visit_<class>` method per model class.
|
|
150
|
+
3. Add `autoload :<ClassCamel>, "asciichem/formatter/<name>"` to
|
|
151
|
+
`lib/asciichem/formatter.rb`.
|
|
152
|
+
4. Optionally add `to_<name>` to `Model::Node`.
|
|
153
|
+
|
|
154
|
+
The format is reachable via `AsciiChem::Formatter[<name>]` — no switch
|
|
155
|
+
statement, no registry of instances. Adding the autoload entry is the
|
|
156
|
+
only required change to an existing file.
|
|
157
|
+
|
|
158
|
+
=== Adding a new arrow / bond / bracket kind
|
|
159
|
+
|
|
160
|
+
Each enum (`Reaction::ARROWS`, `Bond::KINDS`, `Group#bracket`) is a
|
|
161
|
+
constant hash. Adding a kind is a single entry; the formatter reads from
|
|
162
|
+
the hash, not from a switch statement.
|
|
163
|
+
|
|
164
|
+
== Plurimath interop
|
|
165
|
+
|
|
166
|
+
Mathematics embedded in chemistry is delegated to
|
|
167
|
+
{Plurimath}[https://github.com/plurimath/plurimath].
|
|
168
|
+
|
|
169
|
+
* `EmbeddedMath` wraps a `Plurimath::Math::Formula`.
|
|
170
|
+
* The grammar captures backtick-delimited runs as raw text and passes
|
|
171
|
+
them to `Plurimath::Asciimath.new(source).to_formula` at transform
|
|
172
|
+
time.
|
|
173
|
+
* The MathML formatter calls the wrapped formula's `to_mathml` and
|
|
174
|
+
strips the outer `<math>` so the fragment slots into AsciiChem's
|
|
175
|
+
`<mrow>`.
|
|
176
|
+
* The Text formatter emits the original backtick-delimited source
|
|
177
|
+
verbatim.
|
|
178
|
+
|
|
179
|
+
AsciiChem does not reinvent math typography. Anywhere math appears in
|
|
180
|
+
chemistry (rate laws, equilibrium constants, Arrhenius expressions),
|
|
181
|
+
Plurimath handles it.
|
|
182
|
+
|
|
183
|
+
== Glossary
|
|
184
|
+
|
|
185
|
+
[cols="1,3"]
|
|
186
|
+
|===
|
|
187
|
+
| Term | Definition
|
|
188
|
+
|
|
189
|
+
| Atom
|
|
190
|
+
| A chemical atom with element symbol and optional isotope, charge,
|
|
191
|
+
oxidation state, or subscript multiplicity.
|
|
192
|
+
|
|
193
|
+
| Molecule
|
|
194
|
+
| An ordered sequence of atoms/groups plus an optional leading
|
|
195
|
+
stoichiometric coefficient.
|
|
196
|
+
|
|
197
|
+
| Group
|
|
198
|
+
| A parenthesised sub-formula with an outer multiplicity. Brackets
|
|
199
|
+
may be `()`, `[]`, or `{}`.
|
|
200
|
+
|
|
201
|
+
| Bond
|
|
202
|
+
| A connection between two adjacent nodes in a structural chain.
|
|
203
|
+
Single, double, triple, wedge, hash, dative, or wavy.
|
|
204
|
+
|
|
205
|
+
| Reaction
|
|
206
|
+
| A transformation with reactants, an arrow, and products, plus
|
|
207
|
+
optional conditions above/below the arrow.
|
|
208
|
+
|
|
209
|
+
| ElectronConfiguration
|
|
210
|
+
| A list of orbital/occupancy pairs plus an optional term symbol.
|
|
211
|
+
|
|
212
|
+
| EmbeddedMath
|
|
213
|
+
| A Plurimath formula embedded in chemistry text.
|
|
214
|
+
|
|
215
|
+
| Prefix isotope
|
|
216
|
+
| A mass number written before the element symbol, e.g. `^14C`. The
|
|
217
|
+
binding to the atom is the headline semantic fix over AsciiMath.
|
|
218
|
+
|
|
219
|
+
| Stoichiometric coefficient
|
|
220
|
+
| Leading digits on a molecule, e.g. the `2` in `2H_2O`.
|
|
221
|
+
|
|
222
|
+
| Canonical form
|
|
223
|
+
| The AsciiChem text that the Text formatter emits. Round-trip
|
|
224
|
+
conformance: `parse(s).to_text == s` for any canonical `s`.
|
|
225
|
+
|===
|
|
226
|
+
|
|
227
|
+
== Constraints honoured
|
|
228
|
+
|
|
229
|
+
* No `require_relative` for internal code; `autoload` everywhere,
|
|
230
|
+
declared in the immediate parent namespace's file.
|
|
231
|
+
* No `instance_variable_set` / `instance_variable_get`. Equality uses
|
|
232
|
+
declared `value_attributes`.
|
|
233
|
+
* No `respond_to?` type checks. `is_a?` only.
|
|
234
|
+
* No `send` to private methods. The visitor dispatch uses
|
|
235
|
+
`public_send` against public `visit_*` methods.
|
|
236
|
+
* No `double()` in specs. Real instances or `Struct`s.
|
|
237
|
+
* No hand-rolled model serialisation. The model is in-memory only;
|
|
238
|
+
rendering to MathML / HTML / etc. is one-way output via Nokogiri or
|
|
239
|
+
string building, not `(de)serialisation`.
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to AsciiChem are documented here.
|
|
4
|
+
This project follows [Semantic Versioning](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- Initial gem scaffold: autoload tree, version, errors.
|
|
10
|
+
- Core model: `Formula`, `Atom`, `Molecule`, `Group`, `Bond`, `Reaction`,
|
|
11
|
+
`ElectronConfiguration`, `EmbeddedMath`, `Text`.
|
|
12
|
+
- Parslet parser and transform for v1 constructs.
|
|
13
|
+
- Formatters: MathML and Text (round-trip).
|
|
14
|
+
- Thor-based CLI: `convert`, `roundtrip`, `version`.
|
|
15
|
+
- Comprehensive RSpec suite with round-trip conformance.
|
|
16
|
+
|
|
17
|
+
[Unreleased]: https://github.com/asciichem/asciichem-ruby/commits/main
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## What this repo is
|
|
6
|
+
|
|
7
|
+
The reference implementation of **AsciiChem** — an ASCII syntax for
|
|
8
|
+
chemistry (atoms, molecules, bonds, reactions, electron
|
|
9
|
+
configurations, embedded math). Parses text into a semantic model and
|
|
10
|
+
renders the model to MathML, Text, HTML, LaTeX, SVG, and CML.
|
|
11
|
+
|
|
12
|
+
Sister repo: `../asciichem.github.io` is the specification site. When
|
|
13
|
+
a spec page describes syntax, the gem must implement it; when the gem
|
|
14
|
+
adds a feature, a spec page must document it. The site's
|
|
15
|
+
`TODO.impl/` directory tracks cross-repo work; this repo's
|
|
16
|
+
`TODO.gem/` directory tracks gem-side work.
|
|
17
|
+
|
|
18
|
+
## Why this exists (the semantic fix)
|
|
19
|
+
|
|
20
|
+
AsciiMath is insufficient for chemistry: prefix superscripts/subscripts
|
|
21
|
+
have no atom to bind to (AsciiMath forces a phantom `{}` carrier),
|
|
22
|
+
and there's no native syntax for stoichiometric coefficients, reaction
|
|
23
|
+
arrows, conditions, spectator ions, electron configurations, term
|
|
24
|
+
symbols, or bonds. AsciiChem closes these gaps. The defining fix
|
|
25
|
+
lives at the parser/model boundary: `^14C` parses as
|
|
26
|
+
`Atom(element: "C", isotope: "14")` — the isotope binds to the atom,
|
|
27
|
+
not to a phantom empty element.
|
|
28
|
+
|
|
29
|
+
## Commands
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bundle install # install dependencies (chemicalml 0.1.0 from rubygems)
|
|
33
|
+
bundle exec rake spec # run the full spec suite
|
|
34
|
+
bundle exec rspec spec/asciichem/cml/ # run specs in a directory
|
|
35
|
+
bundle exec rspec -e "round-trips" # run specs matching a name
|
|
36
|
+
bundle exec rubocop # lint
|
|
37
|
+
bundle exec rake build # build the gem into pkg/
|
|
38
|
+
bundle exec ./exe/asciichem convert -i "H_2O" -t mathml # smoke-test the CLI
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The default `rake` task is `:spec`. RSpec is configured for
|
|
42
|
+
randomised order, profile of the 10 slowest examples, and
|
|
43
|
+
`spec/examples.txt` persistence. `config.warnings = true` — Ruby
|
|
44
|
+
warnings fail the suite.
|
|
45
|
+
|
|
46
|
+
## High-level architecture
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
text ─► Grammar (parslet) ─► Transform ─► AsciiChem::Model ─► Formatter (visitor)
|
|
50
|
+
│
|
|
51
|
+
▼
|
|
52
|
+
ModelAdapter (bidirectional)
|
|
53
|
+
│
|
|
54
|
+
▼
|
|
55
|
+
Chemicalml::Model (canonical, in chemicalml gem)
|
|
56
|
+
│
|
|
57
|
+
▼
|
|
58
|
+
Chemicalml::Cml (CML wire, in chemicalml gem)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Five layers, MECE
|
|
62
|
+
|
|
63
|
+
1. **Grammar** (`lib/asciichem/grammar.rb`) — a Parslet parser.
|
|
64
|
+
Leaf rules return strings; `.as(:name)` is applied at the
|
|
65
|
+
combination site so the parse tree is a flat hash of named strings
|
|
66
|
+
wherever practical. The prefix-isotope binding (`^14C`) is enforced
|
|
67
|
+
*structurally*: `prefixed_atom` consumes `^digits element` as a
|
|
68
|
+
unit; a bare `^digits` with no following element is rejected.
|
|
69
|
+
|
|
70
|
+
2. **Transform** (`lib/asciichem/transform.rb`) — a Parslet transform
|
|
71
|
+
that converts the flat parse tree into a tree of `Model::*`
|
|
72
|
+
instances. Minimal logic; the grammar encodes semantics, not the
|
|
73
|
+
transform. Builder classes (`AtomBuilder`, `ReactionBuilder`,
|
|
74
|
+
`CascadeBuilder`) keep constructor-call mapping readable.
|
|
75
|
+
`TextNormaliser.strip_quotes` removes the surrounding `"..."` from
|
|
76
|
+
quoted-text matches so the model never carries the delimiters; the
|
|
77
|
+
Text formatter re-adds them on output.
|
|
78
|
+
|
|
79
|
+
3. **Model** (`lib/asciichem/model/`) — the semantic hub. Every
|
|
80
|
+
formatter consumes the same tree. Classes: `Formula`, `Atom`,
|
|
81
|
+
`Molecule`, `Group`, `Bond`, `Reaction`, `ReactionCascade`,
|
|
82
|
+
`ElectronConfiguration`, `EmbeddedMath`, `Text`. All inherit from
|
|
83
|
+
`Model::Node`, which provides structural equality (via
|
|
84
|
+
`value_attributes`), visitor dispatch (`accept`), and the
|
|
85
|
+
`to_<format>` shortcuts.
|
|
86
|
+
|
|
87
|
+
**Text syntax.** Free-form text uses `"..."` delimiters (matching
|
|
88
|
+
AsciiMath). Unquoted prose that isn't chemistry raises
|
|
89
|
+
`ParseError`; this is intentional — silently swallowing arbitrary
|
|
90
|
+
content as Text was a bug magnet. Embedded math uses backticks
|
|
91
|
+
(`` `...` ``) and is handled by a separate grammar rule.
|
|
92
|
+
|
|
93
|
+
4. **Formatter** (`lib/asciichem/formatter/`) — one visitor per output
|
|
94
|
+
format. Each subclass of `Formatter::Base` implements one
|
|
95
|
+
`visit_<class>` method per `Model::*` class. Missing visits raise
|
|
96
|
+
`NotImplementedError` so gaps surface at first use, not silently.
|
|
97
|
+
Reachable via `Formatter[:name]` (triggers autoload).
|
|
98
|
+
|
|
99
|
+
5. **ModelAdapter** (`lib/asciichem/model_adapter/`) — bridges
|
|
100
|
+
`AsciiChem::Model` and the canonical `Chemicalml::Model` (which
|
|
101
|
+
lives in the chemicalml gem). The adapter is bidirectional and
|
|
102
|
+
pure; no I/O. Composed with `Chemicalml::Cml::Translator` it
|
|
103
|
+
forms the AsciiChem ↔ CML pipeline.
|
|
104
|
+
|
|
105
|
+
### Linter (opt-in)
|
|
106
|
+
|
|
107
|
+
`lib/asciichem/linter/` is a separate pass that walks the model and
|
|
108
|
+
reports chemistry errors. Checks self-register via
|
|
109
|
+
`Linter::Base.register(:name)` inside their class body; the
|
|
110
|
+
`Linter` module eagerly triggers every autoload at load time so
|
|
111
|
+
registration runs before any API query.
|
|
112
|
+
|
|
113
|
+
Built-in checks:
|
|
114
|
+
|
|
115
|
+
- `BalanceCheck` — stoichiometric balance of reactions.
|
|
116
|
+
- `BracketBalanceCheck` — group bracket consistency.
|
|
117
|
+
- `ElementValidationCheck` — warns on element symbols not in the
|
|
118
|
+
periodic table (catches typos like `Hx`, `Cy`).
|
|
119
|
+
- `IsotopeSanityCheck` — isotope mass ≥ atomic number.
|
|
120
|
+
- `UnclosedRingCheck` — errors on ring closure digits with no
|
|
121
|
+
matching partner (`C1-C-C` is unmatched).
|
|
122
|
+
- `ValenceCheck` — atom bond order + charge ≤ max valence.
|
|
123
|
+
|
|
124
|
+
All element data (symbol, atomic number, max valence) is sourced
|
|
125
|
+
from `AsciiChem::PeriodicTable` — the single source of truth.
|
|
126
|
+
Adding a new field (covalent radius, common oxidation states) is
|
|
127
|
+
one column on `Element` plus populating it; the linter picks it up
|
|
128
|
+
automatically.
|
|
129
|
+
|
|
130
|
+
### Ring closures (SMILES-style)
|
|
131
|
+
|
|
132
|
+
Atoms can carry a digit suffix that opens or closes a ring:
|
|
133
|
+
`C1-C-C-C-C-C1` is cyclohexane. The `Atom#ring_closures` field
|
|
134
|
+
holds the digit string. `AsciiChem::RingBonds` is the single source
|
|
135
|
+
of truth for the "find ring bond pairs" algorithm — Layout,
|
|
136
|
+
ModelAdapter, and the UnclosedRingCheck linter all consume it.
|
|
137
|
+
Multiple closures on one atom (`C12-C-C1-C2`) and fused rings are
|
|
138
|
+
supported.
|
|
139
|
+
|
|
140
|
+
### Layout (2D structural SVG)
|
|
141
|
+
|
|
142
|
+
`lib/asciichem/layout.rb` converts a `Model::Molecule` into an elkrb
|
|
143
|
+
graph, runs the layout algorithm, and returns positioned atoms + bonds.
|
|
144
|
+
The `Formatter::StructuralSvg` visitor consumes the result. For
|
|
145
|
+
molecules without bonds it falls back to the linear SVG formatter.
|
|
146
|
+
|
|
147
|
+
Three MECE concerns inside Layout:
|
|
148
|
+
|
|
149
|
+
- `MoleculeWalker` — walks the AsciiChem tree, assigns stable IDs,
|
|
150
|
+
produces a neutral atom+bond list. Pure; no elkrb dependency.
|
|
151
|
+
- `GraphBuilder` — converts the walker's neutral list into an
|
|
152
|
+
elkrb `Graph::Graph` with proper nodes and edges.
|
|
153
|
+
- `ResultExtractor` — maps elkrb's laid-out positions back onto the
|
|
154
|
+
walker's neutral list, producing a `Layout::Result`.
|
|
155
|
+
|
|
156
|
+
Default algorithm is `layered` (Sugiyama-style hierarchical) for
|
|
157
|
+
deterministic output across runs — essential for visual regression.
|
|
158
|
+
`force` is available but may produce different output between runs.
|
|
159
|
+
|
|
160
|
+
The `StructuralSvg` formatter uses a registry of bond-kind strategies
|
|
161
|
+
(Procs). Adding a new bond style is one Proc + one registry entry;
|
|
162
|
+
no edits to existing renderers.
|
|
163
|
+
|
|
164
|
+
## Extension points (OCP)
|
|
165
|
+
|
|
166
|
+
The system is open for extension in three dimensions; each dimension
|
|
167
|
+
requires edits in exactly one place.
|
|
168
|
+
|
|
169
|
+
- **New construct** — add a model class under `lib/asciichem/model/`,
|
|
170
|
+
autoload it from `lib/asciichem/model.rb`, add grammar productions
|
|
171
|
+
to `grammar.rb`, a transform rule in `transform.rb`, and a
|
|
172
|
+
`visit_<class>` method on each formatter.
|
|
173
|
+
- **New output format** — create `lib/asciichem/formatter/<name>.rb`
|
|
174
|
+
subclassing `Formatter::Base`, implement one `visit_<class>` per
|
|
175
|
+
model class, and add the autoload to `lib/asciichem/formatter.rb`.
|
|
176
|
+
Optionally add `to_<name>` to `Model::Node`. The `Formatter[:name]`
|
|
177
|
+
lookup camelises snake_case names, so `:structural_svg` resolves
|
|
178
|
+
to `StructuralSvg`.
|
|
179
|
+
- **New arrow / bond / bracket kind** — each enum
|
|
180
|
+
(`Reaction::ARROWS`, `Bond::KINDS`, `Group#bracket`) is a constant
|
|
181
|
+
hash. Adding a kind is a single entry; formatters read from the hash.
|
|
182
|
+
For SVG rendering, also add a strategy Proc to
|
|
183
|
+
`StructuralSvg::BondRenderer::RENDERERS`.
|
|
184
|
+
- **New linter check** — subclass `Linter::Base`, call `register(:name)`
|
|
185
|
+
in the class body, implement `run(formula)`. The check self-registers
|
|
186
|
+
at file-load time; the `Linter` module eagerly triggers every
|
|
187
|
+
autoload so registration runs before any API query.
|
|
188
|
+
- **New model class** — add the class under `lib/asciichem/model/`,
|
|
189
|
+
autoload from `lib/asciichem/model.rb`. Override `diagnostic_label`
|
|
190
|
+
if the default (snake-case → space-separated capitalized words)
|
|
191
|
+
isn't suitable. No edits to `Linter::Diagnostic` — it reads
|
|
192
|
+
`node.diagnostic_label` polymorphically.
|
|
193
|
+
|
|
194
|
+
## CML and the canonical model
|
|
195
|
+
|
|
196
|
+
`AsciiChem::Cml` provides bidirectional CML XML conversion via the
|
|
197
|
+
chemicalml gem. The pipeline is:
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
AsciiChem::Model ↔ AsciiChem::ModelAdapter ↔ Chemicalml::Model
|
|
201
|
+
↑
|
|
202
|
+
│
|
|
203
|
+
Chemicalml::Cml::Translator
|
|
204
|
+
│
|
|
205
|
+
▼
|
|
206
|
+
Chemicalml::Cml::* (wire)
|
|
207
|
+
│
|
|
208
|
+
▼
|
|
209
|
+
XML
|
|
210
|
+
│
|
|
211
|
+
AsciiChem::Cml::Extensions
|
|
212
|
+
(aci: namespace side-channel)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
- `formula.to_cml` delegates to `AsciiChem::Cml.from_asciichem`.
|
|
216
|
+
- `AsciiChem::Cml.parse(xml)` delegates to
|
|
217
|
+
`AsciiChem::Cml::Translator.to_asciichem`.
|
|
218
|
+
- `chemicalml` requires `Chemicalml::Cml::Schema3.ensure_registered!`
|
|
219
|
+
before first wire-class use. `Cml::Translator` calls it lazily.
|
|
220
|
+
|
|
221
|
+
### aci: extension namespace
|
|
222
|
+
|
|
223
|
+
CML's standard wire format doesn't cover oxidation state, Lewis
|
|
224
|
+
markers, electron configuration, embedded math, free-form text, or
|
|
225
|
+
group structure. AsciiChem carries these through XML round-trip via
|
|
226
|
+
an `aci:` (AsciiChem extension) namespace on the CML root. Three
|
|
227
|
+
channels, one per scope:
|
|
228
|
+
|
|
229
|
+
- **Atom attributes** (per-atom fields): `aci:<wire_name>` on
|
|
230
|
+
`<atom>` elements. Registry: `AsciiChem::Cml::Extensions::FIELDS`
|
|
231
|
+
(covers oxidation_state, lone_pairs, radical_electrons).
|
|
232
|
+
- **Top-level elements** (standalone constructs):
|
|
233
|
+
`<aci:<element_name> position="N">...</aci:...>` children of
|
|
234
|
+
`<cml>`. Registry: `Extensions::TOP_LEVEL_HANDLERS` (covers
|
|
235
|
+
ElectronConfiguration, EmbeddedMath, Text). Position preserves
|
|
236
|
+
node ordering in the formula.
|
|
237
|
+
- **Molecule-level elements** (structural concepts):
|
|
238
|
+
`<aci:group multiplicity="N" bracket="X" atomRefs="..."/>` children
|
|
239
|
+
of `<molecule>`. Managed by `AsciiChem::Cml::GroupExtensions`
|
|
240
|
+
(preserves AsciiChem Group nodes through the canonical-model
|
|
241
|
+
flattening). Bracket kinds mapped via frozen
|
|
242
|
+
`BRACKET_TO_WIRE` hash.
|
|
243
|
+
|
|
244
|
+
Adding a new extension is one entry in the appropriate frozen
|
|
245
|
+
registry. No other code changes. The `xmlns:aci` declaration appears
|
|
246
|
+
only when at least one extension is present; plain atoms/molecules
|
|
247
|
+
produce clean CML.
|
|
248
|
+
|
|
249
|
+
The `ModelAdapter::ToCanonical::MoleculeWalker` records group
|
|
250
|
+
membership during the canonical walk via a `GroupRecord` struct,
|
|
251
|
+
exposed through `Translation#groups`. `IdRegistry::PREFIXES`
|
|
252
|
+
includes `:group => "g"` so group IDs (`g1`, `g2`, ...) don't
|
|
253
|
+
collide with atom/bond/molecule IDs.
|
|
254
|
+
|
|
255
|
+
**Known limitations** (each guarded by a spec):
|
|
256
|
+
- 3D coordinates, spectroscopy, polymer notation — out of scope for v0.2.
|
|
257
|
+
|
|
258
|
+
See `TODO.gem/03-canonical-model-gaps.md` (all five canonical-model
|
|
259
|
+
gaps now closed) and `TODO.gem/09-group-preservation.md` (Group
|
|
260
|
+
preservation design).
|
|
261
|
+
|
|
262
|
+
## Critical constraints (project-specific)
|
|
263
|
+
|
|
264
|
+
The global `~/.claude/CLAUDE.md` is authoritative. Highlights that
|
|
265
|
+
apply most directly here:
|
|
266
|
+
|
|
267
|
+
- **Never use `require_relative`** for internal library code, and
|
|
268
|
+
never use path-based `require` for files inside `lib/asciichem/`.
|
|
269
|
+
Use Ruby `autoload`, declared in the immediate parent namespace's
|
|
270
|
+
file (e.g. `lib/asciichem/model.rb` autoloads `Model::Atom`).
|
|
271
|
+
- **Never hand-roll serialization.** The model is in-memory only.
|
|
272
|
+
CML wire (de)serialization goes through `lutaml-model` in the
|
|
273
|
+
chemicalml gem. AsciiChem itself never writes a `to_h` / `from_h`
|
|
274
|
+
/ `to_xml` / `from_xml` on a model class.
|
|
275
|
+
- **No `double()` in specs.** Use real `Model::*` instances or
|
|
276
|
+
lightweight `Struct`s.
|
|
277
|
+
- **No `send` to private methods, no `instance_variable_set` /
|
|
278
|
+
`instance_variable_get`, no `respond_to?` type checks.** Visitor
|
|
279
|
+
dispatch uses `public_send` against public `visit_*` methods.
|
|
280
|
+
- **Never commit to `main`, never push tags, never push to `main`.**
|
|
281
|
+
Every change goes through a PR.
|
|
282
|
+
- **Never add AI attribution** to commit messages, PR descriptions,
|
|
283
|
+
code comments, or changelog entries.
|
|
284
|
+
|
|
285
|
+
## Specs and conformance
|
|
286
|
+
|
|
287
|
+
- **Round-trip conformance** is the contract: `AsciiChem.parse(s).to_text == s`
|
|
288
|
+
for any canonical `s`. The Text formatter is the canonicaliser.
|
|
289
|
+
- **Three-way round-trip** for CML: `parse(s).to_cml` →
|
|
290
|
+
`Cml.parse(...)` → `.to_text` equals `s`. See
|
|
291
|
+
`spec/asciichem/cml/cml_spec.rb` and the comprehensive case list
|
|
292
|
+
in `spec/integration/edge_cases_spec.rb`.
|
|
293
|
+
- **Corpus fuzzing** at `spec/fuzz/corpus/` — 15 files exercising
|
|
294
|
+
edge cases. Every line must either parse or raise
|
|
295
|
+
`AsciiChem::ParseError` cleanly (no raw exceptions).
|
|
296
|
+
- **Edge cases** in `spec/integration/edge_cases_spec.rb` — 60+
|
|
297
|
+
cases covering parser robustness (malformed inputs raise
|
|
298
|
+
ParseError cleanly), parser acceptance (every documented
|
|
299
|
+
construct), text and CML round-trip conformance, deterministic
|
|
300
|
+
output across runs, formatter registry, linter coverage, and
|
|
301
|
+
periodic-table coverage.
|
|
302
|
+
- **Known limitations are spec'd.** Don't silently drop constructs on
|
|
303
|
+
round-trip; write a spec that documents the loss and link it to a
|
|
304
|
+
`TODO.gem/` item that will close the gap.
|
|
305
|
+
|
|
306
|
+
## Performance
|
|
307
|
+
|
|
308
|
+
Manual benchmarks at `benchmarks/benchmark.rb`. Run with
|
|
309
|
+
`bundle exec ruby benchmarks/benchmark.rb`. Covers parse, text
|
|
310
|
+
round-trip, MathML output, and CML round-trip. CI does not run
|
|
311
|
+
benchmarks; they are a development-time regression check.
|
|
312
|
+
|
|
313
|
+
## Releasing
|
|
314
|
+
|
|
315
|
+
See `RELEASING.md` for the full release runbook. Short version:
|
|
316
|
+
bump `lib/asciichem/version.rb`, update `CHANGELOG.md`, open a PR,
|
|
317
|
+
merge, then (maintainer only) tag and `gem push`. Never push tags
|
|
318
|
+
yourself.
|
data/Gemfile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gemspec
|
|
6
|
+
|
|
7
|
+
group :development do
|
|
8
|
+
gem "benchmark", "~> 0.4"
|
|
9
|
+
gem "benchmark-ips", "~> 2.14", require: false
|
|
10
|
+
gem "rake", "~> 13.2"
|
|
11
|
+
gem "rspec", "~> 3.13"
|
|
12
|
+
gem "rubocop", "~> 1.66", require: false
|
|
13
|
+
gem "simplecov", "~> 0.22", require: false
|
|
14
|
+
end
|