asciichem 0.31.14 → 0.31.16

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: 380b64ab12e3ccc18a554a5d1dc7793046bfa7ea5ba80d51330bb5d713ea618f
4
- data.tar.gz: 4b3ec1535d5854626b5e2976adb060b4432bfb7f9d20331e79717ae0db4cdf73
3
+ metadata.gz: 1b985064f84599e157131e30ca2ae73555383b6e273c6ea3ae561f02d52df5da
4
+ data.tar.gz: bb08895e055a18b16fe280b30da829666bf9f646d269f9a3b5312b61fbf86578
5
5
  SHA512:
6
- metadata.gz: 49ca402649027d0091acb25b77848e46a9ffaa5bf876e16512f14378d507a3bf32a6dbbb6245967bc54d1eb6728f0672a530db0e92c8db476ca9bfbdb87497f6
7
- data.tar.gz: 1741914b553ebe4f1828fa3a294c3ed0249a22072dd90f57185d92aafea717a97eaded37236c97ac3b406032f2919cd449818ba6cf7a6094fdd37a81c7f5c44e
6
+ metadata.gz: 12604d14213ae4bc6beba2507b6fcd27e540b32d3be966cbdc56816c844f73309e5b6518ea632441e17b7610d7321fcaf25bc2be6ba46659d2165907039c5375
7
+ data.tar.gz: 2676f58b2d881b8a9f321bebcaad712e165f2cc4520f419bdf7b41dab28e4330b72f278456265bd62e3c0101d953501d396435fec84c5281d1379de34637a938
data/CHANGELOG.md CHANGED
@@ -3,6 +3,32 @@
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.16] - 2026-11-12
7
+
8
+ ### Changed
9
+ - **The legacy parse engines are deleted.** The PARG artifact engine
10
+ is the one engine: `AsciiChem.parse` goes straight through the
11
+ checksummed asciichem PARG artifact (asciichem-parg) — the same
12
+ artifact the Rust crate embeds. Removed: the parslet Parser, the
13
+ parslet Transform wrapper, ParsletEngine, ParsanolEngine, the
14
+ Engine selector + the ASCIICHEM_ENGINE seam, the `--engine` CLI
15
+ flag, and the parslet gem dependency. The shared rule definitions
16
+ the PARG transform consumes stay. 376 lines removed; suite 2327/0.
17
+ - The gemspec carries parsanol (~> 1.3) — the PARG runtime.
18
+
19
+ ## [0.31.15] - 2026-11-12
20
+
21
+ ### Fixed
22
+ - Common Chemistry speaks its real key contract (CAS's access
23
+ email): `GET /direct-api/detail` with the token as the
24
+ `X-API-KEY` header plus `x-origin` and `accept` headers — not
25
+ the `apikey` query parameter 0.31.14 guessed. The key resolves
26
+ from `COMMON_CHEMISTRY_API_KEY` or the token's stored location
27
+ (`~/.config/cas-common-chemistry-api.key`); explicit nil means
28
+ no key. Verified live: formaldehyde resolves from production.
29
+ - The fetch contract grows `get(url, headers = {})`; redirects
30
+ keep the headers.
31
+
6
32
  ## [0.31.14] - 2026-11-12
7
33
 
8
34
  ### Added
data/README.adoc CHANGED
@@ -104,8 +104,10 @@ AsciiChem::Resolver.resolve(value: "50-78-2", convention: "cas",
104
104
  ----
105
105
 
106
106
  CAS key-gated the Common Chemistry API: live lookups there need a
107
- key (request one at cas.org/services/commonchemistry-api) exported
108
- as `COMMON_CHEMISTRY_API_KEY`. Without it, that source answers 401
107
+ personal token (request one at cas.org/services/commonchemistry-api,
108
+ valid 12 months). Export it as `COMMON_CHEMISTRY_API_KEY` or store
109
+ it at `~/.config/cas-common-chemistry-api.key` — the adapter sends
110
+ it as the `X-API-KEY` header. Without a key, that source answers 401
109
111
  and is skipped — PubChem and OPSIN keep resolving.
110
112
 
111
113
  === Citations: dataset bibitems with provenance
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
@@ -78,18 +78,19 @@ module AsciiChem
78
78
 
79
79
  # Minimal stdlib HTTP transport. No runtime gem dependencies.
80
80
  class NetFetch
81
- def get(url, limit = 3)
81
+ def get(url, headers = {}, limit = 3)
82
82
  raise Error, "too many redirects" if limit.zero?
83
83
 
84
84
  uri = URI(url)
85
+ merged = { "User-Agent" => "asciichem-resolver" }.merge(headers)
85
86
  response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
86
87
  open_timeout: 5, read_timeout: 10) do |http|
