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