asciichem 0.30.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -0
- data/asciichem.gemspec +1 -1
- data/benchmarks/README.md +21 -0
- data/benchmarks/parg_recheck.rb +120 -0
- data/lib/asciichem/cml/group_extensions.rb +35 -6
- data/lib/asciichem/engine/parg/asciichem.json +1 -0
- data/lib/asciichem/engine/parg_engine.rb +179 -0
- data/lib/asciichem/engine.rb +3 -1
- data/lib/asciichem/layout.rb +5 -4
- data/lib/asciichem/version.rb +1 -1
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c1ac898f589ce81ddaa9d9b9a06f4540603b81bf1196f0a29b5b65468c0e4b0
|
|
4
|
+
data.tar.gz: 436d78daf7a738ea821ae9d936096abfbb01a4de07d51fb83152aa43998e6e7f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8af34246000adfc3d4c370095d81f613eb28eae24ef48071043e50fd626cbfe97824def5d31e0c8cef46629c977d22beec4bb93e8bae2b2f188126c7712a2f03
|
|
7
|
+
data.tar.gz: '090b048ff7fe04c62602985dccf2d35adbaf22f9033e9604009a2e4c1f012e105f1cedfadd0c4b3be684c906c0f3eebf649ce76cfd5e2f2d34942c86f3ef1fef'
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,50 @@
|
|
|
3
3
|
All notable changes to AsciiChem are documented here.
|
|
4
4
|
This project follows [Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [0.31.0] - 2026-10-19
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
- elkrb floor raised to `~> 2.0` (from `~> 1.0`): the layout stack
|
|
10
|
+
migrates to elkrb 2.0's option-based API — the graph-level
|
|
11
|
+
`LayoutOptions` object is gone and the algorithm flows through
|
|
12
|
+
`Elkrb.layout(graph, algorithm:)` (the call site already passed
|
|
13
|
+
it). Full suite (2024 examples: structural-SVG goldens included)
|
|
14
|
+
green under both parse engines on elkrb 2.0.0; layered remains
|
|
15
|
+
the default algorithm and output is unchanged byte-for-byte.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- The `:parg` engine (opt-in, third): parses through the compiled
|
|
19
|
+
PARG artifact of the AsciiChem grammar — vendored from
|
|
20
|
+
asciichem-parg (`lib/asciichem/engine/parg/asciichem.json`,
|
|
21
|
+
version-pinned, drift fails loudly) — and adapts its parse trees
|
|
22
|
+
to the same shapes the shared `TransformRules` consume. The full
|
|
23
|
+
suite (2024 examples: round-trip conformance, L2 MathML goldens,
|
|
24
|
+
CML three-way, edge cases) passes byte-identically under
|
|
25
|
+
`ASCIICHEM_ENGINE=parg`, the shared corpus gates 221/221 with
|
|
26
|
+
model equality against the reference engine, and the workload
|
|
27
|
+
benchmark runs ~2.7x parslet. `AsciiChem::Engine.use(:parg)` or
|
|
28
|
+
`ASCIICHEM_ENGINE=parg`; requires parsanol ≥ 1.3.94 (PARG),
|
|
29
|
+
still a soft dev dependency. Repetition flattening follows
|
|
30
|
+
parslet's own hash-only semantics (parsanol-ruby#231/#232), so
|
|
31
|
+
the grammar captures every structural rule and the engine's
|
|
32
|
+
tree adapter unwraps the wrappers into the DSL shapes; a
|
|
33
|
+
capture-completeness gate fails any grammar edit that leaves a
|
|
34
|
+
trailing literal uncaptured (`benchmarks/parg_recheck.rb` is
|
|
35
|
+
the standing gate).
|
|
36
|
+
|
|
37
|
+
## [0.30.1] - 2026-09-23
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- Groups attached by an explicit bond no longer re-spell through
|
|
41
|
+
CML round-trip: `CC(=O)O` came back as `CC=(O)O`. The CML
|
|
42
|
+
rebuilder left the attachment bond outside the reconstructed
|
|
43
|
+
group; the grammar keeps it inside (the inner molecule starts
|
|
44
|
+
with the bond), so the rebuilder now pulls an adjacent preceding
|
|
45
|
+
Bond into the group. Affects text syntax and SMILES ingestion
|
|
46
|
+
alike; regression specs cover single/sibling bonded groups,
|
|
47
|
+
multi-atom groups, and aspirin stability across double
|
|
48
|
+
round-trips.
|
|
49
|
+
|
|
6
50
|
## [0.30.0] - 2026-09-22
|
|
7
51
|
|
|
8
52
|
### Changed
|
data/asciichem.gemspec
CHANGED
|
@@ -36,7 +36,7 @@ Gem::Specification.new do |spec|
|
|
|
36
36
|
# Floors are the versions the corpus suite validates against
|
|
37
37
|
# (pessimistic ~>); raise them only with a full-suite run.
|
|
38
38
|
spec.add_dependency "chemicalml", "~> 0.3.0"
|
|
39
|
-
spec.add_dependency "elkrb", "~>
|
|
39
|
+
spec.add_dependency "elkrb", "~> 2.0"
|
|
40
40
|
spec.add_dependency "lutaml-model", "~> 0.8"
|
|
41
41
|
spec.add_dependency "mml", "~> 2.3"
|
|
42
42
|
spec.add_dependency "moxml", "~> 0.5"
|
data/benchmarks/README.md
CHANGED
|
@@ -230,3 +230,24 @@ it. The version is fully transitive — lutaml-model constrains
|
|
|
230
230
|
stubbing `leptris_preferred_available?` — lutaml's
|
|
231
231
|
`detect_xml_adapter` calls `runtime_default_adapter`, which
|
|
232
232
|
ignores `default_adapter=`.
|
|
233
|
+
|
|
234
|
+
### Re-check 10 (2026-09-23, parsanol 1.3.52)
|
|
235
|
+
|
|
236
|
+
Three releases since re-check 9 — notable upstream: 1.3.50 pins
|
|
237
|
+
cross-engine consume-all/recursion semantics (closing the five
|
|
238
|
+
PR-#22 divergence skips), 1.3.51 consolidates on a single Symbol
|
|
239
|
+
tag form. Validation:
|
|
240
|
+
|
|
241
|
+
- Gate **221/221** through the shipped `ParsanolEngine` — every
|
|
242
|
+
accept/reject/round-trip case identical to parslet, including the
|
|
243
|
+
optimizer-acceptance semantics pinned upstream in 1.3.50.
|
|
244
|
+
- The compat surface our engine rides (`Parsanol::Parslet::Parser`)
|
|
245
|
+
now resolves through `Parsanol::Parser` — renamed/absorbed
|
|
246
|
+
upstream during the 1.3.3x-4x line; our subclassing works
|
|
247
|
+
unchanged (the gate is the proof) and needs no engine-side change.
|
|
248
|
+
- **Perf: ratio-only** (machine at load ~175 during measurement —
|
|
249
|
+
the parslet control measured 7-9 i/s against its quiet ~75):
|
|
250
|
+
parsanol **2.6-3.2x parslet** across two same-process runs
|
|
251
|
+
(22.8/7.2, 24.3/9.4), consistent with re-checks 8/9.
|
|
252
|
+
- The unpublished v1.0.1 draft noted in re-check 9 is still
|
|
253
|
+
untagged/unpublished; nothing to validate there yet.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# PARG gate + benchmark: AsciiChem::Engine.use(:parg) parses through
|
|
4
|
+
# the compiled PARG artifact vendored from asciichem-parg, adapted to
|
|
5
|
+
# the shared TransformRules. (1) gates on the shared corpus, forked
|
|
6
|
+
# per case so a native crash aborts the child — reported, not fatal;
|
|
7
|
+
# (2) gates on capture completeness (a grammar edit leaving a
|
|
8
|
+
# trailing literal uncaptured must fail loudly — the runtime itself
|
|
9
|
+
# is parslet-strict about end of input); (3) measures against the
|
|
10
|
+
# parslet path. The model-equality gate against the reference engine lives in
|
|
11
|
+
# spec/asciichem/engine/parg_engine_spec.rb and the full suite green
|
|
12
|
+
# under ASCIICHEM_ENGINE=parg is the L2 contract.
|
|
13
|
+
#
|
|
14
|
+
# bundle exec ruby benchmarks/parg_recheck.rb
|
|
15
|
+
require "benchmark/ips"
|
|
16
|
+
require "asciichem"
|
|
17
|
+
require "asciichem/engine/parg_engine"
|
|
18
|
+
|
|
19
|
+
def run_in_child
|
|
20
|
+
reader, writer = IO.pipe
|
|
21
|
+
pid = fork do
|
|
22
|
+
reader.close
|
|
23
|
+
Marshal.dump(yield, writer)
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
Marshal.dump({ exception: e.class.name, message: e.message }, writer)
|
|
26
|
+
ensure
|
|
27
|
+
writer.close
|
|
28
|
+
end
|
|
29
|
+
writer.close
|
|
30
|
+
payload = Marshal.load(reader)
|
|
31
|
+
reader.close
|
|
32
|
+
_, status = Process.waitpid2(pid)
|
|
33
|
+
[payload, status]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# -- 1. Capture-completeness gate --------------------------------------
|
|
37
|
+
# Tails the grammar cannot capture must reject, as in the reference.
|
|
38
|
+
EOS_CASES = { "A(oh)GARBAGE" => false, "H_2O extra" => false,
|
|
39
|
+
"Ca(OH)_2 junk" => false, "H_2O" => true }.freeze
|
|
40
|
+
|
|
41
|
+
AsciiChem::Engine.use(:parg)
|
|
42
|
+
puts "parg engine | artifact #{AsciiChem::Engine::PargEngine.artifact.version} " \
|
|
43
|
+
"(parsanol #{Parsanol::VERSION})"
|
|
44
|
+
|
|
45
|
+
eos_fail = 0
|
|
46
|
+
EOS_CASES.each do |input, should_parse|
|
|
47
|
+
payload, status = run_in_child do
|
|
48
|
+
begin
|
|
49
|
+
{ text: AsciiChem.parse(input).to_text }
|
|
50
|
+
rescue AsciiChem::ParseError => e
|
|
51
|
+
{ error: e.message }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
accepted = status.success? && payload.key?(:text)
|
|
55
|
+
next if accepted == should_parse
|
|
56
|
+
|
|
57
|
+
eos_fail += 1
|
|
58
|
+
puts " EOS FAIL: #{input.inspect} accepted=#{accepted} expected=#{should_parse}"
|
|
59
|
+
end
|
|
60
|
+
puts format("capture-completeness gate: %d/%d ok", EOS_CASES.length - eos_fail, EOS_CASES.length)
|
|
61
|
+
|
|
62
|
+
# -- 2. Shared-corpus gate ---------------------------------------------
|
|
63
|
+
corpus_dir = File.expand_path("../../asciichem-tests/corpus/fixtures", __dir__)
|
|
64
|
+
unless File.directory?(corpus_dir)
|
|
65
|
+
warn "corpus not found at #{corpus_dir} — clone asciichem-tests alongside"
|
|
66
|
+
exit 1
|
|
67
|
+
end
|
|
68
|
+
cases = Dir[File.join(corpus_dir, "*.json")].sort.flat_map { |p| JSON.parse(File.read(p)) }
|
|
69
|
+
parser_cases = cases.select { |c| c.key?("input") && !c.key?("lint") && !c.key?("convention") }
|
|
70
|
+
|
|
71
|
+
pass = fail_parse = fail_reject = fail_roundtrip = fatal = 0
|
|
72
|
+
parser_cases.each do |fixture|
|
|
73
|
+
input = fixture.fetch("input")
|
|
74
|
+
payload, status = run_in_child do
|
|
75
|
+
formula = AsciiChem.parse(input)
|
|
76
|
+
{ text: (formula.to_text if fixture["roundTrip"]) }
|
|
77
|
+
end
|
|
78
|
+
if status.signaled? || !status.success?
|
|
79
|
+
fatal += 1
|
|
80
|
+
puts " FATAL (child #{status.exitstatus ? "exit #{status.exitstatus}" : "aborted"}): #{fixture["id"]} #{input.inspect}" if fatal <= 8
|
|
81
|
+
next
|
|
82
|
+
end
|
|
83
|
+
if payload.key?(:exception)
|
|
84
|
+
if fixture.fetch("parses")
|
|
85
|
+
fail_parse += 1
|
|
86
|
+
puts " PARSE FAIL: #{input.inspect} -> #{payload[:message][0, 90]}" if fail_parse <= 8
|
|
87
|
+
else
|
|
88
|
+
pass += 1
|
|
89
|
+
end
|
|
90
|
+
next
|
|
91
|
+
end
|
|
92
|
+
unless fixture.fetch("parses")
|
|
93
|
+
fail_reject += 1
|
|
94
|
+
puts " SHOULD REJECT: #{input.inspect}" if fail_reject <= 8
|
|
95
|
+
next
|
|
96
|
+
end
|
|
97
|
+
if fixture["roundTrip"] && payload[:text] != input
|
|
98
|
+
fail_roundtrip += 1
|
|
99
|
+
puts " ROUNDTRIP DIFF: #{input.inspect} -> #{payload[:text].inspect}" if fail_roundtrip <= 5
|
|
100
|
+
next
|
|
101
|
+
end
|
|
102
|
+
pass += 1
|
|
103
|
+
end
|
|
104
|
+
total = parser_cases.length
|
|
105
|
+
puts format("corpus gate: %d/%d ok (parse-fails %d, should-reject %d, roundtrip-diffs %d, fatal %d)",
|
|
106
|
+
pass, total, fail_parse, fail_reject, fail_roundtrip, fatal)
|
|
107
|
+
|
|
108
|
+
# -- 3. Performance ----------------------------------------------------
|
|
109
|
+
WORKLOAD = [
|
|
110
|
+
"H_2O", "Ca^2+", "SO_4^2-", "(R)-CH_3CH(OH)COOH",
|
|
111
|
+
"2H_2 + O_2 -> 2H_2O", "N_2 + 3H_2 <=>[Fe][400C] 2NH_3",
|
|
112
|
+
"C1-C-C-C-C-C1", "CH_3-CH_2-OH",
|
|
113
|
+
'^14C @name("carbon-14") @cas("14104-86-4")',
|
|
114
|
+
"A ->[heat] B ->[cool] C"
|
|
115
|
+
].freeze
|
|
116
|
+
|
|
117
|
+
Benchmark.ips do |x|
|
|
118
|
+
x.report("parg parse x10") { WORKLOAD.each { |s| AsciiChem.parse(s) } }
|
|
119
|
+
x.compare!
|
|
120
|
+
end
|
|
@@ -213,18 +213,23 @@ module AsciiChem
|
|
|
213
213
|
def self.splice_group_into(nodes, target_nodes, record)
|
|
214
214
|
return if target_nodes.empty?
|
|
215
215
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
216
|
+
inner_nodes = group_nodes(nodes, target_nodes)
|
|
217
|
+
|
|
218
|
+
positions = target_nodes
|
|
219
|
+
.map { |target| position_of(nodes, target) }
|
|
220
|
+
.compact
|
|
220
221
|
return if positions.empty?
|
|
221
222
|
|
|
222
223
|
group = AsciiChem::Model::Group.new(
|
|
223
|
-
nodes:
|
|
224
|
+
nodes: inner_nodes,
|
|
224
225
|
multiplicity: record[:multiplicity],
|
|
225
226
|
bracket: record[:bracket]
|
|
226
227
|
)
|
|
228
|
+
replace_positions_with_group(nodes, group, positions)
|
|
229
|
+
end
|
|
230
|
+
private_class_method :splice_group_into
|
|
227
231
|
|
|
232
|
+
def self.replace_positions_with_group(nodes, group, positions)
|
|
228
233
|
first_pos = positions.min
|
|
229
234
|
nodes[first_pos] = group
|
|
230
235
|
# Remove remaining positions in descending order so earlier
|
|
@@ -233,7 +238,31 @@ module AsciiChem
|
|
|
233
238
|
nodes.delete_at(pos)
|
|
234
239
|
end
|
|
235
240
|
end
|
|
236
|
-
private_class_method :
|
|
241
|
+
private_class_method :replace_positions_with_group
|
|
242
|
+
|
|
243
|
+
# A Bond sitting immediately before the group's first target node
|
|
244
|
+
# is the group's attachment bond — the `=` of `C(=O)`. The
|
|
245
|
+
# grammar keeps that bond INSIDE the group (its inner molecule
|
|
246
|
+
# starts with the bond), so the rebuilder moves it in instead of
|
|
247
|
+
# leaving a stray prefix bond outside, which would re-spell as
|
|
248
|
+
# `C=(O)`. By construction of the bond insertion (each bond sits
|
|
249
|
+
# just before its later endpoint), a bond adjacent to the group's
|
|
250
|
+
# first atom always has that atom as its later endpoint.
|
|
251
|
+
def self.group_nodes(nodes, target_nodes)
|
|
252
|
+
idx = position_of(nodes, target_nodes.first)
|
|
253
|
+
bond = idx&.positive? ? nodes[idx - 1] : nil
|
|
254
|
+
return target_nodes unless bond.is_a?(AsciiChem::Model::Bond)
|
|
255
|
+
|
|
256
|
+
[nodes.delete_at(idx - 1), *target_nodes]
|
|
257
|
+
end
|
|
258
|
+
private_class_method :group_nodes
|
|
259
|
+
|
|
260
|
+
# Identity-based position lookup so duplicate Bonds (same kind
|
|
261
|
+
# → `==` equal) aren't confused.
|
|
262
|
+
def self.position_of(nodes, node)
|
|
263
|
+
nodes.each_with_index.find { |candidate, _| candidate.equal?(node) }&.last
|
|
264
|
+
end
|
|
265
|
+
private_class_method :position_of
|
|
237
266
|
|
|
238
267
|
# Flatten the canonical document's molecules (top-level +
|
|
239
268
|
# reaction reactants/products + cascade reactions) into a list,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"bindings":null,"version":"0.3.1","grammar":"AsciiChemCore","shape":"parsanol-tree/v2","binding_version":1,"entries":{"formula":{"root":"formula","grammar":{"atoms":[{"Entity":{"atom":5}},{"Str":{"pattern":" "}},{"Re":{"pattern":"[\\x09]"}},{"Re":{"pattern":"[\\x0a]"}},{"Re":{"pattern":"[\\x0d]"}},{"Alternative":{"atoms":[1,2,3,4]}},{"Repetition":{"atom":0,"min":1,"max":null,"tag":"Repetition"}},{"Repetition":{"atom":6,"min":0,"max":1,"tag":"Maybe"}},{"Entity":{"atom":640}},{"Entity":{"atom":426}},{"Entity":{"atom":412}},{"Entity":{"atom":409}},{"Entity":{"atom":408}},{"Entity":{"atom":30}},{"Str":{"pattern":"("}},{"Entity":{"atom":26}},{"Str":{"pattern":"alpha"}},{"Str":{"pattern":"beta"}},{"Str":{"pattern":"R"}},{"Str":{"pattern":"S"}},{"Str":{"pattern":"E"}},{"Str":{"pattern":"Z"}},{"Str":{"pattern":"α"}},{"Str":{"pattern":"β"}},{"Str":{"pattern":"a"}},{"Str":{"pattern":"b"}},{"Alternative":{"atoms":[16,17,18,19,20,21,22,23,24,25]}},{"Named":{"name":"stereo","atom":15}},{"Str":{"pattern":")"}},{"Str":{"pattern":"-"}},{"Sequence":{"atoms":[14,27,28,29]}},{"Repetition":{"atom":13,"min":0,"max":1,"tag":"Maybe"}},{"Entity":{"atom":43}},{"Re":{"pattern":"[\\x30-\\x39]"}},{"Repetition":{"atom":33,"min":1,"max":null,"tag":"Repetition"}},{"Str":{"pattern":"("}},{"Str":{"pattern":"["}},{"Str":{"pattern":"{"}},{"Re":{"pattern":"[\\x41-\\x5a]"}},{"Alternative":{"atoms":[35,36,37,38]}},{"Lookahead":{"atom":39,"positive":true}},{"Sequence":{"atoms":[34,40]}},{"Named":{"name":"value","atom":41}},{"Named":{"name":"coefficient","atom":42}},{"Repetition":{"atom":32,"min":0,"max":1,"tag":"Maybe"}},{"Entity":{"atom":403}},{"Entity":{"atom":209}},{"Entity":{"atom":50}},{"Str":{"pattern":":"}},{"Repetition":{"atom":48,"min":1,"max":null,"tag":"Repetition"}},{"Named":{"name":"lone_pairs","atom":49}},{"Repetition":{"atom":47,"min":0,"max":1,"tag":"Maybe"}},{"Str":{"pattern":"^"}},{"Str":{"pattern":"_"}},{"Alternative":{"atoms":[52,53]}},{"Re":{"pattern":"[\\x30-\\x39]"}},{"Repetition":{"atom":55,"min":1,"max":null,"tag":"Repetition"}},{"Sequence":{"atoms":[54,56]}},{"Named":{"name":"isotope","atom":57}},{"Re":{"pattern":"[\\x41-\\x5a]"}},{"Re":{"pattern":"[\\x61-\\x7a]"}},{"Repetition":{"atom":60,"min":0,"max":1,"tag":"Maybe"}},{"Sequence":{"atoms":[59,61]}},{"Named":{"name":"element","atom":62}},{"Entity":{"atom":124}},{"Str":{"pattern":"_"}},{"Entity":{"atom":77}},{"Str":{"pattern":"{"}},{"Str":{"pattern":"}"}},{"Lookahead":{"atom":68,"positive":false}},{"Re":{"pattern":"[\\x00-\\u{10ffff}]"}},{"Sequence":{"atoms":[69,70]}},{"Repetition":{"atom":71,"min":0,"max":null,"tag":"Repetition"}},{"Str":{"pattern":"}"}},{"Sequence":{"atoms":[67,72,73]}},{"Re":{"pattern":"[\\x30-\\x39]"}},{"Repetition":{"atom":75,"min":1,"max":null,"tag":"Repetition"}},{"Alternative":{"atoms":[74,76]}},{"Sequence":{"atoms":[65,66]}},{"Named":{"name":"subscript","atom":78}},{"Repetition":{"atom":79,"min":0,"max":1,"tag":"Maybe"}},{"Str":{"pattern":"^"}},{"Entity":{"atom":120}},{"Str":{"pattern":"{"}},{"Str":{"pattern":"}"}},{"Lookahead":{"atom":84,"positive":false}},{"Re":{"pattern":"[\\x00-\\u{10ffff}]"}},{"Sequence":{"atoms":[85,86]}},{"Repetition":{"atom":87,"min":0,"max":null,"tag":"Repetition"}},{"Str":{"pattern":"}"}},{"Sequence":{"atoms":[83,88,89]}},{"Entity":{"atom":102}},{"Str":{"pattern":"("}},{"Re":{"pattern":"[I]"}},{"Re":{"pattern":"[V]"}},{"Re":{"pattern":"[X]"}},{"Re":{"pattern":"[L]"}},{"Re":{"pattern":"[C]"}},{"Re":{"pattern":"[D]"}},{"Alternative":{"atoms":[93,94,95,96,97,98]}},{"Repetition":{"atom":99,"min":1,"max":null,"tag":"Repetition"}},{"Str":{"pattern":")"}},{"Sequence":{"atoms":[92,100,101]}},{"Entity":{"atom":118}},{"Re":{"pattern":"[\\x30-\\x39]"}},{"Repetition":{"atom":104,"min":1,"max":null,"tag":"Repetition"}},{"Str":{"pattern":"+"}},{"Str":{"pattern":"-"}},{"Alternative":{"atoms":[106,107]}},{"Repetition":{"atom":108,"min":0,"max":1,"tag":"Maybe"}},{"Sequence":{"atoms":[105,109]}},{"Str":{"pattern":"+"}},{"Str":{"pattern":"-"}},{"Alternative":{"atoms":[111,112]}},{"Re":{"pattern":"[\\x30-\\x39]"}},{"Repetition":{"atom":114,"min":1,"max":null,"tag":"Repetition"}},{"Repetition":{"atom":115,"min":0,"max":1,"tag":"Maybe"}},{"Sequence":{"atoms":[113,116]}},{"Alternative":{"atoms":[110,117]}},{"Alternative":{"atoms":[91,103]}},{"Alternative":{"atoms":[90,119]}},{"Sequence":{"atoms":[81,82]}},{"Named":{"name":"superscript","atom":121}},{"Repetition":{"atom":122,"min":0,"max":1,"tag":"Maybe"}},{"Sequence":{"atoms":[80,123]}},{"Entity":{"atom":128}},{"Str":{"pattern":"."}},{"Repetition":{"atom":126,"min":1,"max":null,"tag":"Repetition"}},{"Named":{"name":"radical_electrons","atom":127}},{"Repetition":{"atom":125,"min":0,"max":1,"tag":"Maybe"}},{"Re":{"pattern":"[\\x30-\\x39]"}},{"Repetition":{"atom":130,"min":1,"max":null,"tag":"Repetition"}},{"Named":{"name":"ring_closures","atom":131}},{"Repetition":{"atom":132,"min":0,"max":1,"tag":"Maybe"}},{"Entity":{"atom":207}},{"Entity":{"atom":159}},{"Str":{"pattern":"@("}},{"Entity":{"atom":147}},{"Str":{"pattern":"-"}},{"Repetition":{"atom":138,"min":0,"max":1,"tag":"Maybe"}},{"Re":{"pattern":"[\\x30-\\x39]"}},{"Repetition":{"atom":140,"min":1,"max":null,"tag":"Repetition"}},{"Str":{"pattern":"."}},{"Re":{"pattern":"[\\x30-\\x39]"}},{"Repetition":{"atom":143,"min":0,"max":null,"tag":"Repetition"}},{"Sequence":{"atoms":[142,144]}},{"Repetition":{"atom":145,"min":0,"max":1,"tag":"Maybe"}},{"Sequence":{"atoms":[139,141,146]}},{"Named":{"name":"x2","atom":137}},{"Str":{"pattern":","}},{"Entity":{"atom":147}},{"Named":{"name":"y2","atom":150}},{"Str":{"pattern":","}},{"Entity":{"atom":147}},{"Named":{"name":"z2","atom":153}},{"Sequence":{"atoms":[152,154]}},{"Repetition":{"atom":155,"min":0,"max":1,"tag":"Maybe"}},{"Str":{"pattern":")"}},{"Named":{"name":"coord_close","atom":157}},{"Sequence":{"atoms":[136,148,149,151,156,158]}},{"Repetition":{"atom":135,"min":0,"max":1,"tag":"Maybe"}},{"Entity":{"atom":167}},{"Str":{"pattern":"@"}},{"Str":{"pattern":"R"}},{"Str":{"pattern":"S"}},{"Alternative":{"atoms":[163,164]}},{"Named":{"name":"atom_parity","atom":165}},{"Sequence":{"atoms":[162,166]}},{"Repetition":{"atom":161,"min":0,"max":1,"tag":"Maybe"}},{"Entity":{"atom":176}},{"Str":{"pattern":"@m("}},{"Re":{"pattern":"[\\x30-\\x39]"}},{"Repetition":{"atom":171,"min":1,"max":null,"tag":"Repetition"}},{"Named":{"name":"spin_multiplicity","atom":172}},{"Str":{"pattern":")"}},{"Named":{"name":"spin_close","atom":174}},{"Sequence":{"atoms":[170,173,175]}},{"Repetition":{"atom":169,"min":0,"max":1,"tag":"Maybe"}},{"Entity":{"atom":191}},{"Str":{"pattern":"@t("}},{"Str":{"pattern":"\""}},{"Str":{"pattern":"\""}},{"Lookahead":{"atom":181,"positive":false}},{"Re":{"pattern":"[\\x00-\\u{10ffff}]"}},{"Sequence":{"atoms":[182,183]}},{"Repetition":{"atom":184,"min":0,"max":null,"tag":"Repetition"}},{"Named":{"name":"atom_title","atom":185}},{"Str":{"pattern":"\""}},{"Named":{"name":"title_quote","atom":187}},{"Str":{"pattern":")"}},{"Named":{"name":"title_close","atom":189}},{"Sequence":{"atoms":[179,180,186,188,190]}},{"Repetition":{"atom":178,"min":0,"max":1,"tag":"Maybe"}},{"Entity":{"atom":205}},{"Str":{"pattern":"@f("}},{"Entity":{"atom":147}},{"Named":{"name":"x_fract","atom":195}},{"Str":{"pattern":","}},{"Entity":{"atom":147}},{"Named":{"name":"y_fract","atom":198}},{"Str":{"pattern":","}},{"Entity":{"atom":147}},{"Named":{"name":"z_fract","atom":201}},{"Str":{"pattern":")"}},{"Named":{"name":"fract_close","atom":203}},{"Sequence":{"atoms":[194,196,197,199,200,202,204]}},{"Repetition":{"atom":193,"min":0,"max":1,"tag":"Maybe"}},{"Sequence":{"atoms":[160,168,177,192,206]}},{"Sequence":{"atoms":[51,58,63,64,129,133,134]}},{"Named":{"name":"atom","atom":208}},{"Entity":{"atom":359}},{"Entity":{"atom":216}},{"Str":{"pattern":"("}},{"Str":{"pattern":"["}},{"Str":{"pattern":"{"}},{"Alternative":{"atoms":[212,213,214]}},{"Named":{"name":"open_bracket","atom":215}},{"Entity":{"atom":355}},{"Entity":{"atom":292}},{"Named":{"name":"reactants","atom":11}},{"Entity":{"atom":249}},{"Entity":{"atom":5}},{"Repetition":{"atom":221,"min":1,"max":null,"tag":"Repetition"}},{"Repetition":{"atom":222,"min":0,"max":1,"tag":"Maybe"}},{"Entity":{"atom":229}},{"Str":{"pattern":"<=>"}},{"Str":{"pattern":"<->"}},{"Str":{"pattern":"->"}},{"Str":{"pattern":"<-"}},{"Alternative":{"atoms":[225,226,227,228]}},{"Named":{"name":"kind","atom":224}},{"Entity":{"atom":242}},{"Str":{"pattern":"["}},{"Str":{"pattern":"]"}},{"Lookahead":{"atom":233,"positive":false}},{"Re":{"pattern":"[\\x00-\\u{10ffff}]"}},{"Sequence":{"atoms":[234,235]}},{"Repetition":{"atom":236,"min":0,"max":null,"tag":"Repetition"}},{"Named":{"name":"text","atom":237}},{"Str":{"pattern":"]"}},{"Named":{"name":"condition_close","atom":239}},{"Sequence":{"atoms":[232,238,240]}},{"Named":{"name":"condition","atom":241}},{"Named":{"name":"above","atom":231}},{"Repetition":{"atom":243,"min":0,"max":1,"tag":"Maybe"}},{"Entity":{"atom":242}},{"Named":{"name":"below","atom":245}},{"Repetition":{"atom":246,"min":0,"max":1,"tag":"Maybe"}},{"Sequence":{"atoms":[223,230,244,247]}},{"Named":{"name":"arrow","atom":248}},{"Entity":{"atom":5}},{"Repetition":{"atom":250,"min":1,"max":null,"tag":"Repetition"}},{"Repetition":{"atom":251,"min":0,"max":1,"tag":"Maybe"}},{"Entity":{"atom":289}},{"Entity":{"atom":287}},{"Entity":{"atom":5}},{"Repetition":{"atom":255,"min":1,"max":null,"tag":"Repetition"}},{"Repetition":{"atom":256,"min":0,"max":1,"tag":"Maybe"}},{"Str":{"pattern":"+"}},{"Entity":{"atom":5}},{"Repetition":{"atom":259,"min":1,"max":null,"tag":"Repetition"}},{"Repetition":{"atom":260,"min":0,"max":1,"tag":"Maybe"}},{"Entity":{"atom":285}},{"Entity":{"atom":280}},{"Str":{"pattern":"##"}},{"Named":{"name":"quadruple","atom":264}},{"Str":{"pattern":">-"}},{"Named":{"name":"wedge","atom":266}},{"Str":{"pattern":"-<"}},{"Named":{"name":"hash","atom":268}},{"Str":{"pattern":"~>"}},{"Named":{"name":"dative","atom":270}},{"Str":{"pattern":"~~"}},{"Named":{"name":"wavy","atom":272}},{"Str":{"pattern":"#"}},{"Named":{"name":"triple","atom":274}},{"Str":{"pattern":"="}},{"Named":{"name":"double","atom":276}},{"Str":{"pattern":"-"}},{"Named":{"name":"single","atom":278}},{"Alternative":{"atoms":[265,267,269,271,273,275,277,279]}},{"Alternative":{"atoms":[45,263]}},{"Repetition":{"atom":281,"min":1,"max":null,"tag":"Repetition"}},{"Named":{"name":"units","atom":282}},{"Sequence":{"atoms":[31,44,283]}},{"Named":{"name":"molecule","atom":284}},{"Sequence":{"atoms":[257,258,261,262]}},{"Named":{"name":"plus_term","atom":286}},{"Repetition":{"atom":254,"min":0,"max":null,"tag":"Repetition"}},{"Sequence":{"atoms":[12,288]}},{"Named":{"name":"products","atom":253}},{"Sequence":{"atoms":[219,220,252,290]}},{"Named":{"name":"reaction","atom":291}},{"Entity":{"atom":315}},{"Entity":{"atom":305}},{"Re":{"pattern":"[\\x30-\\x39]"}},{"Repetition":{"atom":295,"min":1,"max":null,"tag":"Repetition"}},{"Str":{"pattern":"s"}},{"Str":{"pattern":"p"}},{"Str":{"pattern":"d"}},{"Str":{"pattern":"f"}},{"Str":{"pattern":"g"}},{"Str":{"pattern":"h"}},{"Alternative":{"atoms":[297,298,299,300,301,302]}},{"Sequence":{"atoms":[296,303]}},{"Named":{"name":"orbital","atom":304}},{"Str":{"pattern":"^"}},{"Re":{"pattern":"[\\x30-\\x39]"}},{"Repetition":{"atom":307,"min":1,"max":null,"tag":"Repetition"}},{"Named":{"name":"occupancy","atom":308}},{"Entity":{"atom":5}},{"Repetition":{"atom":310,"min":1,"max":null,"tag":"Repetition"}},{"Repetition":{"atom":311,"min":0,"max":1,"tag":"Maybe"}},{"Sequence":{"atoms":[294,306,309,312]}},{"Repetition":{"atom":313,"min":2,"max":null,"tag":"Repetition"}},{"Named":{"name":"electron_config","atom":314}},{"Entity":{"atom":285}},{"Entity":{"atom":333}},{"Repetition":{"atom":217,"min":1,"max":null,"tag":"Repetition"}},{"Named":{"name":"group_nodes","atom":318}},{"Entity":{"atom":325}},{"Str":{"pattern":")"}},{"Str":{"pattern":"]"}},{"Str":{"pattern":"}"}},{"Alternative":{"atoms":[321,322,323]}},{"Named":{"name":"close_bracket","atom":324}},{"Str":{"pattern":"_"}},{"Re":{"pattern":"[\\x30-\\x39]"}},{"Repetition":{"atom":327,"min":1,"max":null,"tag":"Repetition"}},{"Sequence":{"atoms":[326,328]}},{"Named":{"name":"multiplicity","atom":329}},{"Repetition":{"atom":330,"min":0,"max":1,"tag":"Maybe"}},{"Sequence":{"atoms":[211,319,320,331]}},{"Named":{"name":"group","atom":332}},{"Entity":{"atom":345}},{"Str":{"pattern":"`"}},{"Str":{"pattern":"`"}},{"Lookahead":{"atom":336,"positive":false}},{"Re":{"pattern":"[\\x00-\\u{10ffff}]"}},{"Sequence":{"atoms":[337,338]}},{"Repetition":{"atom":339,"min":0,"max":null,"tag":"Repetition"}},{"Named":{"name":"math_source","atom":340}},{"Str":{"pattern":"`"}},{"Named":{"name":"math_close","atom":342}},{"Sequence":{"atoms":[335,341,343]}},{"Named":{"name":"embedded_math","atom":344}},{"Str":{"pattern":"\""}},{"Str":{"pattern":"\""}},{"Lookahead":{"atom":347,"positive":false}},{"Re":{"pattern":"[\\x00-\\u{10ffff}]"}},{"Sequence":{"atoms":[348,349]}},{"Repetition":{"atom":350,"min":0,"max":null,"tag":"Repetition"}},{"Str":{"pattern":"\""}},{"Sequence":{"atoms":[346,351,352]}},{"Named":{"name":"group_text_run","atom":353}},{"Alternative":{"atoms":[218,293,316,317,334,354]}},{"Repetition":{"atom":217,"min":1,"max":null,"tag":"Repetition"}},{"Named":{"name":"group_nodes","atom":356}},{"Sequence":{"atoms":[211,357,320,331]}},{"Named":{"name":"group","atom":358}},{"Entity":{"atom":384}},{"Entity":{"atom":50}},{"Repetition":{"atom":361,"min":0,"max":1,"tag":"Maybe"}},{"Str":{"pattern":"H"}},{"Named":{"name":"element","atom":363}},{"Re":{"pattern":"[\\x61-\\x7a]"}},{"Lookahead":{"atom":365,"positive":false}},{"Str":{"pattern":"_"}},{"Entity":{"atom":77}},{"Sequence":{"atoms":[367,368]}},{"Re":{"pattern":"[\\x30-\\x39]"}},{"Repetition":{"atom":370,"min":1,"max":null,"tag":"Repetition"}},{"Alternative":{"atoms":[369,371]}},{"Named":{"name":"subscript","atom":372}},{"Repetition":{"atom":373,"min":0,"max":1,"tag":"Maybe"}},{"Str":{"pattern":"^"}},{"Entity":{"atom":120}},{"Sequence":{"atoms":[375,376]}},{"Named":{"name":"superscript","atom":377}},{"Repetition":{"atom":378,"min":0,"max":1,"tag":"Maybe"}},{"Entity":{"atom":128}},{"Repetition":{"atom":380,"min":0,"max":1,"tag":"Maybe"}},{"Entity":{"atom":207}},{"Sequence":{"atoms":[362,364,366,374,379,381,382]}},{"Named":{"name":"atom","atom":383}},{"Entity":{"atom":402}},{"Entity":{"atom":50}},{"Repetition":{"atom":386,"min":0,"max":1,"tag":"Maybe"}},{"Re":{"pattern":"[\\x41-\\x5a]"}},{"Re":{"pattern":"[\\x61-\\x7a]"}},{"Repetition":{"atom":389,"min":0,"max":1,"tag":"Maybe"}},{"Sequence":{"atoms":[388,390]}},{"Named":{"name":"element","atom":391}},{"Entity":{"atom":124}},{"Entity":{"atom":128}},{"Repetition":{"atom":394,"min":0,"max":1,"tag":"Maybe"}},{"Re":{"pattern":"[\\x30-\\x39]"}},{"Repetition":{"atom":396,"min":1,"max":null,"tag":"Repetition"}},{"Named":{"name":"ring_closures","atom":397}},{"Repetition":{"atom":398,"min":0,"max":1,"tag":"Maybe"}},{"Entity":{"atom":207}},{"Sequence":{"atoms":[387,392,393,395,399,400]}},{"Named":{"name":"atom","atom":401}},{"Alternative":{"atoms":[46,210,360,385]}},{"Alternative":{"atoms":[45,263]}},{"Repetition":{"atom":404,"min":1,"max":null,"tag":"Repetition"}},{"Named":{"name":"units","atom":405}},{"Sequence":{"atoms":[31,44,406]}},{"Named":{"name":"molecule","atom":407}},{"Sequence":{"atoms":[12,288]}},{"Named":{"name":"reactants","atom":11}},{"Sequence":{"atoms":[410,220,252,290]}},{"Named":{"name":"reaction","atom":411}},{"Named":{"name":"first","atom":10}},{"Entity":{"atom":422}},{"Entity":{"atom":249}},{"Entity":{"atom":5}},{"Repetition":{"atom":416,"min":1,"max":null,"tag":"Repetition"}},{"Repetition":{"atom":417,"min":0,"max":1,"tag":"Maybe"}},{"Entity":{"atom":409}},{"Named":{"name":"products","atom":419}},{"Sequence":{"atoms":[415,418,420]}},{"Named":{"name":"segment","atom":421}},{"Repetition":{"atom":414,"min":1,"max":null,"tag":"Repetition"}},{"Named":{"name":"segments","atom":423}},{"Sequence":{"atoms":[413,424]}},{"Named":{"name":"cascade","atom":425}},{"Entity":{"atom":412}},{"Entity":{"atom":315}},{"Entity":{"atom":465}},{"Str":{"pattern":"crystal"}},{"Str":{"pattern":"["}},{"Str":{"pattern":"]"}},{"Lookahead":{"atom":432,"positive":false}},{"Re":{"pattern":"[\\x00-\\u{10ffff}]"}},{"Sequence":{"atoms":[433,434]}},{"Repetition":{"atom":435,"min":0,"max":null,"tag":"Repetition"}},{"Named":{"name":"crystal_name","atom":436}},{"Str":{"pattern":"]"}},{"Named":{"name":"crystal_name_close","atom":438}},{"Sequence":{"atoms":[431,437,439]}},{"Repetition":{"atom":440,"min":0,"max":1,"tag":"Maybe"}},{"Str":{"pattern":"("}},{"Str":{"pattern":")"}},{"Lookahead":{"atom":443,"positive":false}},{"Re":{"pattern":"[\\x00-\\u{10ffff}]"}},{"Sequence":{"atoms":[444,445]}},{"Repetition":{"atom":446,"min":0,"max":null,"tag":"Repetition"}},{"Named":{"name":"crystal_params","atom":447}},{"Str":{"pattern":")"}},{"Named":{"name":"crystal_params_close","atom":449}},{"Sequence":{"atoms":[442,448,450]}},{"Repetition":{"atom":451,"min":0,"max":1,"tag":"Maybe"}},{"Str":{"pattern":"{"}},{"Str":{"pattern":"}"}},{"Lookahead":{"atom":454,"positive":false}},{"Re":{"pattern":"[\\x00-\\u{10ffff}]"}},{"Sequence":{"atoms":[455,456]}},{"Repetition":{"atom":457,"min":0,"max":null,"tag":"Repetition"}},{"Named":{"name":"crystal_body","atom":458}},{"Str":{"pattern":"}"}},{"Named":{"name":"crystal_body_close","atom":460}},{"Sequence":{"atoms":[453,459,461]}},{"Repetition":{"atom":462,"min":0,"max":1,"tag":"Maybe"}},{"Sequence":{"atoms":[430,441,452,463]}},{"Named":{"name":"crystal_node","atom":464}},{"Entity":{"atom":502}},{"Str":{"pattern":"spectrum"}},{"Str":{"pattern":"["}},{"Str":{"pattern":"]"}},{"Lookahead":{"atom":469,"positive":false}},{"Re":{"pattern":"[\\x00-\\u{10ffff}]"}},{"Sequence":{"atoms":[470,471]}},{"Repetition":{"atom":472,"min":0,"max":null,"tag":"Repetition"}},{"Named":{"name":"spectrum_type","atom":473}},{"Str":{"pattern":"]"}},{"Named":{"name":"spectrum_type_close","atom":475}},{"Sequence":{"atoms":[468,474,476]}},{"Repetition":{"atom":477,"min":0,"max":1,"tag":"Maybe"}},{"Str":{"pattern":"("}},{"Str":{"pattern":")"}},{"Lookahead":{"atom":480,"positive":false}},{"Re":{"pattern":"[\\x00-\\u{10ffff}]"}},{"Sequence":{"atoms":[481,482]}},{"Repetition":{"atom":483,"min":0,"max":null,"tag":"Repetition"}},{"Named":{"name":"spectrum_params","atom":484}},{"Str":{"pattern":")"}},{"Named":{"name":"spectrum_params_close","atom":486}},{"Sequence":{"atoms":[479,485,487]}},{"Repetition":{"atom":488,"min":0,"max":1,"tag":"Maybe"}},{"Str":{"pattern":"{"}},{"Str":{"pattern":"}"}},{"Lookahead":{"atom":491,"positive":false}},{"Re":{"pattern":"[\\x00-\\u{10ffff}]"}},{"Sequence":{"atoms":[492,493]}},{"Repetition":{"atom":494,"min":0,"max":null,"tag":"Repetition"}},{"Named":{"name":"spectrum_body","atom":495}},{"Str":{"pattern":"}"}},{"Named":{"name":"spectrum_body_close","atom":497}},{"Sequence":{"atoms":[490,496,498]}},{"Repetition":{"atom":499,"min":0,"max":1,"tag":"Maybe"}},{"Sequence":{"atoms":[467,478,489,500]}},{"Named":{"name":"spectrum_node","atom":501}},{"Entity":{"atom":528}},{"Str":{"pattern":"calc"}},{"Str":{"pattern":"("}},{"Str":{"pattern":")"}},{"Lookahead":{"atom":506,"positive":false}},{"Re":{"pattern":"[\\x00-\\u{10ffff}]"}},{"Sequence":{"atoms":[507,508]}},{"Repetition":{"atom":509,"min":0,"max":null,"tag":"Repetition"}},{"Named":{"name":"calc_params","atom":510}},{"Str":{"pattern":")"}},{"Named":{"name":"calc_params_close","atom":512}},{"Sequence":{"atoms":[505,511,513]}},{"Repetition":{"atom":514,"min":0,"max":1,"tag":"Maybe"}},{"Str":{"pattern":"{"}},{"Str":{"pattern":"}"}},{"Lookahead":{"atom":517,"positive":false}},{"Re":{"pattern":"[\\x00-\\u{10ffff}]"}},{"Sequence":{"atoms":[518,519]}},{"Repetition":{"atom":520,"min":0,"max":null,"tag":"Repetition"}},{"Named":{"name":"calc_body","atom":521}},{"Str":{"pattern":"}"}},{"Named":{"name":"calc_body_close","atom":523}},{"Sequence":{"atoms":[516,522,524]}},{"Repetition":{"atom":525,"min":0,"max":1,"tag":"Maybe"}},{"Sequence":{"atoms":[504,515,526]}},{"Named":{"name":"calc_node","atom":527}},{"Entity":{"atom":543}},{"Str":{"pattern":"zmatrix"}},{"Str":{"pattern":"{"}},{"Str":{"pattern":"}"}},{"Lookahead":{"atom":532,"positive":false}},{"Re":{"pattern":"[\\x00-\\u{10ffff}]"}},{"Sequence":{"atoms":[533,534]}},{"Repetition":{"atom":535,"min":0,"max":null,"tag":"Repetition"}},{"Named":{"name":"zmatrix_body","atom":536}},{"Str":{"pattern":"}"}},{"Named":{"name":"zmatrix_body_close","atom":538}},{"Sequence":{"atoms":[531,537,539]}},{"Repetition":{"atom":540,"min":0,"max":1,"tag":"Maybe"}},{"Sequence":{"atoms":[530,541]}},{"Named":{"name":"zmatrix_node","atom":542}},{"Entity":{"atom":558}},{"Str":{"pattern":"mechanism"}},{"Str":{"pattern":"{"}},{"Str":{"pattern":"}"}},{"Lookahead":{"atom":547,"positive":false}},{"Re":{"pattern":"[\\x00-\\u{10ffff}]"}},{"Sequence":{"atoms":[548,549]}},{"Repetition":{"atom":550,"min":0,"max":null,"tag":"Repetition"}},{"Named":{"name":"mechanism_body","atom":551}},{"Str":{"pattern":"}"}},{"Named":{"name":"mechanism_body_close","atom":553}},{"Sequence":{"atoms":[546,552,554]}},{"Repetition":{"atom":555,"min":0,"max":1,"tag":"Maybe"}},{"Sequence":{"atoms":[545,556]}},{"Named":{"name":"mechanism_node","atom":557}},{"Entity":{"atom":628}},{"Entity":{"atom":408}},{"Named":{"name":"mol","atom":560}},{"Entity":{"atom":624}},{"Entity":{"atom":5}},{"Repetition":{"atom":563,"min":1,"max":null,"tag":"Repetition"}},{"Repetition":{"atom":564,"min":0,"max":1,"tag":"Maybe"}},{"Entity":{"atom":588}},{"Str":{"pattern":"@meta("}},{"Str":{"pattern":"\""}},{"Str":{"pattern":"\""}},{"Lookahead":{"atom":569,"positive":false}},{"Re":{"pattern":"[\\x00-\\u{10ffff}]"}},{"Sequence":{"atoms":[570,571]}},{"Repetition":{"atom":572,"min":0,"max":null,"tag":"Repetition"}},{"Named":{"name":"meta_key","atom":573}},{"Str":{"pattern":"\""}},{"Str":{"pattern":","}},{"Str":{"pattern":"\""}},{"Str":{"pattern":"\""}},{"Lookahead":{"atom":578,"positive":false}},{"Re":{"pattern":"[\\x00-\\u{10ffff}]"}},{"Sequence":{"atoms":[579,580]}},{"Repetition":{"atom":581,"min":0,"max":null,"tag":"Repetition"}},{"Named":{"name":"meta_value","atom":582}},{"Str":{"pattern":"\""}},{"Named":{"name":"meta_value_close","atom":584}},{"Str":{"pattern":")"}},{"Named":{"name":"meta_close","atom":586}},{"Sequence":{"atoms":[567,568,574,575,576,577,583,585,587]}},{"Entity":{"atom":621}},{"Str":{"pattern":"@"}},{"Entity":{"atom":602}},{"Str":{"pattern":"name"}},{"Str":{"pattern":"title"}},{"Str":{"pattern":"formula"}},{"Str":{"pattern":"label"}},{"Str":{"pattern":"inchi"}},{"Str":{"pattern":"smiles"}},{"Str":{"pattern":"cas"}},{"Str":{"pattern":"iupac"}},{"Str":{"pattern":"cid"}},{"Str":{"pattern":"chebi"}},{"Alternative":{"atoms":[592,593,594,595,596,597,598,599,600,601]}},{"Entity":{"atom":605}},{"Re":{"pattern":"[\\x61-\\x7a]"}},{"Repetition":{"atom":604,"min":1,"max":null,"tag":"Repetition"}},{"Alternative":{"atoms":[591,603]}},{"Named":{"name":"ann_type","atom":606}},{"Str":{"pattern":"("}},{"Str":{"pattern":"\""}},{"Str":{"pattern":"\""}},{"Lookahead":{"atom":610,"positive":false}},{"Re":{"pattern":"[\\x00-\\u{10ffff}]"}},{"Sequence":{"atoms":[611,612]}},{"Repetition":{"atom":613,"min":0,"max":null,"tag":"Repetition"}},{"Named":{"name":"ann_value","atom":614}},{"Str":{"pattern":"\""}},{"Named":{"name":"ann_value_close","atom":616}},{"Str":{"pattern":")"}},{"Named":{"name":"ann_close","atom":618}},{"Sequence":{"atoms":[607,608,609,615,617,619]}},{"Sequence":{"atoms":[590,620]}},{"Alternative":{"atoms":[566,589]}},{"Sequence":{"atoms":[565,622]}},{"Named":{"name":"annotation_item","atom":623}},{"Repetition":{"atom":562,"min":1,"max":null,"tag":"Repetition"}},{"Named":{"name":"annotations","atom":625}},{"Sequence":{"atoms":[561,626]}},{"Named":{"name":"annotated_molecule","atom":627}},{"Entity":{"atom":408}},{"Entity":{"atom":345}},{"Str":{"pattern":"\""}},{"Str":{"pattern":"\""}},{"Lookahead":{"atom":632,"positive":false}},{"Re":{"pattern":"[\\x00-\\u{10ffff}]"}},{"Sequence":{"atoms":[633,634]}},{"Repetition":{"atom":635,"min":0,"max":null,"tag":"Repetition"}},{"Str":{"pattern":"\""}},{"Sequence":{"atoms":[631,636,637]}},{"Named":{"name":"text_run","atom":638}},{"Alternative":{"atoms":[9,427,428,429,466,503,529,544,559,629,630,639]}},{"Entity":{"atom":5}},{"Repetition":{"atom":641,"min":1,"max":null,"tag":"Repetition"}},{"Repetition":{"atom":642,"min":0,"max":1,"tag":"Maybe"}},{"Entity":{"atom":640}},{"Sequence":{"atoms":[643,644]}},{"Repetition":{"atom":645,"min":0,"max":null,"tag":"Repetition"}},{"Sequence":{"atoms":[8,646]}},{"Named":{"name":"formula","atom":647}},{"Entity":{"atom":5}},{"Repetition":{"atom":649,"min":1,"max":null,"tag":"Repetition"}},{"Repetition":{"atom":650,"min":0,"max":1,"tag":"Maybe"}},{"Sequence":{"atoms":[7,648,651]}}],"root":652},"bindings":[]}},"preprocess":{},"tables":{},"lint":{"order_warnings":["rule formula_node: branches 1 and 2 are order-dependent (shared first bytes; ordered choice is decisive)","rule formula_node: branches 1 and 3 are order-dependent (shared first bytes; ordered choice is decisive)","rule formula_node: branches 1 and 9 are order-dependent (shared first bytes; ordered choice is decisive)","rule formula_node: branches 1 and 10 are order-dependent (shared first bytes; ordered choice is decisive)","rule formula_node: branches 2 and 3 are order-dependent (shared first bytes; ordered choice is decisive)","rule formula_node: branches 2 and 9 are order-dependent (shared first bytes; ordered choice is decisive)","rule formula_node: branches 2 and 10 are order-dependent (shared first bytes; ordered choice is decisive)","rule formula_node: branches 3 and 9 are order-dependent (shared first bytes; ordered choice is decisive)","rule formula_node: branches 3 and 10 are order-dependent (shared first bytes; ordered choice is decisive)","rule formula_node: branches 4 and 6 are order-dependent (shared first bytes; ordered choice is decisive)","rule formula_node: branches 9 and 10 are order-dependent (shared first bytes; ordered choice is decisive)","rule arrow_kind: branches 1 and 2 are order-dependent (shared first bytes; ordered choice is decisive)","rule arrow_kind: branches 1 and 4 are order-dependent (shared first bytes; ordered choice is decisive)","rule arrow_kind: branches 2 and 4 are order-dependent (shared first bytes; ordered choice is decisive)","rule stereo_letter: branches 1 and 9 are order-dependent (shared first bytes; ordered choice is decisive)","rule stereo_letter: branches 2 and 10 are order-dependent (shared first bytes; ordered choice is decisive)","rule stereo_letter: branches 7 and 8 are order-dependent (shared first bytes; ordered choice is decisive)","rule unit: branches 1 and 3 are order-dependent (shared first bytes; ordered choice is decisive)","rule unit: branches 1 and 4 are order-dependent (shared first bytes; ordered choice is decisive)","rule unit: branches 3 and 4 are order-dependent (shared first bytes; ordered choice is decisive)","rule group_unit: branches 1 and 2 are order-dependent (shared first bytes; ordered choice is decisive)","rule group_unit: branches 1 and 3 are order-dependent (shared first bytes; ordered choice is decisive)","rule group_unit: branches 1 and 4 are order-dependent (shared first bytes; ordered choice is decisive)","rule group_unit: branches 2 and 3 are order-dependent (shared first bytes; ordered choice is decisive)","rule group_unit: branches 3 and 4 are order-dependent (shared first bytes; ordered choice is decisive)","rule bond: branches 1 and 6 are order-dependent (shared first bytes; ordered choice is decisive)","rule bond: branches 3 and 8 are order-dependent (shared first bytes; ordered choice is decisive)","rule bond: branches 4 and 5 are order-dependent (shared first bytes; ordered choice is decisive)","rule known_annotation_type: branches 5 and 8 are order-dependent (shared first bytes; ordered choice is decisive)","rule known_annotation_type: branches 7 and 9 are order-dependent (shared first bytes; ordered choice is decisive)","rule known_annotation_type: branches 7 and 10 are order-dependent (shared first bytes; ordered choice is decisive)","rule known_annotation_type: branches 9 and 10 are order-dependent (shared first bytes; ordered choice is decisive)"]},"default_entry":"formula","render":{},"derive":{},"tests":[],"docs":{"formula":"spaces? >> nodes.as(:formula) >> spaces?","formula_node":"Top-level nodes, DSL ordered-choice order (cascade before plain\nreaction, annotations before bare molecule).","cascade":"Two or more chained reactions: the transform promotes the chain\nto a ReactionCascade (first leg + arrow/products segments).","reaction":"Reactants, arrow (with optional above/below conditions), products.","arrow":"arrow.as(:arrow) {kind, above?, below?} — leading spaces ride the\narrow, conditions attach with no gap.","molecule":"A molecule: stereochemistry prefix, optional coefficient, units.","stereo_prefix":"`(R)-` style prefixes; a/b are the alpha/beta shorthand.","coefficient":"Coefficient: digits only when an element or bracket follows (else\nthe digits are subscripts/rings). Captured as {value}.","unit":"Units: prefixed (isotope) atoms, bracket groups, hydrogen\n(bare-digit subscripts allowed), plain atoms.","prefixed_atom":"Prefix isotope: ^14C or _14C — the marker binds to the atom, the\ncore semantic fix over AsciiMath. Captured with the marker.","group":"Bracket group: ( ... ) [ ... ] { ... } with optional multiplicity.","group_unit":"Inside a group: reactions, molecules, electron configs, embedded\nmath and quoted text recurse (DSL group_node set).","hydrogen_atom":"Hydrogen: bare digits after H are subscripts (H cannot ring-\nclose), but H must not steal the H of He, Hf...","atom_suffix":"Suffix markers consume `_` / `^` but capture marker + value\n(AtomBuilder strips the marker).","charge":"Charges: 2+, +2, 2 (bare digits fall through to the same capture).","oxidation_state":"Oxidation state: (IV), (II)...","lewis_prefix":"Lewis markers: leading colons = lone pairs; trailing dots =\nradical electrons.","atom_annotation":"Atom annotations, DSL order: coordinates, parity, multiplicity,\ntitle, fractional coordinates.","bond":"Bonds capture under their kind name (transform contract), longest\nfirst so `##` beats `#`; `->` is reserved for reactions.","crystal":"crystal[Name](a=X,b=Y,sg=SG){atoms with @f(x,y,z)}","spectrum":"spectrum[type](params){peak data}","calculation":"calc(method/basis){key: value units}","zmatrix":"zmatrix{lines}","mechanism":"mechanism{steps}","electron_config":"`1s2 2s2 2p6` — at least two orbital terms (DSL repeat(2)).","embedded_math":"Backtick-delimited Plurimath/AsciiMath source (delimiters stripped\nby the transform).","annotated_molecule":"Free-form text uses \"...\" delimiters, matching AsciiMath; the\ncapture includes the quotes (TextNormaliser strips them).\n@name(\"aspirin\") @cas(\"50-78-2\") @meta(\"k\",\"v\") — the annotation\nvocabulary rides the molecule.","metadata_annotation":"Two quoted args, comma-separated; keys distinguish metadata from\nregular annotations.","simple_annotation":"Known types (name, inchi, ...) handled specially by the transform;\nany other lowercase word becomes a property."},"source":"grammar AsciiChemCore version \"0.3.1\" {\n ## The AsciiChem syntax — full grammar, DSL-shaped captures\n ## (P1b, asciichem-ruby#96). Capture names and nesting mirror\n ## GrammarRules exactly so the PARG tree adapts to the same shape\n ## TransformRules consumes: sequence captures merge into one hash\n ## (parens to attach `as`), repetitions stay arrays, unwrapped\n ## rules let their captures ride into the parent context. Absent\n ## optionals omit their key (the tree adapter reinstates the ones\n ## transform patterns require, e.g. group multiplicity). Structural\n ## rules (molecule/reaction/group/annotated_molecule) are captured\n ## and the adapter unwraps them: PARG merge semantics for captured\n ## sequences with embedded repetitions yield arrays, and uncaptured\n ## sequence rules drop captures non-deterministically in reps\n ## (parsanol findings).\n ##\n ## Language notes (P0 findings): entries reference rules; sequences\n ## take no trailing capture (wrap in parens); [ ] is the option\n ## syntax so literal brackets are strings; until is exclusive\n ## (restate the closer); strict PEG — no repetition backtracking.\n\n entry formula: formula\n\n ## spaces? >> nodes.as(:formula) >> spaces?\n formula = [ 1*( ws ) ] ( formula_node 0*( [ 1*( ws ) ] formula_node ) ) as formula [ 1*( ws ) ]\n\n ws = ( \" \" / %x09 / %x0A / %x0D )\n\n ## Top-level nodes, DSL ordered-choice order (cascade before plain\n ## reaction, annotations before bare molecule).\n formula_node = cascade / reaction / electron_config / crystal / spectrum / calculation / zmatrix / mechanism / annotated_molecule / molecule / embedded_math / ( \"\\\"\" until \"\\\"\" \"\\\"\" ) as text_run\n\n # -- reactions ---------------------------------------------------------\n\n ## Two or more chained reactions: the transform promotes the chain\n ## to a ReactionCascade (first leg + arrow/products segments).\n cascade = ( ( reaction ) as first ( 1*( cascade_segment ) ) as segments ) as cascade\n\n cascade_segment = ( arrow [ 1*( ws ) ] ( terms as products ) ) as segment\n\n ## Reactants, arrow (with optional above/below conditions), products.\n reaction = ( ( terms as reactants ) arrow [ 1*( ws ) ] ( terms as products ) ) as reaction\n\n terms = molecule 0*( plus_term )\n\n plus_term = ( [ 1*( ws ) ] \"+\" [ 1*( ws ) ] molecule ) as plus_term\n\n ## arrow.as(:arrow) {kind, above?, below?} — leading spaces ride the\n ## arrow, conditions attach with no gap.\n arrow = ( [ 1*( ws ) ] ( arrow_kind ) as kind [ condition as above ] [ condition as below ] ) as arrow\n\n arrow_kind = ( \"<=>\" / \"<->\" / \"->\" / \"<-\" )\n\n condition = ( \"[\" ( until \"]\" ) as text ( \"]\" ) as condition_close ) as condition\n\n ## A molecule: stereochemistry prefix, optional coefficient, units.\n molecule = ( [ stereo_prefix ] [ coefficient ] ( 1*( unit / bond ) ) as units ) as molecule\n\n ## `(R)-` style prefixes; a/b are the alpha/beta shorthand.\n stereo_prefix = \"(\" ( stereo_letter ) as stereo \")\" \"-\"\n\n stereo_letter = ( \"alpha\" / \"beta\" / \"R\" / \"S\" / \"E\" / \"Z\" / \"α\" / \"β\" / \"a\" / \"b\" )\n\n ## Coefficient: digits only when an element or bracket follows (else\n ## the digits are subscripts/rings). Captured as {value}.\n coefficient = ( ( 1*(%x30-39) &( \"(\" / \"[\" / \"{\" / %x41-5A ) ) as value ) as coefficient\n\n ## Units: prefixed (isotope) atoms, bracket groups, hydrogen\n ## (bare-digit subscripts allowed), plain atoms.\n unit = prefixed_atom / group / hydrogen_atom / plain_atom\n\n ## Prefix isotope: ^14C or _14C — the marker binds to the atom, the\n ## core semantic fix over AsciiMath. Captured with the marker.\n prefixed_atom = ( [ lewis_prefix ] ( ( \"^\" / \"_\" ) 1*(%x30-39) ) as isotope ( %x41-5A [ %x61-7A ] ) as element atom_suffix [ lewis_radicals ] [ ( 1*(%x30-39) ) as ring_closures ] atom_annotation ) as atom\n\n ## Bracket group: ( ... ) [ ... ] { ... } with optional multiplicity.\n group = ( group_open ( 1*( group_unit ) ) as group_nodes group_close [ ( \"_\" 1*(%x30-39) ) as multiplicity ] ) as group\n\n group_open = ( \"(\" / \"[\" / \"{\" ) as open_bracket\n\n group_close = ( \")\" / \"]\" / \"}\" ) as close_bracket\n\n ## Inside a group: reactions, molecules, electron configs, embedded\n ## math and quoted text recurse (DSL group_node set).\n group_unit = reaction / electron_config / molecule / group / embedded_math / ( \"\\\"\" until \"\\\"\" \"\\\"\" ) as group_text_run\n\n ## Hydrogen: bare digits after H are subscripts (H cannot ring-\n ## close), but H must not steal the H of He, Hf...\n hydrogen_atom = ( [ lewis_prefix ] ( \"H\" ) as element !( %x61-7A ) [ ( \"_\" subscript_value / 1*(%x30-39) ) as subscript ] [ ( \"^\" superscript_value ) as superscript ] [ lewis_radicals ] atom_annotation ) as atom\n\n plain_atom = ( [ lewis_prefix ] ( %x41-5A [ %x61-7A ] ) as element atom_suffix [ lewis_radicals ] [ ( 1*(%x30-39) ) as ring_closures ] atom_annotation ) as atom\n\n ## Suffix markers consume `_` / `^` but capture marker + value\n ## (AtomBuilder strips the marker).\n atom_suffix = [ ( \"_\" subscript_value ) as subscript ] [ ( \"^\" superscript_value ) as superscript ]\n\n subscript_value = ( \"{\" until \"}\" \"}\" ) / 1*(%x30-39)\n\n superscript_value = ( \"{\" until \"}\" \"}\" ) / ( oxidation_state / charge )\n\n ## Charges: 2+, +2, 2 (bare digits fall through to the same capture).\n charge = ( 1*(%x30-39) [ \"+\" / \"-\" ] ) / ( ( \"+\" / \"-\" ) [ 1*(%x30-39) ] )\n\n ## Oxidation state: (IV), (II)...\n oxidation_state = \"(\" 1*( %x49 / %x56 / %x58 / %x4C / %x43 / %x44 ) \")\"\n\n ## Lewis markers: leading colons = lone pairs; trailing dots =\n ## radical electrons.\n lewis_prefix = 1*( \":\" ) as lone_pairs\n\n lewis_radicals = 1*( \".\" ) as radical_electrons\n\n ## Atom annotations, DSL order: coordinates, parity, multiplicity,\n ## title, fractional coordinates.\n atom_annotation = [ coordinate_annotation ] [ parity_annotation ] [ multiplicity_annotation ] [ title_annotation ] [ fractional_annotation ]\n\n coordinate_annotation = \"@(\" ( float_number ) as x2 \",\" ( float_number ) as y2 [ \",\" ( float_number ) as z2 ] ( \")\" ) as coord_close\n\n parity_annotation = \"@\" ( \"R\" / \"S\" ) as atom_parity\n\n multiplicity_annotation = \"@m(\" ( 1*(%x30-39) ) as spin_multiplicity ( \")\" ) as spin_close\n\n title_annotation = \"@t(\" \"\\\"\" ( until \"\\\"\" ) as atom_title ( \"\\\"\" ) as title_quote ( \")\" ) as title_close\n\n fractional_annotation = \"@f(\" ( float_number ) as x_fract \",\" ( float_number ) as y_fract \",\" ( float_number ) as z_fract ( \")\" ) as fract_close\n\n float_number = [ \"-\" ] 1*(%x30-39) [ \".\" 0*(%x30-39) ]\n\n # -- bonds ---------------------------------------------------------------\n\n ## Bonds capture under their kind name (transform contract), longest\n ## first so `##` beats `#`; `->` is reserved for reactions.\n bond = ( \"##\" as quadruple ) / ( \">-\" as wedge ) / ( \"-<\" as hash ) / ( \"~>\" as dative ) / ( \"~~\" as wavy ) / ( \"#\" as triple ) / ( \"=\" as double ) / ( \"-\" as single )\n\n # -- beyond-formulas constructs -----------------------------------------\n\n ## crystal[Name](a=X,b=Y,sg=SG){atoms with @f(x,y,z)}\n crystal = ( \"crystal\" [ ( \"[\" ( until \"]\" ) as crystal_name ( \"]\" ) as crystal_name_close ) ] [ ( \"(\" ( until \")\" ) as crystal_params ( \")\" ) as crystal_params_close ) ] [ ( \"{\" ( until \"}\" ) as crystal_body ( \"}\" ) as crystal_body_close ) ] ) as crystal_node\n\n ## spectrum[type](params){peak data}\n spectrum = ( \"spectrum\" [ ( \"[\" ( until \"]\" ) as spectrum_type ( \"]\" ) as spectrum_type_close ) ] [ ( \"(\" ( until \")\" ) as spectrum_params ( \")\" ) as spectrum_params_close ) ] [ ( \"{\" ( until \"}\" ) as spectrum_body ( \"}\" ) as spectrum_body_close ) ] ) as spectrum_node\n\n ## calc(method/basis){key: value units}\n calculation = ( \"calc\" [ ( \"(\" ( until \")\" ) as calc_params ( \")\" ) as calc_params_close ) ] [ ( \"{\" ( until \"}\" ) as calc_body ( \"}\" ) as calc_body_close ) ] ) as calc_node\n\n ## zmatrix{lines}\n zmatrix = ( \"zmatrix\" [ ( \"{\" ( until \"}\" ) as zmatrix_body ( \"}\" ) as zmatrix_body_close ) ] ) as zmatrix_node\n\n ## mechanism{steps}\n mechanism = ( \"mechanism\" [ ( \"{\" ( until \"}\" ) as mechanism_body ( \"}\" ) as mechanism_body_close ) ] ) as mechanism_node\n\n # -- electron configuration ----------------------------------------------\n\n ## `1s2 2s2 2p6` — at least two orbital terms (DSL repeat(2)).\n electron_config = 2*( orbital \"^\" ( 1*(%x30-39) ) as occupancy [ 1*( ws ) ] ) as electron_config\n\n orbital = ( 1*(%x30-39) ( \"s\" / \"p\" / \"d\" / \"f\" / \"g\" / \"h\" ) ) as orbital\n\n # -- embedded math and text ------------------------------------------------\n\n ## Backtick-delimited Plurimath/AsciiMath source (delimiters stripped\n ## by the transform).\n embedded_math = ( \"`\" ( until \"`\" ) as math_source ( \"`\" ) as math_close ) as embedded_math\n\n ## Free-form text uses \"...\" delimiters, matching AsciiMath; the\n ## capture includes the quotes (TextNormaliser strips them).\n\n # -- molecule annotations ---------------------------------------------------\n\n ## @name(\"aspirin\") @cas(\"50-78-2\") @meta(\"k\",\"v\") — the annotation\n ## vocabulary rides the molecule.\n annotated_molecule = ( ( molecule ) as mol ( 1*( annotation_item ) ) as annotations ) as annotated_molecule\n\n annotation_item = ( [ 1*( ws ) ] ( metadata_annotation / simple_annotation ) ) as annotation_item\n\n ## Two quoted args, comma-separated; keys distinguish metadata from\n ## regular annotations.\n metadata_annotation = \"@meta(\" \"\\\"\" ( until \"\\\"\" ) as meta_key \"\\\"\" \",\" \"\\\"\" ( until \"\\\"\" ) as meta_value ( \"\\\"\" ) as meta_value_close ( \")\" ) as meta_close\n\n ## Known types (name, inchi, ...) handled specially by the transform;\n ## any other lowercase word becomes a property.\n simple_annotation = \"@\" ( ( known_annotation_type / property_name ) as ann_type \"(\" \"\\\"\" ( until \"\\\"\" ) as ann_value ( \"\\\"\" ) as ann_value_close ( \")\" ) as ann_close )\n\n known_annotation_type = ( \"name\" / \"title\" / \"formula\" / \"label\" / \"inchi\" / \"smiles\" / \"cas\" / \"iupac\" / \"cid\" / \"chebi\" )\n\n property_name = 1*( %x61-7A )\n\n test formula {\n accept \"H_2O\"\n accept \"H2O\"\n accept \"Ca^2+\"\n accept \"SO_4^2-\"\n accept \"C1-C-C-C-C-C1\"\n accept \"C12-C-C1-C2\"\n accept \"CH_3-CH_2-OH\"\n accept \"2H_2 + O_2 -> 2H_2O\"\n accept \"N_2 + 3H_2 <=>[Fe][400C] 2NH_3\"\n accept \"A ->[heat] B ->[cool] C\"\n accept \"A -> B -> C -> D\"\n accept \"Ca(OH)_2\"\n accept \"Fe(IV)\"\n accept \"_14C\"\n accept \"^14C\"\n accept \"CC(=O)OC1=CC=CC=C1C(=O)O\"\n accept \"Ca^2+ + CO_3^2- -> CaCO_3\"\n accept \"(R)-CH_3CH(OH)COOH\"\n accept \":O\"\n accept \"1s^2 2s^2 2p^6\"\n accept \"`x^2`\"\n accept \"\\\"nitrogen gas\\\"\"\n accept \"crystal[NaCl](a=5.64,sg=Fm-3m){Na@f(0,0,0)}\"\n accept \"spectrum[nmr](type=1H){1.2: 3H s \\\"CH3\\\"}\"\n accept \"calc(b3lyp){energy: -234.5}\"\n accept \"zmatrix{ C1 H2 C1 1.09 }\"\n accept \"mechanism{ step1: A -> B }\"\n accept \"H_2O @name(\\\"water\\\") @cas(\\\"7732-18-5\\\")\"\n accept \"H_2O @meta(\\\"source\\\",\\\"handbook\\\")\"\n accept \" H_2O \"\n accept \"C@(10,20)@R@m(2)@t(\\\"C1\\\")\"\n reject \"\"\n reject \"???\"\n reject \"H_\"\n reject \"C(=O\"\n reject \"+ H_2O\"\n reject \"crystal[NaCl\"\n reject \"zmatrix{ C1 \"\n reject \"@name(\\\"x\\\") H_2O\"\n }\n}\n","source_hash":"bb8a13d71e196f3294318305cc157f60781c14e212802d6afb27b91d20237d56","compiler_version":"1.3.94","checksum":"sha256:4b042741d854fdd52375ffbb1fc980159855716b3a88b4f4ec6431b546eff343"}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Opt-in soft dependency: fail with guidance, never silently.
|
|
4
|
+
begin
|
|
5
|
+
require 'parsanol/parslet'
|
|
6
|
+
require 'parsanol/parg'
|
|
7
|
+
require 'parsanol/native/dynamic'
|
|
8
|
+
rescue LoadError => e
|
|
9
|
+
raise AsciiChem::Engine::NotAvailableError,
|
|
10
|
+
'the parg engine requires the parsanol gem (>= 1.3.94 for PARG) — ' \
|
|
11
|
+
'add gem "parsanol" to your Gemfile ' \
|
|
12
|
+
"(#{e.message})"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
module AsciiChem
|
|
16
|
+
module Engine
|
|
17
|
+
# The PARG engine: parses through the compiled AsciiChem PARG
|
|
18
|
+
# artifact (grammar/asciichem.parg in asciichem-parg, vendored at
|
|
19
|
+
# lib/asciichem/engine/parg/asciichem.json) and adapts its parse
|
|
20
|
+
# tree to the DSL shapes TransformRules consumes. The shared
|
|
21
|
+
# conformance corpus passes model-identically (227/227) against
|
|
22
|
+
# the reference engine (benchmarks/parg_recheck.rb).
|
|
23
|
+
#
|
|
24
|
+
# Tree shape: repetition flattening follows parslet's own
|
|
25
|
+
# hash-only semantics (parsanol-ruby#231/#232), so the grammar
|
|
26
|
+
# captures every structural rule — keeping every iteration a
|
|
27
|
+
# hash — and TreeAdapter unwraps the wrappers into the DSL
|
|
28
|
+
# shapes. Grammar#parse additionally fails when the tree covers
|
|
29
|
+
# less input than was matched (a grammar edit leaving a trailing
|
|
30
|
+
# literal uncaptured), a capture-completeness invariant rather
|
|
31
|
+
# than end-of-input handling: the runtime itself is parslet-strict.
|
|
32
|
+
class PargEngine
|
|
33
|
+
# Version the vendored artifact was built from; the load fails
|
|
34
|
+
# loudly on drift so the engine never runs a stale grammar.
|
|
35
|
+
PARG_GRAMMAR_VERSION = '0.3.1'
|
|
36
|
+
|
|
37
|
+
ARTIFACT_PATH = File.expand_path('parg/asciichem.json', __dir__)
|
|
38
|
+
|
|
39
|
+
class Transform < ::Parsanol::Parslet::Transform
|
|
40
|
+
include AsciiChem::TransformRules
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Grammar seam for Parser: #parse returns the adapted tree the
|
|
44
|
+
# shared TransformRules consume, raising Parsanol::ParseFailed
|
|
45
|
+
# (with a char position) on rejection or short matches.
|
|
46
|
+
class Grammar
|
|
47
|
+
def parse(text)
|
|
48
|
+
source = text.strip
|
|
49
|
+
raw = PargEngine.artifact.parse('formula', source)
|
|
50
|
+
unless TreeAdapter.consumed_length(raw) >= source.length
|
|
51
|
+
raise ::Parsanol::ParseFailed,
|
|
52
|
+
"Failed to match sequence at char #{TreeAdapter.consumed_length(raw)}: " \
|
|
53
|
+
'input not fully captured'
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
TreeAdapter.adapt(raw)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Unwraps the artifact tree's captured structural rules into the
|
|
61
|
+
# DSL shapes TransformRules matches on.
|
|
62
|
+
module TreeAdapter
|
|
63
|
+
WRAPPERS = %i[
|
|
64
|
+
molecule reaction annotated_molecule segment plus_term
|
|
65
|
+
annotation_item formula_tail condition embedded_math
|
|
66
|
+
].freeze
|
|
67
|
+
|
|
68
|
+
# Sequence captures whose merged array form collapses to the
|
|
69
|
+
# single hash the DSL shape carries.
|
|
70
|
+
ARRAY_MERGE_KEYS = %i[cascade molecule annotated_molecule].freeze
|
|
71
|
+
|
|
72
|
+
private_constant :WRAPPERS, :ARRAY_MERGE_KEYS
|
|
73
|
+
|
|
74
|
+
module_function
|
|
75
|
+
|
|
76
|
+
def consumed_length(node)
|
|
77
|
+
case node
|
|
78
|
+
when ::Parsanol::Slice then node.offset + node.content.length
|
|
79
|
+
when Hash then node.values.sum { |value| consumed_length(value) }
|
|
80
|
+
when Array then node.map { |member| consumed_length(member) }.max.to_i
|
|
81
|
+
else 0
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def adapt(node)
|
|
86
|
+
case node
|
|
87
|
+
when ::Parsanol::Slice then node.to_s
|
|
88
|
+
when Hash then normalise(node.to_h { |key, value| [key.to_sym, adapt(value)] })
|
|
89
|
+
when Array then node.map { |member| adapt(member) }
|
|
90
|
+
else node
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def normalise(hash)
|
|
95
|
+
return unwrap_single_key(hash) if hash.length == 1
|
|
96
|
+
|
|
97
|
+
ARRAY_MERGE_KEYS.each do |key|
|
|
98
|
+
hash[key] = merge_items(hash[key]) if array_value?(hash, key)
|
|
99
|
+
end
|
|
100
|
+
hash
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def unwrap_single_key(hash)
|
|
104
|
+
key = hash.keys.first
|
|
105
|
+
return { math_source: math_source_of(hash[key]) } if key == :embedded_math
|
|
106
|
+
|
|
107
|
+
return hash[key] if WRAPPERS.include?(key)
|
|
108
|
+
return merge_multiplicity(merge_items(hash[key])) if key == :group
|
|
109
|
+
|
|
110
|
+
hash
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def math_source_of(inner)
|
|
114
|
+
inner.is_a?(Hash) ? inner[:math_source] : inner
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def array_value?(hash, key)
|
|
118
|
+
hash.key?(key) && hash[key].is_a?(Array)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def merge_multiplicity(merged)
|
|
122
|
+
merged[:multiplicity] ||= nil
|
|
123
|
+
merged
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Captured sequences with embedded repetitions yield arrays
|
|
127
|
+
# of per-child capture hashes; merge them into the single hash
|
|
128
|
+
# the DSL shape carries (later duplicate keys append, matching
|
|
129
|
+
# parslet's repeated-capture behavior).
|
|
130
|
+
def merge_items(items)
|
|
131
|
+
return items unless items.is_a?(Array)
|
|
132
|
+
|
|
133
|
+
items.each_with_object({}) do |item, merged|
|
|
134
|
+
merged.update(item) { |_key, old, new| old.is_a?(Array) ? old + Array(new) : new }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
class << self
|
|
140
|
+
def artifact
|
|
141
|
+
@artifact ||= load_artifact
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def grammar
|
|
145
|
+
Grammar
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def transform
|
|
149
|
+
Transform
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def parse_failed
|
|
153
|
+
::Parsanol::ParseFailed
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def grammar_instance
|
|
157
|
+
@grammar_instance ||= grammar.new
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def transform_instance
|
|
161
|
+
@transform_instance ||= transform.new
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def load_artifact
|
|
167
|
+
artifact = ::Parsanol::PARG::Artifact.load(ARTIFACT_PATH)
|
|
168
|
+
version = artifact.version
|
|
169
|
+
return artifact if version == PARG_GRAMMAR_VERSION
|
|
170
|
+
|
|
171
|
+
raise NotAvailableError,
|
|
172
|
+
"vendored PARG artifact version #{version.inspect} does not match " \
|
|
173
|
+
"the engine's pinned grammar #{PARG_GRAMMAR_VERSION.inspect} — " \
|
|
174
|
+
're-vendor from asciichem-parg'
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
data/lib/asciichem/engine.rb
CHANGED
|
@@ -31,6 +31,7 @@ module AsciiChem
|
|
|
31
31
|
|
|
32
32
|
autoload :ParsletEngine, 'asciichem/engine/parslet_engine'
|
|
33
33
|
autoload :ParsanolEngine, 'asciichem/engine/parsanol_engine'
|
|
34
|
+
autoload :PargEngine, 'asciichem/engine/parg_engine'
|
|
34
35
|
|
|
35
36
|
class << self
|
|
36
37
|
# Selects the engine by name. Returns the engine class.
|
|
@@ -50,8 +51,9 @@ module AsciiChem
|
|
|
50
51
|
case name.to_s
|
|
51
52
|
when 'parslet' then ParsletEngine
|
|
52
53
|
when 'parsanol' then ParsanolEngine
|
|
54
|
+
when 'parg' then PargEngine
|
|
53
55
|
else
|
|
54
|
-
raise Error, "unknown engine #{name.inspect} (available: parslet, parsanol)"
|
|
56
|
+
raise Error, "unknown engine #{name.inspect} (available: parslet, parsanol, parg)"
|
|
55
57
|
end
|
|
56
58
|
end
|
|
57
59
|
end
|
data/lib/asciichem/layout.rb
CHANGED
|
@@ -63,7 +63,7 @@ module AsciiChem
|
|
|
63
63
|
# round-tripping through CML.
|
|
64
64
|
return pre_positioned_result(walk) if all_positioned?(walk)
|
|
65
65
|
|
|
66
|
-
graph = GraphBuilder.new(walk).build
|
|
66
|
+
graph = GraphBuilder.new(walk).build
|
|
67
67
|
laid_out = Elkrb.layout(graph, algorithm: algorithm)
|
|
68
68
|
ResultExtractor.new(laid_out, walk).extract
|
|
69
69
|
end
|
|
@@ -201,14 +201,15 @@ module AsciiChem
|
|
|
201
201
|
@walk = walk
|
|
202
202
|
end
|
|
203
203
|
|
|
204
|
-
def build
|
|
204
|
+
def build
|
|
205
205
|
children = @walk.atoms.map { |a| elkrb_node(a) }
|
|
206
206
|
edges = @walk.bonds.map { |b| elkrb_edge(b) }
|
|
207
|
+
# elkrb 2.0 takes the algorithm as a layout call option, not a
|
|
208
|
+
# graph-level LayoutOptions object (Layout.layout passes it).
|
|
207
209
|
Elkrb::Graph::Graph.new(
|
|
208
210
|
id: 'root',
|
|
209
211
|
children: children,
|
|
210
|
-
edges: edges
|
|
211
|
-
layout_options: Elkrb::Graph::LayoutOptions.new(algorithm: algorithm)
|
|
212
|
+
edges: edges
|
|
212
213
|
)
|
|
213
214
|
end
|
|
214
215
|
|
data/lib/asciichem/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: asciichem
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.31.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -29,14 +29,14 @@ dependencies:
|
|
|
29
29
|
requirements:
|
|
30
30
|
- - "~>"
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '
|
|
32
|
+
version: '2.0'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '
|
|
39
|
+
version: '2.0'
|
|
40
40
|
- !ruby/object:Gem::Dependency
|
|
41
41
|
name: lutaml-model
|
|
42
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -178,6 +178,7 @@ files:
|
|
|
178
178
|
- benchmarks/RESULTS.md
|
|
179
179
|
- benchmarks/benchmark.rb
|
|
180
180
|
- benchmarks/engines.rb
|
|
181
|
+
- benchmarks/parg_recheck.rb
|
|
181
182
|
- benchmarks/parsanol_recheck.rb
|
|
182
183
|
- exe/asciichem
|
|
183
184
|
- lib/asciichem.rb
|
|
@@ -193,6 +194,8 @@ files:
|
|
|
193
194
|
- lib/asciichem/cml/opaque_extensions.rb
|
|
194
195
|
- lib/asciichem/cml/translator.rb
|
|
195
196
|
- lib/asciichem/engine.rb
|
|
197
|
+
- lib/asciichem/engine/parg/asciichem.json
|
|
198
|
+
- lib/asciichem/engine/parg_engine.rb
|
|
196
199
|
- lib/asciichem/engine/parsanol_engine.rb
|
|
197
200
|
- lib/asciichem/engine/parslet_engine.rb
|
|
198
201
|
- lib/asciichem/errors.rb
|