87
- http.request(Net::HTTP::Get.new(uri.request_uri, "User-Agent" => "asciichem-resolver"))
88
+ http.request(Net::HTTP::Get.new(uri.request_uri, merged))
88
89
  end
89
90
 
90
91
  case response
91
92
  when Net::HTTPSuccess then response.body
92
- when Net::HTTPRedirection then get(URI.join(uri, response["location"]).to_s, limit - 1)
93
+ when Net::HTTPRedirection then get(URI.join(uri, response["location"]).to_s, headers, limit - 1)
93
94
  when Net::HTTPNotFound then nil
94
95
  else
95
96
  raise Error, "HTTP #{response.code}: #{response.message}"
@@ -16,30 +16,40 @@ module AsciiChem
16
16
  # `Resolver.resolve(sources:)`.
17
17
  #
18
18
  # API access: CAS key-gated the API (401 without a key; request
19
- # one at cas.org/services/commonchemistry-api — the exact contract
20
- # arrives with the access email). The adapter sends the key as the
21
- # `apikey` query parameter; set it per instance (`api_key:`) or
22
- # through COMMON_CHEMISTRY_API_KEY, which the resolver's own
23
- # instantiation picks up. Without a key, live lookups answer 401
24
- # and the resolver skips this source (the other sources still
25
- # resolve; seeded caches keep working).
19
+ # one at cas.org/services/commonchemistry-api). The access email
20
+ # defines the contract: GET /direct-api/detail with the token as
21
+ # the X-API-KEY header plus x-origin and accept headers. The key
22
+ # comes per instance (`api_key:`), from
23
+ # COMMON_CHEMISTRY_API_KEY, or from
24
+ # ~/.config/cas-common-chemistry-api.key (the token's stored
25
+ # location). Tokens are personal and expire after 12 months
26
+ # (CAS emails a renewal notice). Without a key, live lookups
27
+ # answer 401 and the resolver skips this source; seeded caches
28
+ # keep working.
26
29
  class CommonChemistry < Adapter
27
30
  source_name :common_chemistry
28
31
  supports "cas"
29
32
 
30
- BASE = "https://commonchemistry.cas.org/api/detail"
33
+ BASE = "https://commonchemistry.cas.org/direct-api/detail"
34
+ ORIGIN = "https://commonchemistry.cas.org/api-overview"
35
+ KEY_FILE = File.expand_path("~/.config/cas-common-chemistry-api.key")
31
36
 
32
37
  attr_reader :api_key
33
38
 
34
- def initialize(api_key: ENV.fetch("COMMON_CHEMISTRY_API_KEY", nil))
39
+ # Explicit nil means no key; the env/file lookup runs only when
40
+ # the argument is omitted.
41
+ def initialize(api_key: default_api_key)
35
42
  super()
36
43
  @api_key = api_key
37
44
  end
38
45
 
39
46
  def fetch_record(value:, convention:, fetch:)
40
- url = "#{BASE}?cas_rn=#{CGI.escape(value.to_s)}"
41
- url = "#{url}&apikey=#{CGI.escape(api_key)}" if api_key
42
- body = fetch.get(url)
47
+ headers = {
48
+ "Accept" => "application/json",
49
+ "x-origin" => ORIGIN
50
+ }
51
+ headers["X-API-KEY"] = api_key if api_key
52
+ body = fetch.get("#{BASE}?cas_rn=#{CGI.escape(value.to_s)}", headers)
43
53
  return nil unless body
44
54
 
45
55
  data = JSON.parse(body)
@@ -56,6 +66,11 @@ module AsciiChem
56
66
 
57
67
  private
58
68
 
69
+ def default_api_key
70
+ ENV.fetch("COMMON_CHEMISTRY_API_KEY", nil) ||
71
+ (File.read(KEY_FILE).strip if File.readable?(KEY_FILE))
72
+ end
73
+
59
74
  def substance_from(data)
60
75
  provenance = default_provenance
61
76
  identifiers = [Identifier.new(value: data["rn"], convention: "cas",
@@ -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.14"
4
+ VERSION = "0.31.16"
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.14
4
+ version: 0.31.16
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