asciichem 0.28.2 → 0.29.1

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.
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Opt-in soft dependency: fail with guidance, never silently.
4
+ begin
5
+ require 'parsanol/parslet'
6
+ # parsanol-ruby 1.3.16's native.rb references
7
+ # Parsanol::Native::Dynamic from the serializer without loading it
8
+ # (parsanol-ruby#25).
9
+ require 'parsanol/native/dynamic'
10
+ rescue LoadError => e
11
+ raise AsciiChem::Engine::NotAvailableError,
12
+ 'the parsanol engine requires the parsanol gem — add gem "parsanol", "~> 1.3.16" ' \
13
+ "to your Gemfile (#{e.message})"
14
+ end
15
+
16
+ module AsciiChem
17
+ module Engine
18
+ # The Parsanol engine: the SAME rules (GrammarRules /
19
+ # TransformRules) over Parsanol's Rust-backed parslet-compat
20
+ # layer. Corpus-identical to :parslet (221/221 byte-for-byte,
21
+ # benchmarks/parsanol_recheck.rb) at ~3x parse speed.
22
+ class ParsanolEngine
23
+ # Backend twins of the reference classes; the shared rules
24
+ # modules are the single source of grammar truth.
25
+ class Grammar < ::Parsanol::Parslet::Parser
26
+ include AsciiChem::GrammarRules
27
+ end
28
+
29
+ # Transform twin; #apply normalizes the engine's merged sibling
30
+ # captures before the shared rules run (see split_merged_formula).
31
+ class Transform < ::Parsanol::Parslet::Transform
32
+ include AsciiChem::TransformRules
33
+
34
+ # Engine divergence seam: parslet yields repeated sibling
35
+ # node captures as an array of single-key hashes; parsanol
36
+ # merges them into one hash, which no rule can match. Split
37
+ # the merged form back before the shared rules run, so the
38
+ # rules stay engine-agnostic (single source of truth).
39
+ def apply(tree, context = nil)
40
+ super(ParsanolEngine.split_merged_formula(tree), context)
41
+ end
42
+ end
43
+
44
+ class << self
45
+ # NODE_STARTERS mirrors the grammar's `node` alternatives
46
+ # (grammar_rules.rb): the capture key each alternative
47
+ # produces FIRST. A new node group begins at each starter;
48
+ # continuation keys (arrow/products, coefficient/annotations,
49
+ # crystal_name/params/body, ...) attach to the current group.
50
+ NODE_STARTERS = %i[
51
+ cascade reactants electron_config crystal_node spectrum_node
52
+ calc_node zmatrix_node mechanism_node mol units math_source text_run
53
+ ].freeze
54
+
55
+ # Grammar truth: a molecule node is {coefficient? stereo? units}
56
+ # — coefficient/stereo may LEGITIMATELY precede :units in one
57
+ # node. They are prefixes, not new-node markers.
58
+ PREFIX_KEYS = %i[coefficient stereo].freeze
59
+
60
+ def split_merged_formula(node)
61
+ case node
62
+ when Hash
63
+ node.to_h { |key, value| [key, split_formula_value(key, value)] }
64
+ when Array
65
+ node.map { |member| split_merged_formula(member) }
66
+ else
67
+ node
68
+ end
69
+ end
70
+
71
+ def grammar
72
+ Grammar
73
+ end
74
+
75
+ def transform
76
+ Transform
77
+ end
78
+
79
+ def parse_failed
80
+ ::Parsanol::ParseFailed
81
+ end
82
+
83
+ def grammar_instance
84
+ @grammar_instance ||= grammar.new
85
+ end
86
+
87
+ def transform_instance
88
+ @transform_instance ||= transform.new
89
+ end
90
+
91
+ private
92
+
93
+ def split_formula_value(key, value)
94
+ if key == :formula && value.is_a?(Hash) && value.length > 1
95
+ groups = []
96
+ value.each do |k, v|
97
+ groups << {} if new_group?(groups, k)
98
+ groups.last[k] = split_merged_formula(v)
99
+ end
100
+ groups.length == 1 ? groups.first : groups
101
+ else
102
+ split_merged_formula(value)
103
+ end
104
+ end
105
+
106
+ def new_group?(groups, key)
107
+ return true if groups.empty?
108
+
109
+ return false unless NODE_STARTERS.include?(key)
110
+
111
+ # :units continues a group that so far holds only molecule
112
+ # prefixes ({coefficient:...} / {stereo:...}).
113
+ !(key == :units && groups.last.keys.all? { |pk| PREFIX_KEYS.include?(pk) })
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Engine
5
+ # The reference engine: pure-Ruby parslet. Zero extra
6
+ # dependencies; Grammar and Transform as always.
7
+ class ParsletEngine
8
+ class << self
9
+ def grammar
10
+ Grammar
11
+ end
12
+
13
+ def transform
14
+ Transform
15
+ end
16
+
17
+ def parse_failed
18
+ Parslet::ParseFailed
19
+ end
20
+
21
+ # Engines memoize their grammar/transform instances — they are
22
+ # stateless and expensive to construct (benchmark: ~15%
23
+ # throughput on repeated parses).
24
+ def grammar_instance
25
+ @grammar_instance ||= grammar.new
26
+ end
27
+
28
+ def transform_instance
29
+ @transform_instance ||= transform.new
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ # Parsing engine selector (TODO.impl 63/64; parsanol-ruby#25).
5
+ #
6
+ # The reference engine is :parslet (pure Ruby, zero extra
7
+ # dependencies) and remains the default. The :parsanol engine runs
8
+ # the SAME rules (GrammarRules/TransformRules) over Parsanol's
9
+ # Rust-backed parslet-compat layer — the shared corpus passes
10
+ # byte-for-byte (221/221) at ~3x parslet speed.
11
+ #
12
+ # Parsanol is an opt-in soft dependency; the gemspec is unchanged:
13
+ #
14
+ # # Gemfile
15
+ # gem "parsanol", "~> 1.3.16"
16
+ #
17
+ # require "asciichem/engine/parsanol_engine"
18
+ # AsciiChem::Engine.use(:parsanol)
19
+ # AsciiChem.parse("H_2O") # parsed by the Rust core
20
+ #
21
+ # The active engine may also be selected via ASCIICHEM_ENGINE
22
+ # (a testing seam, mirroring ASCIICHEM_CORPUS):
23
+ #
24
+ # ASCIICHEM_ENGINE=parsanol bundle exec rspec
25
+ #
26
+ module Engine
27
+ class Error < AsciiChem::Error; end
28
+
29
+ # The named engine exists but its gem is not installed.
30
+ class NotAvailableError < Error; end
31
+
32
+ autoload :ParsletEngine, 'asciichem/engine/parslet_engine'
33
+ autoload :ParsanolEngine, 'asciichem/engine/parsanol_engine'
34
+
35
+ class << self
36
+ # Selects the engine by name. Returns the engine class.
37
+ def use(name)
38
+ @current = engine_for(name)
39
+ end
40
+
41
+ # The active engine. Defaults to :parslet, overridable once via
42
+ # ASCIICHEM_ENGINE at first use.
43
+ def current
44
+ @current ||= engine_for(ENV.fetch('ASCIICHEM_ENGINE', 'parslet'))
45
+ end
46
+
47
+ private
48
+
49
+ def engine_for(name)
50
+ case name.to_s
51
+ when 'parslet' then ParsletEngine
52
+ when 'parsanol' then ParsanolEngine
53
+ else
54
+ raise Error, "unknown engine #{name.inspect} (available: parslet, parsanol)"
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "parslet"
3
+ require 'parslet'
4
4
 
