asciichem 0.31.15 → 0.31.17

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3246a177690e1019834ab1e177da12f75aee9a7a75cdaa6caecaa3e7a721b227
4
- data.tar.gz: e2194852b615ef5e61ddea0726e8b279543c6cbad326991a6238adf791e8d87c
3
+ metadata.gz: 96ad0bea754dd62a7b5b92f44cf87d1446a7afc1594634f903fd744c7c7c0326
4
+ data.tar.gz: 00d721ea5ba129cbd6f6b606167469178c83ec17ca53ecf1749160d337ea5639
5
5
  SHA512:
6
- metadata.gz: 9fb5e64c26d57111cb9a742388228e225a99da2965d5ac7793cf8ec2366367a3c3b4ed24c14aab1c173f03d324309bd0f34919c52d29a0ee569cb34de88d1012
7
- data.tar.gz: cc604167f949bac08b2f47c1dd6ec69e76d1b85ec9d2158ed7d0004a1c0f60c368ec5c1c5a6544fec68f6cc9f71ffec197d1fc2eb165c7da3ea6062e039db7f1
6
+ metadata.gz: a4d8d30b7b44a9b4b8ac460708a38e5eaf122a90a3d53c387bda976390eab2e41cf6750742c88755d5147119fa15f58758f4a55c9f846165b21b7ec0986a6bc3
7
+ data.tar.gz: c3e7da0afbcd8f8cce91335c6f722fc797af05d461952b743913e4e12066c25962a3add7db9deca81e7baccde11ac3fcd5b662f1c6bd787b722f608d84b459d7
@@ -9,7 +9,7 @@ env:
9
9
  # Hermetic CI: the corpus ref is pinned, never floating. Bump this
10
10
  # one line deliberately with each corpus release (the tag carries
11
11
  # what changed).
12
- CORPUS_REF: v0.8.0
12
+ CORPUS_REF: v0.11.0
13
13
 
14
14
  jobs:
15
15
  test:
