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
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
@@ -0,0 +1,14 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /spec/examples.txt
10
+ /tmp/
11
+ /ruby/
12
+ /.vscode/
13
+ /.idea/
14
+ *.gem
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