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,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Formatter
5
+ # Renders a Model tree as a linear SVG. The SVG draws the formula
6
+ # along a horizontal baseline with elements at fixed spacing.
7
+ #
8
+ # This is the v1 fallback for environments that want a self-contained
9
+ # vector output without MathML. True 2D structural diagrams (skeletal
10
+ # formulae, rings, stereo wedges) require `mn/elk-rb` integration
11
+ # (TODO 13); this formatter does not attempt that.
12
+ class Svg < Base
13
+ LINE_HEIGHT = 40
14
+ CHAR_WIDTH = 14
15
+ BASELINE = 30
16
+
17
+ def visit_formula(formula)
18
+ nodes = formula.nodes
19
+ rows = layout_rows(nodes)
20
+ width = rows.map { |r| layout_width(r) }.max
21
+ height = rows.size * LINE_HEIGHT + 10
22
+ render_svg(width, height, rows)
23
+ end
24
+
25
+ def visit_molecule(molecule)
26
+ prefix = molecule.coefficient.nil? || molecule.coefficient.to_s.empty? ? "" : molecule.coefficient.to_s
27
+ stereo = molecule.stereo ? "(#{molecule.stereo_letter})-" : ""
28
+ body = molecule.nodes.map { |n| render_node(n) }.join
29
+ "#{stereo}#{prefix}#{body}"
30
+ end
31
+
32
+ def visit_atom(atom)
33
+ parts = []
34
+ parts << "^#{atom.isotope}" if atom.isotope
35
+ parts << atom.element
36
+ parts << "_#{atom.subscript}" if atom.subscript
37
+ if atom.charge
38
+ parts << "^#{atom.charge}"
39
+ elsif atom.oxidation_state
40
+ parts << "^(#{atom.oxidation_state})"
41
+ elsif atom.superscript
42
+ parts << "^#{atom.superscript}"
43
+ end
44
+ parts << atom.ring_closures.to_s if atom.ring_closures
45
+ parts.join
46
+ end
47
+
48
+ def visit_group(group)
49
+ body = group.nodes.map { |n| render_node(n) }.join
50
+ suffix = group.multiplicity ? "_#{group.multiplicity}" : ""
51
+ "#{group.open_char}#{body}#{group.close_char}#{suffix}"
52
+ end
53
+
54
+ def visit_bond(bond)
55
+ bond.ascii
56
+ end
57
+
58
+ def visit_reaction(reaction)
59
+ left = reaction.reactants.map { |n| render_node(n) }.join(" + ")
60
+ right = reaction.products.map { |n| render_node(n) }.join(" + ")
61
+ arrow = reaction.arrow_ascii
62
+ conds = reaction.conditions
63
+ return "#{left} #{arrow} #{right}" unless conds
64
+
65
+ above = conds.above ? "[#{conds.above}]" : ""
66
+ below = conds.below ? "[#{conds.below}]" : ""
67
+ "#{left} #{arrow}#{above}#{below} #{right}"
68
+ end
69
+
70
+ def visit_reaction_cascade(cascade)
71
+ return "" if cascade.steps.empty?
72
+
73
+ head = cascade.steps.first
74
+ out = head.reactants.map { |n| render_node(n) }.join(" + ")
75
+ cascade.steps.each do |step|
76
+ arrow = step.arrow_ascii
77
+ conds = step.conditions
78
+ if conds
79
+ above = conds.above ? "[#{conds.above}]" : ""
80
+ below = conds.below ? "[#{conds.below}]" : ""
81
+ out += " #{arrow}#{above}#{below}"
82
+ else
83
+ out += " #{arrow}"
84
+ end
85
+ out += " " + step.products.map { |n| render_node(n) }.join(" + ")
86
+ end
87
+ out
88
+ end
89
+
90
+ def visit_electron_configuration(ec)
91
+ ec.orbitals.map { |orb, occ| "#{orb}^#{occ}" }.join(" ")
92
+ end
93
+
94
+ def visit_embedded_math(em)
95
+ em.source.to_s
96
+ end
97
+
98
+ def visit_text(text)
99
+ text.content
100
+ end
101
+
102
+ private
103
+
104
+ def render_node(node)
105
+ node.accept(self)
106
+ end
107
+
108
+ def layout_rows(nodes)
109
+ # v1: one row. Multi-row layout deferred until 2D structural
110
+ # support arrives (TODO 13).
111
+ [nodes.map { |n| render_node(n) }.join]
112
+ end
113
+
114
+ def layout_width(row)
115
+ row.length * CHAR_WIDTH + 20
116
+ end
117
+
118
+ def render_svg(width, height, rows)
119
+ title = rows.join(" ").gsub(/[<>&]/, "" => "")
120
+ lines = []
121
+ lines << %(<?xml version="1.0" encoding="UTF-8"?>)
122
+ lines << %(<svg xmlns="http://www.w3.org/2000/svg" width="#{width}" height="#{height}" viewBox="0 0 #{width} #{height}" role="img" aria-label="#{escape(title)}">)
123
+ lines << %( <title>#{escape(title)}</title>)
124
+ lines << %( <rect width="100%" height="100%" fill="transparent"/>)
125
+ rows.each_with_index do |row, idx|
126
+ y = BASELINE + (idx * LINE_HEIGHT)
127
+ lines << %( <text x="10" y="#{y}" font-family="serif" font-size="20" fill="currentColor">#{escape(row)}</text>)
128
+ end
129
+ lines << %(</svg>)
130
+ lines.join("\n")
131
+ end
132
+
133
+ def escape(string)
134
+ string.to_s
135
+ .gsub("&", "&amp;")
136
+ .gsub("<", "&lt;")
137
+ .gsub(">", "&gt;")
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Formatter
5
+ # Renders a Model tree as canonical AsciiChem text. Round-trip
6
+ # conformance: `AsciiChem.parse(s).to_text == s` for any conformant
7
+ # `s`. The formatter is the canonicaliser — equivalent inputs map
8
+ # to the same output.
9
+ #
10
+ # Canonicalisation rules (v1):
11
+ # - Explicit subscript marker: `H_2`, not `H2`.
12
+ # - Coefficient before molecule: `2H_2O`.
13
+ # - Isotope binds to atom: `^14C` (no `{}` carrier).
14
+ # - Charge is number-then-sign per IUPAC: `Ca^2+`.
15
+ # - Oxidation state in roman numerals with parens: `Fe^(III)`.
16
+ # - Group brackets preserved from input.
17
+ # - Reaction arrows use the canonical ASCII spelling (`->`, `<-`,
18
+ # `<=>`, `<->`).
19
+ class Text < Base
20
+ def visit_formula(formula)
21
+ formula.nodes.map { |n| render_node(n) }.join(" ")
22
+ end
23
+
24
+ def visit_molecule(molecule)
25
+ prefix = molecule.coefficient.nil? || molecule.coefficient.empty? ? "" : molecule.coefficient.to_s
26
+ stereo = molecule.stereo ? "(#{molecule.stereo_letter})-" : ""
27
+ body = molecule.nodes.map { |n| render_node(n) }.join
28
+ annotations = molecule_annotations(molecule)
29
+ "#{stereo}#{prefix}#{body}#{annotations}"
30
+ end
31
+
32
+ def molecule_annotations(molecule)
33
+ parts = []
34
+ molecule.names.each { |n| parts << %(@name("#{n.content}")) }
35
+ molecule.identifiers.each { |i| parts << %(@#{i.convention}("#{i.value}")) }
36
+ parts << %(@title("#{molecule.title}")) if molecule.title
37
+ molecule.formulas.each { |f| parts << %(@formula("#{f[:concise]}")) if f[:concise] }
38
+ molecule.labels.each { |l| parts << %(@label("#{l[:value]}")) if l[:value] }
39
+ molecule.properties.each { |p| parts << %(@#{p[:title]}("#{p[:value]}")) if p[:title] && p[:value] }
40
+ molecule.metadata.each { |m| parts << %(@meta("#{m[:name]}","#{m[:content]}")) }
41
+ parts.empty? ? "" : " #{parts.join}"
42
+ end
43
+
44
+ def visit_atom(atom)
45
+ parts = []
46
+ parts << (":" * atom.lone_pairs) if atom.lone_pairs
47
+ parts << "^#{atom.isotope}" if atom.isotope
48
+ parts << atom.element
49
+ parts << "_#{atom.subscript}" if atom.subscript
50
+ parts << "^#{atom.superscript}" if atom.superscript
51
+ parts << "^#{atom.charge}" if atom.charge
52
+ parts << "^(#{atom.oxidation_state})" if atom.oxidation_state
53
+ parts << ("." * atom.radical_electrons) if atom.radical_electrons
54
+ parts << atom.ring_closures.to_s if atom.ring_closures
55
+ parts << atom_annotation(atom)
56
+ parts.join
57
+ end
58
+
59
+ def atom_annotation(atom)
60
+ annotation = +""
61
+ if atom.x2 && atom.y2
62
+ annotation << "@(#{format_coord(atom.x2)},#{format_coord(atom.y2)}"
63
+ annotation << ",#{format_coord(atom.z2)}" if atom.z2
64
+ annotation << ")"
65
+ elsif atom.atom_parity
66
+ annotation << "@#{atom.atom_parity}"
67
+ end
68
+ annotation
69
+ end
70
+
71
+ def format_coord(value)
72
+ value == value.to_i ? value.to_i.to_s : value.to_s
73
+ end
74
+
75
+ def visit_group(group)
76
+ body = group.nodes.map { |n| render_node(n) }.join
77
+ suffix = group.multiplicity ? "_#{group.multiplicity}" : ""
78
+ "#{group.open_char}#{body}#{group.close_char}#{suffix}"
79
+ end
80
+
81
+ def visit_bond(bond)
82
+ bond.ascii
83
+ end
84
+
85
+ def visit_reaction(reaction)
86
+ left = reaction.reactants.map { |n| render_node(n) }.join(" + ")
87
+ right = reaction.products.map { |n| render_node(n) }.join(" + ")
88
+ arrow = reaction.arrow_ascii
89
+ conds = reaction.conditions
90
+ return "#{left} #{arrow} #{right}" unless conds
91
+
92
+ above = conds.above ? "[#{conds.above}]" : ""
93
+ below = conds.below ? "[#{conds.below}]" : ""
94
+ "#{left} #{arrow}#{above}#{below} #{right}"
95
+ end
96
+
97
+ def visit_reaction_cascade(cascade)
98
+ return "" if cascade.steps.empty?
99
+
100
+ head = cascade.steps.first
101
+ out = "#{render_terms(head.reactants)} #{render_arrow_with_conditions(head)} #{render_terms(head.products)}"
102
+ cascade.steps.drop(1).each do |step|
103
+ out += " #{render_arrow_with_conditions(step)} #{render_terms(step.products)}"
104
+ end
105
+ out
106
+ end
107
+
108
+ def visit_electron_configuration(ec)
109
+ parts = ec.orbitals.map { |orb, occ| "#{orb}^#{occ}" }
110
+ parts << ec.term_symbol.to_s if ec.term_symbol
111
+ parts.join(" ")
112
+ end
113
+
114
+ def visit_embedded_math(em)
115
+ "`#{em.source}`"
116
+ end
117
+
118
+ def visit_text(text)
119
+ %("#{text.content}")
120
+ end
121
+
122
+ private
123
+
124
+ def render_node(node)
125
+ node.accept(self)
126
+ end
127
+
128
+ def render_terms(terms)
129
+ terms.map { |n| render_node(n) }.join(" + ")
130
+ end
131
+
132
+ def render_arrow_with_conditions(reaction)
133
+ arrow = reaction.arrow_ascii
134
+ conds = reaction.conditions
135
+ return arrow unless conds
136
+
137
+ above = conds.above ? "[#{conds.above}]" : ""
138
+ below = conds.below ? "[#{conds.below}]" : ""
139
+ "#{arrow}#{above}#{below}"
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ # Format registry. Each output (MathML, Text, HTML, LaTeX, SVG) is a
5
+ # class under this module. The model's `to_<name>` shortcuts route
6
+ # through `Formatter[<name>].new.render(node)`.
7
+ #
8
+ # To add a new formatter:
9
+ # 1. Create `lib/asciichem/formatter/<name>.rb` with a class
10
+ # `AsciiChem::Formatter::<ClassCamel> < Base`.
11
+ # 2. Add `autoload :<ClassCamel>, "asciichem/formatter/<name>"` to
12
+ # this file.
13
+ # 3. Add `def to_<name>` to `Model::Node` if a shortcut is desired.
14
+ #
15
+ # No edits to existing formatters — OCP.
16
+ module Formatter
17
+ autoload :Base, "asciichem/formatter/base"
18
+ autoload :Html, "asciichem/formatter/html"
19
+ autoload :Latex, "asciichem/formatter/latex"
20
+ autoload :Mathml, "asciichem/formatter/mathml"
21
+ autoload :StructuralSvg, "asciichem/formatter/structural_svg"
22
+ autoload :Svg, "asciichem/formatter/svg"
23
+ autoload :Text, "asciichem/formatter/text"
24
+
25
+ # Lookup by format name. Triggers autoload; raises FormatError if
26
+ # the name is not registered. Accepts either snake_case
27
+ # (`:structural_svg`) or camelCase (`:mathml`) — both resolve to
28
+ # the matching constant.
29
+ def self.[](name)
30
+ const_get(camelize(name.to_s))
31
+ rescue NameError => e
32
+ raise AsciiChem::FormatError, "unknown formatter #{name.inspect}: #{e.message}"
33
+ end
34
+
35
+ def self.render(name, node)
36
+ self[name].new.render(node)
37
+ end
38
+
39
+ private_class_method def self.camelize(string)
40
+ string.split("_").map(&:capitalize).join
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parslet"
4
+
5
+ module AsciiChem
6
+ # Parslet grammar for AsciiChem v1.
7
+ #
8
+ # Design notes:
9
+ #
10
+ # - Leaf rules return strings; `.as(:name)` is applied at the
11
+ # combination site so the parse tree is a flat hash of named strings
12
+ # wherever practical.
13
+ # - The prefix-isotope binding (`^14C`) is enforced structurally: the
14
+ # `prefixed_atom` production consumes `^digits element` as a unit.
15
+ # A bare `^digits` without an element is rejected.
16
+ # - Marker rules (`subscript_marker`, `superscript_marker`) capture
17
+ # ONLY the value, not the leading `_` or `^`. The literal prefix is
18
+ # consumed by the rule but not included in the named capture.
19
+ class Grammar < Parslet::Parser
20
+ root :formula
21
+
22
+ # -- top level ---------------------------------------------------------
23
+
24
+ rule(:formula) do
25
+ spaces? >> nodes.as(:formula) >> spaces?
26
+ end
27
+
28
+ rule(:nodes) { node >> (spaces? >> node).repeat }
29
+
30
+ rule(:node) { reaction_cascade | reaction | electron_config | annotated_molecule | molecule | embedded_math | text_run.as(:text_run) }
31
+
32
+ # Annotated molecule: a molecule followed by one or more
33
+ # `@key("value")` annotations for CML metadata (names,
34
+ # identifiers, title, formula, labels).
35
+ rule(:annotated_molecule) do
36
+ molecule.as(:mol) >> molecule_annotation.repeat(1).as(:annotations)
37
+ end
38
+
39
+ rule(:molecule_annotation) do
40
+ spaces?.maybe >>
41
+ (metadata_annotation | simple_annotation)
42
+ end
43
+
44
+ # Metadata: @meta("key","value") — two quoted args, comma-separated.
45
+ # Produces CML <metadata name="key" content="value"/>.
46
+ # Uses distinct capture keys (meta_key/meta_value) so the transform
47
+ # can distinguish metadata from regular @key("value") annotations.
48
+ rule(:metadata_annotation) do
49
+ str('@meta(') >> str('"') >>
50
+ (str('"').absent? >> any).repeat.as(:meta_key) >> str('"') >>
51
+ str(',') >> str('"') >>
52
+ (str('"').absent? >> any).repeat.as(:meta_value) >> str('"') >>
53
+ str(')')
54
+ end
55
+
56
+ # Simple annotation: @key("value") — one quoted arg.
57
+ # Known types (name, inchi, etc.) are handled specially by the
58
+ # transform. Unknown types become properties.
59
+ rule(:simple_annotation) do
60
+ str('@') >> annotation_type.as(:ann_type) >>
61
+ str('(') >> str('"') >>
62
+ (str('"').absent? >> any).repeat.as(:ann_value) >>
63
+ str('"') >> str(')')
64
+ end
65
+
66
+ # Known annotation types first; property_name is a catch-all so
67
+ # any lowercase word (e.g. "mw", "density", "logP") becomes a
68
+ # property annotation.
69
+ rule(:annotation_type) do
70
+ str('name') | str('title') | str('formula') | str('label') |
71
+ str('inchi') | str('smiles') | str('cas') | str('iupac') |
72
+ str('cid') | str('chebi') |
73
+ property_name
74
+ end
75
+
76
+ rule(:property_name) do
77
+ match('[a-z]').repeat(1)
78
+ end
79
+
80
+ # -- reactions ---------------------------------------------------------
81
+
82
+ # A reaction cascade is two or more reactions chained together:
83
+ # the products of step N become the reactants of step N+1, with
84
+ # an arrow between them. The grammar reuses `reaction` for the
85
+ # first leg and `arrow >> terms` for each subsequent leg; the
86
+ # transform promotes the whole chain to a `ReactionCascade`.
87
+ rule(:reaction_cascade) do
88
+ (reaction.as(:first) >>
89
+ (arrow.as(:arrow) >> spaces? >> terms.as(:products)).repeat(1)).as(:cascade)
90
+ end
91
+
92
+ rule(:reaction) do
93
+ terms.as(:reactants) >>
94
+ arrow.as(:arrow) >>
95
+ spaces? >>
96
+ terms.as(:products)
97
+ end
98
+
99
+ rule(:terms) do
100
+ molecule >> (spaces? >> plus >> spaces? >> molecule).repeat
101
+ end
102
+
103
+ rule(:plus) { str("+") }
104
+
105
+ rule(:arrow) do
106
+ spaces? >>
107
+ arrow_token.as(:kind) >>
108
+ condition.maybe.as(:above) >>
109
+ condition.maybe.as(:below)
110
+ end
111
+
112
+ rule(:condition) do
113
+ str("[") >> (str("]").absent? >> any).repeat.as(:text) >> str("]")
114
+ end
115
+
116
+ rule(:arrow_token) do
117
+ str("<=>") | str("<->") | str("->") | str("<-")
118
+ end
119
+
120
+ # -- molecules ---------------------------------------------------------
121
+
122
+ rule(:molecule) do
123
+ stereo_prefix.maybe >> coefficient.maybe >> units.as(:units)
124
+ end
125
+
126
+ # Stereochemistry prefix: `(R)-`, `(S)-`, `(E)-`, `(Z)-`,
127
+ # `(a)-`/`(α)-` (alpha), `(b)-`/`(β)-` (beta). Tried before
128
+ # `coefficient` so the lookahead-via-failure on the closed letter
129
+ # set disambiguates from a parenthesised group: `(R)` matches
130
+ # because `R` is in the stereo set; `(OH)` fails because `OH` is
131
+ # not a single stereo letter, and the molecule rule falls through
132
+ # to the regular group parse.
133
+ rule(:stereo_prefix) do
134
+ str("(") >> stereo_letter.as(:stereo) >> str(")") >> str("-")
135
+ end
136
+
137
+ rule(:stereo_letter) do
138
+ str("alpha") | str("beta") |
139
+ str("R") | str("S") | str("E") | str("Z") |
140
+ str("α") | str("β") |
141
+ str("a") | str("b")
142
+ end
143
+
144
+ rule(:units) { (unit | bond).repeat(1) }
145
+ rule(:unit) { prefixed_atom | group | plain_atom }
146
+
147
+ # Bonds appear inside molecules as separators between units.
148
+ # Supported kinds, in alternation order (longest match first to
149
+ # avoid `>-` shadowing `-`):
150
+ # single `-`
151
+ # double `=`
152
+ # triple `#`
153
+ # quadruple `##`
154
+ # wedge `>-` (solid wedge toward viewer)
155
+ # hash `-<` (hashed wedge away from viewer)
156
+ # dative `~>` (electron-pair donor → acceptor; `->` is taken
157
+ # by the reaction arrow)
158
+ # wavy `~~` (resonance / delocalised)
159
+ rule(:bond) do
160
+ str("##").as(:quadruple) |
161
+ str(">-").as(:wedge) |
162
+ str("-<").as(:hash) |
163
+ str("~>").as(:dative) |
164
+ str("~~").as(:wavy) |
165
+ str("#").as(:triple) |
166
+ str("=").as(:double) |
167
+ str("-").as(:single)
168
+ end
169
+
170
+ rule(:coefficient) do
171
+ (digits.as(:value) >> (element_symbol | open_bracket).present?).as(:coefficient)
172
+ end
173
+
174
+ rule(:open_bracket) { str("(") | str("[") | str("{") }
175
+
176
+ # -- atoms -------------------------------------------------------------
177
+
178
+ rule(:prefixed_atom) do
179
+ (lewis_prefix.maybe >>
180
+ isotope_marker.as(:isotope) >>
181
+ element_symbol.as(:element) >>
182
+ atom_suffix >>
183
+ lewis_radicals.maybe >>
184
+ ring_closures.maybe.as(:ring_closures) >>
185
+ atom_annotation.maybe).as(:atom)
186
+ end
187
+
188
+ rule(:plain_atom) do
189
+ (lewis_prefix.maybe >>
190
+ element_symbol.as(:element) >>
191
+ atom_suffix >>
192
+ lewis_radicals.maybe >>
193
+ ring_closures.maybe.as(:ring_closures) >>
194
+ atom_annotation.maybe).as(:atom)
195
+ end
196
+
197
+ # Atom annotations: stereo parity (@R / @S) or 2D/3D coordinates
198
+ # (@(x,y) / @(x,y,z)). Both use the `@` prefix. An atom can carry
199
+ # at most one annotation in the grammar; multiple annotations
200
+ # would require compound syntax (deferred).
201
+ rule(:atom_annotation) do
202
+ coordinate_annotation | parity_annotation
203
+ end
204
+
205
+ rule(:parity_annotation) do
206
+ str('@') >> (str('R') | str('S')).as(:atom_parity)
207
+ end
208
+
209
+ rule(:coordinate_annotation) do
210
+ str('@(') >>
211
+ float_number.as(:x2) >> str(',') >>
212
+ float_number.as(:y2) >>
213
+ (str(',') >> float_number.as(:z2)).maybe >>
214
+ str(')')
215
+ end
216
+
217
+ rule(:float_number) do
218
+ str('-').maybe >> match('[0-9]').repeat(1) >> (str('.') >> match('[0-9]').repeat(0)).maybe
219
+ end
220
+
221
+ # Ring closure digits (SMILES-style). A digit suffix on an atom
222
+ # opens or closes a ring; two atoms with the same digit become
223
+ # bonded. Captured as a string (e.g. "1", "12") so multiple
224
+ # closures on one atom are preserved.
225
+ rule(:ring_closures) do
226
+ match('[0-9]').repeat(1)
227
+ end
228
+
229
+ rule(:atom_suffix) do
230
+ subscript_marker.maybe.as(:subscript) >>
231
+ superscript_marker.maybe.as(:superscript)
232
+ end
233
+
234
+ # Lewis markers. Prefix `:` count = lone_pairs (binds to following
235
+ # atom, like the isotope prefix). Suffix `.` count = radical
236
+ # electrons. Position-specific lone pairs (`:O:` style) collapse
237
+ # into a total count — the renderer decides layout.
238
+ rule(:lewis_prefix) { str(":").repeat(1).as(:lone_pairs) }
239
+ rule(:lewis_radicals) { str(".").repeat(1).as(:radical_electrons) }
240
+
241
+ # Markers consume the leading `_` / `^` but capture only the value.
242
+ rule(:isotope_marker) do
243
+ (str("^") | str("_")) >> digits
244
+ end
245
+
246
+ rule(:subscript_marker) do
247
+ str("_") >> subscript_value
248
+ end
249
+
250
+ rule(:superscript_marker) do
251
+ str("^") >> superscript_value
252
+ end
253
+
254
+ rule(:element_symbol) do
255
+ match("[A-Z]") >> match("[a-z]").maybe
256
+ end
257
+
258
+ rule(:subscript_value) do
259
+ (str("{") >> (str("}").absent? >> any).repeat >> str("}")) |
260
+ digits
261
+ end
262
+
263
+ rule(:superscript_value) do
264
+ oxidation_state | charge | braced_or_bare
265
+ end
266
+
267
+ rule(:charge) do
268
+ (digits >> match("[+-]")) |
269
+ (match("[+-]") >> digits.maybe) |
270
+ digits
271
+ end
272
+
273
+ rule(:oxidation_state) do
274
+ str("(") >> roman_numeral >> str(")")
275
+ end
276
+
277
+ rule(:roman_numeral) { match("[IVXLCDM]").repeat(1) }
278
+
279
+ rule(:braced_or_bare) do
280
+ (str("{") >> (str("}").absent? >> any).repeat >> str("}")) |
281
+ match("[0-9a-zA-Z]").repeat(1)
282
+ end
283
+
284
+ # -- groups ------------------------------------------------------------
285
+
286
+ rule(:group) do
287
+ group_open.as(:open_bracket) >>
288
+ group_nodes >>
289
+ group_close.as(:close_bracket) >>
290
+ multiplicity.maybe.as(:multiplicity)
291
+ end
292
+
293
+ # `group_nodes` is a separate rule so we can carve out the closing
294
+ # bracket from `node`'s text fallback. Without this, `text_run`
295
+ # would consume the closing bracket and the group never terminates.
296
+ rule(:group_nodes) do
297
+ group_node.repeat(1).as(:group_nodes)
298
+ end
299
+
300
+ rule(:group_node) do
301
+ reaction | electron_config | molecule | embedded_math | group_text_run
302
+ end
303
+
304
+ rule(:group_text_run) do
305
+ str('"') >> (str('"').absent? >> any).repeat >> str('"')
306
+ end
307
+
308
+ rule(:group_open) { str("(") | str("[") | str("{") }
309
+ rule(:group_close) { str(")") | str("]") | str("}") }
310
+
311
+ rule(:multiplicity) { str("_") >> digits }
312
+
313
+ # -- electron configuration -------------------------------------------
314
+
315
+ rule(:electron_config) do
316
+ (orbital.as(:orbital) >> str("^") >> digits.as(:occupancy) >> spaces?.maybe).repeat(2).as(:electron_config)
317
+ end
318
+
319
+ rule(:orbital) { digits >> match("[spdfgh]") }
320
+
321
+ # -- embedded math ----------------------------------------------------
322
+
323
+ rule(:embedded_math) do
324
+ str("`") >>
325
+ (str("`").absent? >> any).repeat.as(:math_source) >>
326
+ str("`")
327
+ end
328
+
329
+ # -- text (top level) -------------------------------------------------
330
+
331
+ # Free-form text uses `"..."` delimiters, matching AsciiMath's
332
+ # convention. The quoted content becomes a Text node with the
333
+ # surrounding quotes stripped (handled by the transform).
334
+ rule(:text_run) do
335
+ str('"') >> (str('"').absent? >> any).repeat >> str('"')
336
+ end
337
+
338
+ # -- primitives -------------------------------------------------------
339
+
340
+ rule(:digits) { match("[0-9]").repeat(1) }
341
+ rule(:spaces) { match(/\s/).repeat(1) }
342
+ rule(:spaces?) { spaces.maybe }
343
+ end
344
+ end