data/CHANGELOG.md CHANGED
@@ -3,6 +3,36 @@
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.17] - 2026-11-13
7
+
8
+ ### Added
9
+ - The resolver clock seam: `resolve_all` and every adapter accept a
10
+ `now:` ISO-8601 UTC timestamp (nil = the wall clock) that pins
11
+ `retrievedAt` — recorded-fixture runs and the citation goldens that
12
+ consume them compare byte-stably. Mirrors the asciichem-py seam of
13
+ the same name.
14
+
15
+ ### Changed
16
+ - The conformance runner gates the corpus 0.11.0 parity families
17
+ (linter-expectations with engine posture + node matching, resolver
18
+ replay, citation byte-goldens, the 22 CLI contracts); the CI
19
+ corpus pin moves v0.8.0 → v0.11.0. Claims matrix: linter 27 run +
20
+ 3 posture skips, resolver 6/6, citation 4/4 byte-equal, CLI 22/22
21
+ (asciichem-ruby#159).
22
+
23
+ ## [0.31.16] - 2026-11-12
24
+
25
+ ### Changed
26
+ - **The legacy parse engines are deleted.** The PARG artifact engine
27
+ is the one engine: `AsciiChem.parse` goes straight through the
28
+ checksummed asciichem PARG artifact (asciichem-parg) — the same
29
+ artifact the Rust crate embeds. Removed: the parslet Parser, the
30
+ parslet Transform wrapper, ParsletEngine, ParsanolEngine, the
31
+ Engine selector + the ASCIICHEM_ENGINE seam, the `--engine` CLI
32
+ flag, and the parslet gem dependency. The shared rule definitions
33
+ the PARG transform consumes stay. 376 lines removed; suite 2327/0.
34
+ - The gemspec carries parsanol (~> 1.3) — the PARG runtime.
35
+
6
36
  ## [0.31.15] - 2026-11-12
7
37
 
8
38
  ### Fixed
data/asciichem.gemspec CHANGED
@@ -40,7 +40,7 @@ Gem::Specification.new do |spec|
40
40
  spec.add_dependency "lutaml-model", "~> 0.8"
41
41
  spec.add_dependency "mml", "~> 2.3"
42
42
  spec.add_dependency "moxml", "~> 0.6.1"
43
- spec.add_dependency "parslet", "~> 2.0"
43
+ spec.add_dependency "parsanol", "~> 1.3", ">= 1.3.107"
44
44
  spec.add_dependency "relaton-bib", "~> 2.1"
45
45
  spec.add_dependency "plurimath", "~> 0.11"
46
46
  spec.add_dependency "thor", "~> 1.3"
@@ -42,6 +42,32 @@ than 2x.
42
42
  | electron config | _tbd_ | _tbd_ | _tbd_ |
43
43
  | bonds (H-O-H=O#H) | _tbd_ | _tbd_ | _tbd_ |
44
44
 
45
+ ## Cross-runtime parse comparison (2026-11-12)
46
+
47
+ The shared workload (the ten inputs the TS bench and the rs bench
48
+ pin — see `benchmarks/README.md`), per parse input, on the
49
+ maintainer's machine:
50
+
51
+ | Runtime | Engine | per parse |
52
+ |---|---|---|
53
+ | TypeScript (peggy) | peggy-generated JS | **~32 µs** |
54
+ | Python (rd) | rd grammar port | ~672 µs |
55
+ | Ruby (parslet) | parslet | ~2,960 µs |
56
+ | Rust (parsanol) | parsanol portable parser | ~200 µs |
57
+
58
+ Recorded via `npm run bench` (asciichem-ts), `python3
59
+ benchmarks/engines.py` (asciichem-py), `bundle exec ruby
60
+ benchmarks/engines.rb` (the gem), and `cargo bench -- --quick`
61
+ (asciichem-rs, criterion, shared workload — see the crate's
62
+ `benches/parse.rs`).
63
+
64
+ Two Rust facts from the same pass: the deep-stack parse thread is
65
+ spawned once and reused, and inputs ≤ 16 KiB parse inline (PEG
66
+ depth is bounded by input length) — both landed in
67
+ asciichem-rs#24. The remaining Rust-vs-peggy gap is parsanol's
68
+ evaluation loop (setup 12 µs, eval ~37 µs for tiny inputs); the
69
+ optimization lever lives in the parsanol repo.
70
+
45
71
  ## When to investigate
46
72
 
47
73
  - Any input that drops below 100 ips on parse.
data/lib/asciichem/cli.rb CHANGED
@@ -20,18 +20,12 @@ module AsciiChem
20
20
  desc: 'Input grammar: asciichem|smiles|molfile'
21
21
  method_option :format, aliases: '-t', type: :string, default: 'mathml',
22
22
  desc: 'Output format'
23
- method_option :engine, type: :string, default: 'parslet',
24
- desc: 'Parsing engine: parslet (default) | parsanol (opt-in, needs the parsanol gem)'
25
23
  def convert
26
24
  raise AsciiChem::ParseError, 'provide -i INPUT or -f FILE' unless options['input'] || options['file']
27
25
 
28
- select_engine(options[:engine])
29
26
  source = read_source
30
27
  formula = ingest(source, options[:from])
31
28
  puts render(formula, options[:format])
32
- rescue AsciiChem::Engine::Error => e
33
- warn "Engine error: #{e.message}"
34
- exit 6
35
29
  rescue AsciiChem::ParseError => e
36
30
  warn "Parse error: #{e.message}"
37
31
  exit 1
@@ -206,15 +200,6 @@ module AsciiChem
206
200
 
207
201
  private
208
202
 
209
- # --engine feeds the Engine selector (0.29.0+); :parsanol is an
210
- # opt-in soft dependency, :parslet stays the default.
211
- def select_engine(name)
212
- return if name.to_s == 'parslet' && AsciiChem::Engine.current == AsciiChem::Engine::ParsletEngine
213
-
214
- require 'asciichem/engine'
215
- AsciiChem::Engine.use(name.to_s)
216
- end
217
-
218
203
  def read_source
219
204
  return File.read(options[:file]) if options[:file]
220
205
  return $stdin.read if options[:input] == '-'
@@ -1,61 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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
- #
4
+ # The parse engine: the PARG artifact engine, period (TODO.v2/17
5
+ # slice 9; the maintainer's PARG-only directive). The legacy
6
+ # parslet and DSL-parsanol engines are deleted — the checksummed
7
+ # AsciiChem PARG artifact (vendored at engine/parg/asciichem.json,
8
+ # sourced from asciichem-parg) is the one grammar, and
9
+ # PargEngine adapts its tree to the DSL shapes TransformRules
10
+ # consumes.
26
11
  module Engine
27
12
  class Error < AsciiChem::Error; end
28
13
 
29
- # The named engine exists but its gem is not installed.
14
+ # The engine is the PARG runtime; its gem must be installed.
30
15
  class NotAvailableError < Error; end
31
16
 
32
- autoload :ParsletEngine, 'asciichem/engine/parslet_engine'
33
- autoload :ParsanolEngine, 'asciichem/engine/parsanol_engine'
34
17
  autoload :PargEngine, 'asciichem/engine/parg_engine'
35
-
36
- class << self
37
- # Selects the engine by name. Returns the engine class.
38
- def use(name)
39
- @current = engine_for(name)
40
- end
41
-
42
- # The active engine. Defaults to :parslet, overridable once via
43
- # ASCIICHEM_ENGINE at first use.
44
- def current
45
- @current ||= engine_for(ENV.fetch('ASCIICHEM_ENGINE', 'parslet'))
46
- end
47
-
48
- private
49
-
50
- def engine_for(name)
51
- case name.to_s
52
- when 'parslet' then ParsletEngine
53
- when 'parsanol' then ParsanolEngine
54
- when 'parg' then PargEngine
55
- else
56
- raise Error, "unknown engine #{name.inspect} (available: parslet, parsanol, parg)"
57
- end
58
- end
59
- end
60
18
  end
61
19
  end
@@ -3,7 +3,7 @@
3
3
  module AsciiChem
4
4
  # The grammar's rules, backend-neutral: included by Grammar
5
5
  # (parslet, the reference engine) and by
6
- # Engine::ParsanolEngine::Grammar (Rust core) when that opt-in
6
+ # the PARG engine (the one engine, per TODO.v2/17 slice 9)
7
7
  # engine is active. The DSL (str, match, any, .repeat, .maybe,
8
8
  # .absent?, >>, .as) is identical across backends.
9
9
  module GrammarRules
@@ -164,7 +164,7 @@ module AsciiChem
164
164
  rule(:reaction_cascade) do
165
165
  # The repeat is wrapped in a single :segment capture so every
166
166
  # engine arrays it (parsanol merges — and overwrites — bare
167
- # repeated sibling captures; see Engine::ParsanolEngine).
167
+ # repeated sibling captures; see the PARG engine).
168
168
  (reaction.as(:first) >>
169
169
  (arrow.as(:arrow) >> spaces? >> terms.as(:products)).repeat(1).as(:segments)).as(:cascade)
170
170
  end
@@ -8,12 +8,12 @@ module AsciiChem
8
8
  @text = text.to_s
9
9
  end
10
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).
11
+ # The parse goes through the PARG engine (the leptris-family
12
+ # discipline applies to parsing too: ONE engine, the PARG
13
+ # artifact). Its grammar/transform instances are memoized at the
14
+ # class level.
15
15
  def parse
16
- engine = AsciiChem::Engine.current
16
+ engine = AsciiChem::Engine::PargEngine
17
17
  tree = engine.grammar_instance.parse(text)
18
18
  engine.transform_instance.apply(tree)
19
19
  rescue engine.parse_failed => e
@@ -41,7 +41,11 @@ module AsciiChem
41
41
 
42
42
  # Cache-aware resolution. `fetch` must answer #get(url) -> body
43
43
  # String (NetFetch by default; specs inject recorded responses).
44
- def resolve(value:, convention:, fetch: nil, cache: nil, refresh: false)
44
+ # `now` pins the retrieval clock (an ISO-8601 UTC string) for
45
+ # recorded-fixture runs — the shared corpus pins `retrievedAt`
46
+ # so replayed records compare byte-stably; nil means the wall
47
+ # clock.
48
+ def resolve(value:, convention:, fetch: nil, cache: nil, refresh: false, now: nil)
45
49
  cache ||= Cache.default
46
50
  fetch ||= NetFetch.new
47
51
  key = cache.key_for(self.class.source_name, convention, value)
@@ -51,22 +55,22 @@ module AsciiChem
51
55
  return cached if cached
52
56
  end
53
57
 
54
- substance = fetch_record(value: value, convention: convention, fetch: fetch)
55
- .tap { |s| s&.identifiers&.each { |i| i.provenance ||= default_provenance } }
58
+ substance = fetch_record(value: value, convention: convention, fetch: fetch, now: now)
59
+ .tap { |s| s&.identifiers&.each { |i| i.provenance ||= default_provenance(now) } }
56
60
  cache.write(key, substance) if substance
57
61
  substance
58
62
  end
59
63
 
60
64
  # Not-found is nil, not an error — sources legitimately answer
61
65
  # "unknown substance" during multi-source resolution.
62
- def fetch_record(**)
66
+ def fetch_record(value:, convention:, fetch:, now: nil)
63
67
  raise NotImplementedError
64
68
  end
65
69
 
66
- def default_provenance
70
+ def default_provenance(now: nil)
67
71
  Provenance.new(
68
72
  source: self.class.source_name,
69
- retrieved_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
73
+ retrieved_at: now || Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
70
74
  attribution: attribution
71
75
  )
72
76
  end
@@ -43,7 +43,7 @@ module AsciiChem
43
43
  @api_key = api_key
44
44
  end
45
45
 
46
- def fetch_record(value:, convention:, fetch:)
46
+ def fetch_record(value:, convention:, fetch:, now: nil)
47
47
  headers = {
48
48
  "Accept" => "application/json",
49
49
  "x-origin" => ORIGIN
@@ -55,7 +55,7 @@ module AsciiChem
55
55
  data = JSON.parse(body)
56
56
  return nil if data.empty?
57
57
 
58
- substance_from(data)
58
+ substance_from(data, now)
59
59
  rescue JSON::ParserError => e
60
60
  raise Error, "common_chemistry returned malformed JSON: #{e.message}"
61
61
  end
@@ -71,8 +71,8 @@ module AsciiChem
71
71
  (File.read(KEY_FILE).strip if File.readable?(KEY_FILE))
72
72
  end
73
73
 
74
- def substance_from(data)
75
- provenance = default_provenance
74
+ def substance_from(data, now)
75
+ provenance = default_provenance(now: now)
76
76
  identifiers = [Identifier.new(value: data["rn"], convention: "cas",
77
77
  provenance: provenance)]
78
78
  add_identifier(identifiers, data, "inchikey", "inchikey", provenance)
@@ -27,11 +27,11 @@ module AsciiChem
27
27
  BASE = 'https://opsin.ch.cam.ac.uk/opsin'
28
28
  private_constant :BASE
29
29
 
30
- def fetch_record(value:, convention: nil, fetch:)
30
+ def fetch_record(value:, convention: nil, fetch:, now: nil)
31
31
  body = fetch.get("#{BASE}/#{CGI.escape(value.to_s)}.smiles")
32
32
  return nil unless body
33
33
 
34
- substance_from(body.strip)
34
+ substance_from(body.strip, now)
35
35
  rescue StandardError => e
36
36
  raise Error, "opsin request failed: #{e.message}"
37
37
  end
@@ -42,10 +42,10 @@ module AsciiChem
42
42
 
43
43
  private
44
44
 
45
- def substance_from(smiles)
45
+ def substance_from(smiles, now)
46
46
  return nil if smiles.empty?
47
47
 
48
- provenance = default_provenance
48
+ provenance = default_provenance(now: now)
49
49
  Substance.new(
50
50
  preferred_name: nil,
51
51
  identifiers: [Identifier.new(value: smiles, convention: 'canonical-smiles',
@@ -26,14 +26,14 @@ module AsciiChem
26
26
  MolecularWeight IUPACName InChI InChIKey].freeze
27
27
  private_constant :BASE, :PROPERTIES
28
28
 
29
- def fetch_record(value:, convention:, fetch:)
29
+ def fetch_record(value:, convention:, fetch:, now: nil)
30
30
  body = fetch.get(url_for(value, convention))
31
31
  return nil unless body
32
32
 
33
33
  props = JSON.parse(body).dig("PropertyTable", "Properties", 0)
34
34
  return nil unless props
35
35
 
36
- substance_from(props)
36
+ substance_from(props, now)
37
37
  rescue JSON::ParserError => e
38
38
  raise Error, "pubchem returned malformed JSON: #{e.message}"
39
39
  end
@@ -53,8 +53,8 @@ module AsciiChem
53
53
  "#{BASE}/#{namespace}/#{CGI.escape(value.to_s)}/property/#{PROPERTIES.join(',')}/JSON"
54
54
  end
55
55
 
56
- def substance_from(props)
57
- provenance = default_provenance
56
+ def substance_from(props, now)
57
+ provenance = default_provenance(now: now)
58
58
  identifiers = []
59
59
  identifiers << Identifier.new(value: props["CID"].to_s, convention: "pubchem-cid", provenance: provenance) if props["CID"]
60
60
  add_identifier(identifiers, props, "InChIKey", "inchikey", provenance)
@@ -48,15 +48,16 @@ module AsciiChem
48
48
  # entries dropped). Sources answer in registration order. A
49
49
  # source-level failure (HTTP error, rejected request) skips that
50
50
  # source — one dead or key-gated endpoint must not poison the
51
- # others; callers see what answered.
52
- def resolve_all(value:, convention:, sources: nil, fetch: nil, cache: nil, refresh: false)
51
+ # others; callers see what answered. `now` pins the retrieval
52
+ # clock for recorded-fixture runs (the adapters' seam).
53
+ def resolve_all(value:, convention:, sources: nil, fetch: nil, cache: nil, refresh: false, now: nil)
53
54
  selected = sources ? Array(sources).map { |s| self[s] } : adapters.values
54
55
  selected.filter_map do |adapter_class|
55
56
  adapter = adapter_class.new
56
57
  next unless adapter.supports?(convention)
57
58
 
58
59
  begin
59
- adapter.resolve(value: value, convention: convention, fetch: fetch, cache: cache, refresh: refresh)
60
+ adapter.resolve(value: value, convention: convention, fetch: fetch, cache: cache, refresh: refresh, now: now)
60
61
  rescue AsciiChem::Error
61
62
  nil
62
63
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module AsciiChem
4
4
  # The transform's rules, backend-neutral: included by Transform
5
- # (parslet) and by Engine::ParsanolEngine::Transform when that
5
+ # and by the PARG engine's Transform (the one engine,
6
6
  # opt-in engine is active. String class_eval so the nested builder
7
7
  # classes land as constants of the including class, unchanged.
8
8
  module TransformRules
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AsciiChem
4
- VERSION = "0.31.15"
4
+ VERSION = "0.31.17"
5
5
  end
data/lib/asciichem.rb CHANGED
@@ -39,7 +39,6 @@ module AsciiChem
39
39
  autoload :Rxn, "asciichem/rxn"
40
40
  autoload :Smiles, "asciichem/smiles"
41
41
  autoload :Structure, "asciichem/structure"
42
- autoload :Transform, "asciichem/transform"
43
42
  autoload :TransformRules, "asciichem/transform_rules"
44
43
  autoload :VERSION, "asciichem/version"
45
44
  autoload :Wire, "asciichem/wire"
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.31.15
4
+ version: 0.31.17
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -80,19 +80,25 @@ dependencies:
80
80
  - !ruby/object:Gem::Version
81
81
  version: 0.6.1
82
82
  - !ruby/object:Gem::Dependency
83
- name: parslet
83
+ name: parsanol
84
84
  requirement: !ruby/object:Gem::Requirement
85
85
  requirements:
86
86
  - - "~>"
87
87
  - !ruby/object:Gem::Version
88
- version: '2.0'
88
+ version: '1.3'
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: 1.3.107
89
92
  type: :runtime
90
93
  prerelease: false
91
94
  version_requirements: !ruby/object:Gem::Requirement
92
95
  requirements:
93
96
  - - "~>"
94
97
  - !ruby/object:Gem::Version
95
- version: '2.0'
98
+ version: '1.3'
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: 1.3.107
96
102
  - !ruby/object:Gem::Dependency
97
103
  name: relaton-bib
98
104
  requirement: !ruby/object:Gem::Requirement
@@ -196,8 +202,6 @@ files:
196
202
  - lib/asciichem/engine.rb
197
203
  - lib/asciichem/engine/parg/asciichem.json
198
204
  - lib/asciichem/engine/parg_engine.rb
199
- - lib/asciichem/engine/parsanol_engine.rb
200
- - lib/asciichem/engine/parslet_engine.rb
201
205
  - lib/asciichem/errors.rb
202
206
  - lib/asciichem/formatter.rb
203
207
  - lib/asciichem/formatter/base.rb
@@ -207,7 +211,6 @@ files:
207
211
  - lib/asciichem/formatter/structural_svg.rb
208
212
  - lib/asciichem/formatter/svg.rb
209
213
  - lib/asciichem/formatter/text.rb
210
- - lib/asciichem/grammar.rb
211
214
  - lib/asciichem/grammar_rules.rb
212
215
  - lib/asciichem/greek.rb
213
216
  - lib/asciichem/identifiers.rb
@@ -289,7 +292,6 @@ files:
289
292
  - lib/asciichem/structure.rb
290
293
  - lib/asciichem/structure/graph.rb
291
294
  - lib/asciichem/structure/linearizer.rb
292
- - lib/asciichem/transform.rb
293
295
  - lib/asciichem/transform_rules.rb
294
296
  - lib/asciichem/version.rb
295
297
  - lib/asciichem/wire.rb
@@ -1,118 +0,0 @@
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
@@ -1,34 +0,0 @@
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
@@ -1,22 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'parslet'
4
-
5
- module AsciiChem
6
- # Parslet grammar for AsciiChem v1.
7
- #
8
- # Design notes:
9
- #
10
- # - Leaf rules return strings; `.as(:name)` is applied at the
11
- # combination site so the parse tree is a flat hash of named strings
12
- # wherever practical.
13
- # - The prefix-isotope binding (`^14C`) is enforced structurally: the
14
- # `prefixed_atom` production consumes `^digits element` as a unit.
15
- # A bare `^digits` without an element is rejected.
16
- # - Marker rules (`subscript_marker`, `superscript_marker`) capture
17
- # ONLY the value, not the leading `_` or `^`. The literal prefix is
18
- # consumed by the rule but not included in the named capture.
19
- class Grammar < Parslet::Parser
20
- include GrammarRules
21
- end
22
- end
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'parslet'
4
-
5
- module AsciiChem
6
- # Converts a parse tree (from AsciiChem::Grammar) into a tree of
7
- # AsciiChem::Model instances.
8
- #
9
- # The transform contains minimal logic — it maps hash shapes to
10
- # constructor calls. Anything semantically tricky (e.g. distinguishing
11
- # isotope prefix from suffix charge) is encoded in the grammar, not
12
- # here.
13
- class Transform < Parslet::Transform
14
- include TransformRules
15
- end
16
- end