5
5
  module AsciiChem
6
6
  # Parslet grammar for AsciiChem v1.
@@ -17,461 +17,6 @@ module AsciiChem
17
17
  # ONLY the value, not the leading `_` or `^`. The literal prefix is
18
18
  # consumed by the rule but not included in the named capture.
19
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 | crystal | spectrum | calculation | zmatrix | mechanism | annotated_molecule | molecule | embedded_math | text_run.as(:text_run) }
31
-
32
- # -- crystallography -------------------------------------------------
33
-
34
- # crystal[Name](a=X,b=Y,...,sg=SG){atoms with @f(x,y,z)}
35
- rule(:crystal) do
36
- (str('crystal') >>
37
- crystal_name.maybe >>
38
- crystal_params.maybe >>
39
- crystal_body.maybe).as(:crystal_node)
40
- end
41
-
42
- rule(:crystal_name) do
43
- str('[') >> (str(']').absent? >> any).repeat.as(:crystal_name) >> str(']')
44
- end
45
-
46
- rule(:crystal_params) do
47
- str('(') >> (str(')').absent? >> any).repeat.as(:crystal_params) >> str(')')
48
- end
49
-
50
- rule(:crystal_body) do
51
- str('{') >> (str('}').absent? >> any).repeat.as(:crystal_body) >> str('}')
52
- end
53
-
54
- # -- spectroscopy ---------------------------------------------------
55
-
56
- # spectrum[type](params){peak data}
57
- rule(:spectrum) do
58
- (str('spectrum') >>
59
- spectrum_type.maybe >>
60
- spectrum_params.maybe >>
61
- spectrum_body.maybe).as(:spectrum_node)
62
- end
63
-
64
- rule(:spectrum_type) do
65
- str('[') >> (str(']').absent? >> any).repeat.as(:spectrum_type) >> str(']')
66
- end
67
-
68
- rule(:spectrum_params) do
69
- str('(') >> (str(')').absent? >> any).repeat.as(:spectrum_params) >> str(')')
70
- end
71
-
72
- rule(:spectrum_body) do
73
- str('{') >> (str('}').absent? >> any).repeat.as(:spectrum_body) >> str('}')
74
- end
75
-
76
- # -- computational chemistry ----------------------------------------
77
-
78
- # calc(method/basis){key: value units}
79
- rule(:calculation) do
80
- (str('calc') >>
81
- calc_params.maybe >>
82
- calc_body.maybe).as(:calc_node)
83
- end
84
-
85
- rule(:calc_params) do
86
- str('(') >> (str(')').absent? >> any).repeat.as(:calc_params) >> str(')')
87
- end
88
-
89
- rule(:calc_body) do
90
- str('{') >> (str('}').absent? >> any).repeat.as(:calc_body) >> str('}')
91
- end
92
-
93
- # -- Z-Matrix -------------------------------------------------------
94
-
95
- rule(:zmatrix) do
96
- (str('zmatrix') >>
97
- zmatrix_body.maybe).as(:zmatrix_node)
98
- end
99
-
100
- rule(:zmatrix_body) do
101
- str('{') >> (str('}').absent? >> any).repeat.as(:zmatrix_body) >> str('}')
102
- end
103
-
104
- # -- reaction mechanisms --------------------------------------------
105
-
106
- rule(:mechanism) do
107
- (str('mechanism') >>
108
- mechanism_body.maybe).as(:mechanism_node)
109
- end
110
-
111
- rule(:mechanism_body) do
112
- str('{') >> (str('}').absent? >> any).repeat.as(:mechanism_body) >> str('}')
113
- end
114
-
115
- # Annotated molecule: a molecule followed by one or more
116
- # `@key("value")` annotations for CML metadata (names,
117
- # identifiers, title, formula, labels).
118
- rule(:annotated_molecule) do
119
- molecule.as(:mol) >> molecule_annotation.repeat(1).as(:annotations)
120
- end
121
-
122
- rule(:molecule_annotation) do
123
- spaces?.maybe >>
124
- (metadata_annotation | simple_annotation)
125
- end
126
-
127
- # Metadata: @meta("key","value") — two quoted args, comma-separated.
128
- # Produces CML <metadata name="key" content="value"/>.
129
- # Uses distinct capture keys (meta_key/meta_value) so the transform
130
- # can distinguish metadata from regular @key("value") annotations.
131
- rule(:metadata_annotation) do
132
- str('@meta(') >> str('"') >>
133
- (str('"').absent? >> any).repeat.as(:meta_key) >> str('"') >>
134
- str(',') >> str('"') >>
135
- (str('"').absent? >> any).repeat.as(:meta_value) >> str('"') >>
136
- str(')')
137
- end
138
-
139
- # Simple annotation: @key("value") — one quoted arg.
140
- # Known types (name, inchi, etc.) are handled specially by the
141
- # transform. Unknown types become properties.
142
- rule(:simple_annotation) do
143
- str('@') >> annotation_type.as(:ann_type) >>
144
- str('(') >> str('"') >>
145
- (str('"').absent? >> any).repeat.as(:ann_value) >>
146
- str('"') >> str(')')
147
- end
148
-
149
- # Known annotation types first; property_name is a catch-all so
150
- # any lowercase word (e.g. "mw", "density", "logP") becomes a
151
- # property annotation.
152
- rule(:annotation_type) do
153
- str('name') | str('title') | str('formula') | str('label') |
154
- str('inchi') | str('smiles') | str('cas') | str('iupac') |
155
- str('cid') | str('chebi') |
156
- property_name
157
- end
158
-
159
- rule(:property_name) do
160
- match('[a-z]').repeat(1)
161
- end
162
-
163
- # -- reactions ---------------------------------------------------------
164
-
165
- # A reaction cascade is two or more reactions chained together:
166
- # the products of step N become the reactants of step N+1, with
167
- # an arrow between them. The grammar reuses `reaction` for the
168
- # first leg and `arrow >> terms` for each subsequent leg; the
169
- # transform promotes the whole chain to a `ReactionCascade`.
170
- rule(:reaction_cascade) do
171
- (reaction.as(:first) >>
172
- (arrow.as(:arrow) >> spaces? >> terms.as(:products)).repeat(1)).as(:cascade)
173
- end
174
-
175
- rule(:reaction) do
176
- terms.as(:reactants) >>
177
- arrow.as(:arrow) >>
178
- spaces? >>
179
- terms.as(:products)
180
- end
181
-
182
- rule(:terms) do
183
- molecule >> (spaces? >> plus >> spaces? >> molecule).repeat
184
- end
185
-
186
- rule(:plus) { str("+") }
187
-
188
- rule(:arrow) do
189
- spaces? >>
190
- arrow_token.as(:kind) >>
191
- condition.maybe.as(:above) >>
192
- condition.maybe.as(:below)
193
- end
194
-
195
- rule(:condition) do
196
- str("[") >> (str("]").absent? >> any).repeat.as(:text) >> str("]")
197
- end
198
-
199
- rule(:arrow_token) do
200
- str("<=>") | str("<->") | str("->") | str("<-")
201
- end
202
-
203
- # -- molecules ---------------------------------------------------------
204
-
205
- rule(:molecule) do
206
- stereo_prefix.maybe >> coefficient.maybe >> units.as(:units)
207
- end
208
-
209
- # Stereochemistry prefix: `(R)-`, `(S)-`, `(E)-`, `(Z)-`,
210
- # `(a)-`/`(α)-` (alpha), `(b)-`/`(β)-` (beta). Tried before
211
- # `coefficient` so the lookahead-via-failure on the closed letter
212
- # set disambiguates from a parenthesised group: `(R)` matches
213
- # because `R` is in the stereo set; `(OH)` fails because `OH` is
214
- # not a single stereo letter, and the molecule rule falls through
215
- # to the regular group parse.
216
- rule(:stereo_prefix) do
217
- str("(") >> stereo_letter.as(:stereo) >> str(")") >> str("-")
218
- end
219
-
220
- rule(:stereo_letter) do
221
- str("alpha") | str("beta") |
222
- str("R") | str("S") | str("E") | str("Z") |
223
- str("α") | str("β") |
224
- str("a") | str("b")
225
- end
226
-
227
- rule(:units) { (unit | bond).repeat(1) }
228
- rule(:unit) { prefixed_atom | group | hydrogen_atom | plain_atom }
229
-
230
- # Hydrogen special case: H cannot form ring closures (only 1 bond),
231
- # so bare digits after H are unambiguously subscripts. This lets
232
- # users write `H2O` instead of `H_2O` for the common formula case
233
- # without conflicting with SMILES-style ring closures on other
234
- # elements (`C1-C-C1` is still cyclohexane-style).
235
- #
236
- # The `match('[a-z]').absent?` lookahead prevents this rule from
237
- # stealing the H from `He`, `Ho`, etc. — those fall through to
238
- # plain_atom which matches the full element symbol greedily.
239
- rule(:hydrogen_atom) do
240
- (lewis_prefix.maybe >>
241
- str('H').as(:element) >>
242
- match('[a-z]').absent? >>
243
- h_subscript.maybe.as(:subscript) >>
244
- superscript_marker.maybe.as(:superscript) >>
245
- lewis_radicals.maybe >>
246
- atom_annotation).as(:atom)
247
- end
248
-
249
- # Subscript on hydrogen: explicit `_digits` OR bare `digits`
250
- # (implicit). Both produce the same `:subscript` capture. Bare
251
- # digits are safe here because H can't ring-close.
252
- rule(:h_subscript) do
253
- (str('_') >> subscript_value) | digits
254
- end
255
-
256
- # Bonds appear inside molecules as separators between units.
257
- # Supported kinds, in alternation order (longest match first to
258
- # avoid `>-` shadowing `-`):
259
- # single `-`
260
- # double `=`
261
- # triple `#`
262
- # quadruple `##`
263
- # wedge `>-` (solid wedge toward viewer)
264
- # hash `-<` (hashed wedge away from viewer)
265
- # dative `~>` (electron-pair donor → acceptor; `->` is taken
266
- # by the reaction arrow)
267
- # wavy `~~` (resonance / delocalised)
268
- rule(:bond) do
269
- str("##").as(:quadruple) |
270
- str(">-").as(:wedge) |
271
- str("-<").as(:hash) |
272
- str("~>").as(:dative) |
273
- str("~~").as(:wavy) |
274
- str("#").as(:triple) |
275
- str("=").as(:double) |
276
- str("-").as(:single)
277
- end
278
-
279
- rule(:coefficient) do
280
- (digits.as(:value) >> (element_symbol | open_bracket).present?).as(:coefficient)
281
- end
282
-
283
- rule(:open_bracket) { str("(") | str("[") | str("{") }
284
-
285
- # -- atoms -------------------------------------------------------------
286
-
287
- rule(:prefixed_atom) do
288
- (lewis_prefix.maybe >>
289
- isotope_marker.as(:isotope) >>
290
- element_symbol.as(:element) >>
291
- atom_suffix >>
292
- lewis_radicals.maybe >>
293
- ring_closures.maybe.as(:ring_closures) >>
294
- atom_annotation).as(:atom)
295
- end
296
-
297
- rule(:plain_atom) do
298
- (lewis_prefix.maybe >>
299
- element_symbol.as(:element) >>
300
- atom_suffix >>
301
- lewis_radicals.maybe >>
302
- ring_closures.maybe.as(:ring_closures) >>
303
- atom_annotation).as(:atom)
304
- end
305
-
306
- # Atom annotations: each is independently optional via .maybe.
307
- # Order matters: @(x,y) → @R/@S → @m(N) → @t("...") → @f(x,y,z).
308
- # Example: C@(10,20)@R@m(2)@t("C1")@f(0.5,0.5,0.5)
309
- rule(:atom_annotation) do
310
- coordinate_annotation.maybe >>
311
- parity_annotation.maybe >>
312
- multiplicity_annotation.maybe >>
313
- atom_title_annotation.maybe >>
314
- fractional_annotation.maybe
315
- end
316
-
317
- rule(:parity_annotation) do
318
- str('@') >> (str('R') | str('S')).as(:atom_parity)
319
- end
320
-
321
- rule(:coordinate_annotation) do
322
- str('@(') >>
323
- float_number.as(:x2) >> str(',') >>
324
- float_number.as(:y2) >>
325
- (str(',') >> float_number.as(:z2)).maybe >>
326
- str(')')
327
- end
328
-
329
- # Spin multiplicity: @m(2) for doublet, @m(1) for singlet
330
- rule(:multiplicity_annotation) do
331
- str('@m(') >> match('[0-9]').repeat(1).as(:spin_multiplicity) >> str(')')
332
- end
333
-
334
- # Atom title/label: @t("C1")
335
- rule(:atom_title_annotation) do
336
- str('@t(') >> str('"') >>
337
- (str('"').absent? >> any).repeat.as(:atom_title) >>
338
- str('"') >> str(')')
339
- end
340
-
341
- # Fractional coordinates (crystallographic): @f(0.5,0.25,0.75)
342
- rule(:fractional_annotation) do
343
- str('@f(') >>
344
- float_number.as(:x_fract) >> str(',') >>
345
- float_number.as(:y_fract) >> str(',') >>
346
- float_number.as(:z_fract) >>
347
- str(')')
348
- end
349
-
350
- rule(:float_number) do
351
- str('-').maybe >> match('[0-9]').repeat(1) >> (str('.') >> match('[0-9]').repeat(0)).maybe
352
- end
353
-
354
- # Ring closure digits (SMILES-style). A digit suffix on an atom
355
- # opens or closes a ring; two atoms with the same digit become
356
- # bonded. Captured as a string (e.g. "1", "12") so multiple
357
- # closures on one atom are preserved.
358
- rule(:ring_closures) do
359
- match('[0-9]').repeat(1)
360
- end
361
-
362
- rule(:atom_suffix) do
363
- subscript_marker.maybe.as(:subscript) >>
364
- superscript_marker.maybe.as(:superscript)
365
- end
366
-
367
- # Lewis markers. Prefix `:` count = lone_pairs (binds to following
368
- # atom, like the isotope prefix). Suffix `.` count = radical
369
- # electrons. Position-specific lone pairs (`:O:` style) collapse
370
- # into a total count — the renderer decides layout.
371
- rule(:lewis_prefix) { str(":").repeat(1).as(:lone_pairs) }
372
- rule(:lewis_radicals) { str(".").repeat(1).as(:radical_electrons) }
373
-
374
- # Markers consume the leading `_` / `^` but capture only the value.
375
- rule(:isotope_marker) do
376
- (str("^") | str("_")) >> digits
377
- end
378
-
379
- rule(:subscript_marker) do
380
- str("_") >> subscript_value
381
- end
382
-
383
- rule(:superscript_marker) do
384
- str("^") >> superscript_value
385
- end
386
-
387
- rule(:element_symbol) do
388
- match("[A-Z]") >> match("[a-z]").maybe
389
- end
390
-
391
- rule(:subscript_value) do
392
- (str("{") >> (str("}").absent? >> any).repeat >> str("}")) |
393
- digits
394
- end
395
-
396
- rule(:superscript_value) do
397
- oxidation_state | charge | braced_or_bare
398
- end
399
-
400
- rule(:charge) do
401
- (digits >> match("[+-]")) |
402
- (match("[+-]") >> digits.maybe) |
403
- digits
404
- end
405
-
406
- rule(:oxidation_state) do
407
- str("(") >> roman_numeral >> str(")")
408
- end
409
-
410
- rule(:roman_numeral) { match("[IVXLCDM]").repeat(1) }
411
-
412
- rule(:braced_or_bare) do
413
- (str("{") >> (str("}").absent? >> any).repeat >> str("}")) |
414
- match("[0-9a-zA-Z]").repeat(1)
415
- end
416
-
417
- # -- groups ------------------------------------------------------------
418
-
419
- rule(:group) do
420
- group_open.as(:open_bracket) >>
421
- group_nodes >>
422
- group_close.as(:close_bracket) >>
423
- multiplicity.maybe.as(:multiplicity)
424
- end
425
-
426
- # `group_nodes` is a separate rule so we can carve out the closing
427
- # bracket from `node`'s text fallback. Without this, `text_run`
428
- # would consume the closing bracket and the group never terminates.
429
- rule(:group_nodes) do
430
- group_node.repeat(1).as(:group_nodes)
431
- end
432
-
433
- rule(:group_node) do
434
- reaction | electron_config | molecule | embedded_math | group_text_run
435
- end
436
-
437
- rule(:group_text_run) do
438
- str('"') >> (str('"').absent? >> any).repeat >> str('"')
439
- end
440
-
441
- rule(:group_open) { str("(") | str("[") | str("{") }
442
- rule(:group_close) { str(")") | str("]") | str("}") }
443
-
444
- rule(:multiplicity) { str("_") >> digits }
445
-
446
- # -- electron configuration -------------------------------------------
447
-
448
- rule(:electron_config) do
449
- (orbital.as(:orbital) >> str("^") >> digits.as(:occupancy) >> spaces?.maybe).repeat(2).as(:electron_config)
450
- end
451
-
452
- rule(:orbital) { digits >> match("[spdfgh]") }
453
-
454
- # -- embedded math ----------------------------------------------------
455
-
456
- rule(:embedded_math) do
457
- str("`") >>
458
- (str("`").absent? >> any).repeat.as(:math_source) >>
459
- str("`")
460
- end
461
-
462
- # -- text (top level) -------------------------------------------------
463
-
464
- # Free-form text uses `"..."` delimiters, matching AsciiMath's
465
- # convention. The quoted content becomes a Text node with the
466
- # surrounding quotes stripped (handled by the transform).
467
- rule(:text_run) do
468
- str('"') >> (str('"').absent? >> any).repeat >> str('"')
469
- end
470
-
471
- # -- primitives -------------------------------------------------------
472
-
473
- rule(:digits) { match("[0-9]").repeat(1) }
474
- rule(:spaces) { match(/\s/).repeat(1) }
475
- rule(:spaces?) { spaces.maybe }
20
+ include GrammarRules
476
21
  end
477
22
  end