asciichem 0.29.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 106d03bf9d192f66b4f9ade534ed84f8dcc5cbf0ceeb07d45b633c353fc92aaa
4
- data.tar.gz: 36c047aa3f26f73f94cc698eb905a939025feb9fc34fb86b67163cf3a93f7fbe
3
+ metadata.gz: 9579311feee35b70f9d15879379582cb67a8b6b9fe004e7c714e12448835e53c
4
+ data.tar.gz: 6f732f510f6d8923f2d1c855113ec34db33910a258135ee36a954035c90a252b
5
5
  SHA512:
6
- metadata.gz: b9215fa19d5dc3a65d82cfbe5cdfd4f837bd136cbb18cb0241654e7307a314c42088a870a76c3393858bd285e8e5834a95de136849c6f4d1ee4d2469fe616731
7
- data.tar.gz: f7654b2d8257c634e7be6b514011b5875c8060886204c43dd9293b982c8cd241773fb92958d8772d0e28ec07ce50ad82753aba085469e0c3edbc03446201c3bc
6
+ metadata.gz: 509996644be4e271c9432f8c5cc7baee5120f58b75af97f3f0bc6050b776dca6c567b1f69586bd87258237f12a56eeabccd4085e45b41772506ae099230d261c
7
+ data.tar.gz: e4b197451e71cc877398d6ce00130d1ac8dc0f2d73878b05dae4f849f145d99ac7de3e4e8e098db574eeb0c528f67030005a3538ed219f2178fb3ee7bc94c6cc
data/CHANGELOG.md CHANGED
@@ -3,6 +3,14 @@
3
3
  All notable changes to AsciiChem are documented here.
4
4
  This project follows [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.29.1] - 2026-09-16
7
+
8
+ ### Added
9
+ - CLI `convert --engine parslet|parsanol` selects the parsing engine
10
+ per invocation (parslet stays the default); absent parsanol gem
11
+ exits 6 with install guidance. The ASCIICHEM_ENGINE env var keeps
12
+ working for programmatic selection.
13
+
6
14
  ## [0.29.0] - 2026-09-15
7
15
 
8
16
  ### Added
data/benchmarks/README.md CHANGED
@@ -104,6 +104,15 @@ fork-per-case gate so Rust aborts are reported, not fatal):
104
104
  but rejected under parslet (optimizer Str/Re run-merging semantics;
105
105
  no corpus case covers these spellings today).
106
106
 
107
+ ### Re-check 5 (2026-09-15, parsanol 1.3.17)
108
+
109
+ The ffi-gem cdylib tier (Rust engine on every runtime) changes
110
+ nothing for us: gate still **221/221**, 4.40 ms/i on the recheck
111
+ workload. The opt-in engine shipped in asciichem 0.29.0 is
112
+ unaffected; `@next_id` remains unfixed upstream but no longer fires
113
+ on corpus inputs. The re-check-3 verdict below is superseded — the
114
+ engine IS switchable and shipped (TODO.impl 64).
115
+
107
116
  **Verdict: one upstream one-liner from adoption evaluation.** With
108
117
  `@next_id += 1` fixed, the entire corpus passes under native at
109
118
  3x parslet speed — at that point the decision is whether to make the
@@ -1,25 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Parsanol re-check (parsanol-ruby 1.3.15, issue-25 thread): runs the
4
- # UNMODIFIED AsciiChem grammar over the Parsanol engine via the
5
- # Parslet compat shim, then (1) gates on the shared corpus, (2) gates
6
- # on the issue-25 EOF repro, (3) measures against the parslet path.
3
+ # Parsanol gate + benchmark against the SHIPPED opt-in engine
4
+ # (asciichem 0.29.0+): AsciiChem::Engine.use(:parsanol) runs the same
5
+ # GrammarRules/TransformRules over Parsanol's Rust-backed compat
6
+ # layer. (1) gates on the shared corpus, forked per case so a Rust
7
+ # panic aborts the child — reported, not fatal; (2) gates on the
8
+ # issue-25 EOF repro; (3) measures against the parslet path
9
+ # (benchmarks/engines.rb).
7
10
  #
8
- # Mode: native by default (PARSOLAN_MODE=native — default routing);
9
- # PARSANOL_MODE=ruby forces the pure-Ruby engine. As of 1.3.15 the
10
- # native backend parses 169/170 corpus accepts + all 51 rejects at
11
- # ~2.3x parslet speed; the single fatal case is embedded-math input,
12
- # which panics the Rust core on the known @next_id collision
13
- # (parsanol-ruby#25) — run that one in :ruby until upstream fixes it.
11
+ # Requires the parsanol gem (add to the Gemfile, or point -I at a
12
+ # local checkout):
14
13
  #
15
- # Run from asciichem-ruby/ with the shim dir prepended:
16
- # ruby -I /tmp/parsanol_spike -I ../parsanol/parsanol-ruby/lib benchmarks/parsanol_recheck.rb
14
+ # bundle exec ruby -I ../parsanol/parsanol-ruby/lib benchmarks/parsanol_recheck.rb
15
+ #
16
+ # PARSANOL_MODE=ruby forces Parsanol's pure-Ruby backend (no Rust
17
+ # core) for comparison.
17
18
  require "benchmark/ips"
18
19
  require "asciichem"
20
+ require "asciichem/engine/parsanol_engine"
19
21
  require "json"
20
22
 
21
- puts "parsanol #{Parsanol::VERSION} | parslet-compat Parser=#{Parsanol::Parslet::Parser}"
22
- puts "AsciiChem::Grammar superclass: #{AsciiChem::Grammar.superclass}"
23
+ AsciiChem::Engine.use(:parsanol)
24
+
25
+ engine = AsciiChem::Engine.current
26
+ puts "parsanol #{Parsanol::VERSION} | engine: #{engine}"
27
+ puts "grammar superclass: #{engine.grammar.superclass}"
23
28
  puts "mode: #{ENV.fetch("PARSANOL_MODE", "native")}"
24
29
 
25
30
  if ENV.fetch("PARSANOL_MODE", "native") == "ruby"
data/lib/asciichem/cli.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "thor"
3
+ require 'thor'
4
4
 
5
5
  module AsciiChem
6
6
  # Thor-based command line interface. Invoked via the `asciichem`
@@ -8,25 +8,30 @@ module AsciiChem
8
8
  class Cli < Thor
9
9
  # Use lowercase 'asciichem' as the program name in help output
10
10
  # and command banners, matching the executable name.
11
- package_name "asciichem"
11
+ package_name 'asciichem'
12
12
 
13
- desc "convert -i INPUT -t FORMAT", "Convert INPUT to FORMAT (mathml|text|html|latex|svg|structural-svg|model-json|cml|smiles|molfile)"
14
- method_option :input, aliases: "-i", type: :string,
15
- desc: "Source text (or '-' for stdin)"
16
- method_option :file, aliases: "-f", type: :string,
17
- desc: "Read source from a file"
18
- method_option :from, type: :string, default: "asciichem",
19
- desc: "Input grammar: asciichem|smiles|molfile"
20
- method_option :format, aliases: "-t", type: :string, default: "mathml",
21
- desc: "Output format"
13
+ desc 'convert -i INPUT -t FORMAT',
14
+ 'Convert INPUT to FORMAT (mathml|text|html|latex|svg|structural-svg|model-json|cml|smiles|molfile)'
15
+ method_option :input, aliases: '-i', type: :string,
16
+ desc: "Source text (or '-' for stdin)"
17
+ method_option :file, aliases: '-f', type: :string,
18
+ desc: 'Read source from a file'
19
+ method_option :from, type: :string, default: 'asciichem',
20
+ desc: 'Input grammar: asciichem|smiles|molfile'
21
+ method_option :format, aliases: '-t', type: :string, default: 'mathml',
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)'
22
25
  def convert
23
- unless options["input"] || options["file"]
24
- raise AsciiChem::ParseError, "provide -i INPUT or -f FILE"
25
- end
26
+ raise AsciiChem::ParseError, 'provide -i INPUT or -f FILE' unless options['input'] || options['file']
26
27
 
28
+ select_engine(options[:engine])
27
29
  source = read_source
28
30
  formula = ingest(source, options[:from])
29
31
  puts render(formula, options[:format])
32
+ rescue AsciiChem::Engine::Error => e
33
+ warn "Engine error: #{e.message}"
34
+ exit 6
30
35
  rescue AsciiChem::ParseError => e
31
36
  warn "Parse error: #{e.message}"
32
37
  exit 1
@@ -35,9 +40,9 @@ module AsciiChem
35
40
  exit 2
36
41
  end
37
42
 
38
- desc "parse-cml -i INPUT", "Parse CML XML and emit AsciiChem text"
39
- method_option :input, aliases: "-i", type: :string, required: true,
40
- desc: "CML XML source"
43
+ desc 'parse-cml -i INPUT', 'Parse CML XML and emit AsciiChem text'
44
+ method_option :input, aliases: '-i', type: :string, required: true,
45
+ desc: 'CML XML source'
41
46
  def parse_cml
42
47
  formula = AsciiChem::Cml.parse(options[:input])
43
48
  puts formula.to_text
@@ -46,8 +51,8 @@ module AsciiChem
46
51
  exit 1
47
52
  end
48
53
 
49
- desc "roundtrip -i INPUT", "Parse and re-emit; exit non-zero if not equal"
50
- method_option :input, aliases: "-i", type: :string, required: true
54
+ desc 'roundtrip -i INPUT', 'Parse and re-emit; exit non-zero if not equal'
55
+ method_option :input, aliases: '-i', type: :string, required: true
51
56
  def roundtrip
52
57
  original = options[:input]
53
58
  rendered = AsciiChem.parse(original).to_text
@@ -60,11 +65,11 @@ module AsciiChem
60
65
  end
61
66
  end
62
67
 
63
- desc "lint -i INPUT", "Run chemistry checks; exit 1 on error, 0 if clean"
64
- method_option :input, aliases: "-i", type: :string, required: true,
65
- desc: "AsciiChem source text"
66
- method_option :format, aliases: "-f", type: :string, default: "text",
67
- desc: "Output format: text or json"
68
+ desc 'lint -i INPUT', 'Run chemistry checks; exit 1 on error, 0 if clean'
69
+ method_option :input, aliases: '-i', type: :string, required: true,
70
+ desc: 'AsciiChem source text'
71
+ method_option :format, aliases: '-f', type: :string, default: 'text',
72
+ desc: 'Output format: text or json'
68
73
  def lint
69
74
  formula = AsciiChem.parse(options[:input])
70
75
  diagnostics = AsciiChem::Linter.run(formula)
@@ -76,7 +81,7 @@ module AsciiChem
76
81
  end
77
82
 
78
83
  map %w[--version -v] => :version
79
- desc "version", "Print the asciichem version"
84
+ desc 'version', 'Print the asciichem version'
80
85
  def version
81
86
  puts "asciichem #{AsciiChem::VERSION}"
82
87
  end
@@ -86,32 +91,32 @@ module AsciiChem
86
91
  "asciichem #{command.usage}"
87
92
  end
88
93
 
89
- desc "resolve --cas X | --name X | ...", "Resolve a substance from a source (network; cached)"
90
- method_option :cas, type: :string, desc: "CAS registry number"
91
- method_option :name, type: :string, desc: "Substance name"
92
- method_option :cid, type: :string, desc: "PubChem CID"
93
- method_option :inchikey, type: :string, desc: "InChIKey"
94
- method_option :smiles, type: :string, desc: "SMILES"
95
- method_option :source, type: :string, default: "pubchem", desc: "Resolver source"
96
- method_option :refresh, type: :boolean, default: false, desc: "Bypass the cache"
97
- method_option :format, aliases: "-t", type: :string, default: "model-json",
98
- desc: "Output: model-json | text | smiles"
94
+ desc 'resolve --cas X | --name X | ...', 'Resolve a substance from a source (network; cached)'
95
+ method_option :cas, type: :string, desc: 'CAS registry number'
96
+ method_option :name, type: :string, desc: 'Substance name'
97
+ method_option :cid, type: :string, desc: 'PubChem CID'
98
+ method_option :inchikey, type: :string, desc: 'InChIKey'
99
+ method_option :smiles, type: :string, desc: 'SMILES'
100
+ method_option :source, type: :string, default: 'pubchem', desc: 'Resolver source'
101
+ method_option :refresh, type: :boolean, default: false, desc: 'Bypass the cache'
102
+ method_option :format, aliases: '-t', type: :string, default: 'model-json',
103
+ desc: 'Output: model-json | text | smiles'
99
104
  def resolve
100
105
  convention, value = %i[cas name cid inchikey smiles]
101
106
  .filter_map { |k| [k, options[k.to_s]] if options[k.to_s] }
102
107
  .first
103
- raise AsciiChem::Error, "give one of --cas/--name/--cid/--inchikey/--smiles" unless value
108
+ raise AsciiChem::Error, 'give one of --cas/--name/--cid/--inchikey/--smiles' unless value
104
109
 
105
- convention = { cas: "cas", name: "name", cid: "pubchem-cid",
106
- inchikey: "inchikey", smiles: "smiles" }.fetch(convention)
110
+ convention = { cas: 'cas', name: 'name', cid: 'pubchem-cid',
111
+ inchikey: 'inchikey', smiles: 'smiles' }.fetch(convention)
107
112
  substance = AsciiChem::Resolver[options[:source]].new.resolve(
108
113
  value: value, convention: convention, refresh: options[:refresh]
109
114
  )
110
115
  raise AsciiChem::Error, "#{options[:source]} does not know #{value.inspect}" unless substance
111
116
 
112
117
  puts case options[:format].to_s
113
- when "text" then substance.preferred_name.to_s
114
- when "smiles" then substance.identifier_value("canonical-smiles").to_s
118
+ when 'text' then substance.preferred_name.to_s
119
+ when 'smiles' then substance.identifier_value('canonical-smiles').to_s
115
120
  else substance.to_model_json
116
121
  end
117
122
  rescue AsciiChem::Error => e
@@ -119,22 +124,22 @@ module AsciiChem
119
124
  exit 3
120
125
  end
121
126
 
122
- desc "cite --cas X | --name X | ...", "Resolve a substance and emit a dataset-type Relaton bibitem (XML)"
123
- method_option :cas, type: :string, desc: "CAS registry number"
124
- method_option :name, type: :string, desc: "Substance name"
125
- method_option :cid, type: :string, desc: "PubChem CID"
126
- method_option :inchikey, type: :string, desc: "InChIKey"
127
- method_option :smiles, type: :string, desc: "SMILES"
128
- method_option :source, type: :string, default: "pubchem", desc: "Resolver source"
129
- method_option :refresh, type: :boolean, default: false, desc: "Bypass the cache"
127
+ desc 'cite --cas X | --name X | ...', 'Resolve a substance and emit a dataset-type Relaton bibitem (XML)'
128
+ method_option :cas, type: :string, desc: 'CAS registry number'
129
+ method_option :name, type: :string, desc: 'Substance name'
130
+ method_option :cid, type: :string, desc: 'PubChem CID'
131
+ method_option :inchikey, type: :string, desc: 'InChIKey'
132
+ method_option :smiles, type: :string, desc: 'SMILES'
133
+ method_option :source, type: :string, default: 'pubchem', desc: 'Resolver source'
134
+ method_option :refresh, type: :boolean, default: false, desc: 'Bypass the cache'
130
135
  def cite
131
136
  convention, value = %i[cas name cid inchikey smiles]
132
137
  .filter_map { |k| [k, options[k.to_s]] if options[k.to_s] }
133
138
  .first
134
- raise AsciiChem::Error, "give one of --cas/--name/--cid/--inchikey/--smiles" unless value
139
+ raise AsciiChem::Error, 'give one of --cas/--name/--cid/--inchikey/--smiles' unless value
135
140
 
136
- convention = { cas: "cas", name: "name", cid: "pubchem-cid",
137
- inchikey: "inchikey", smiles: "smiles" }.fetch(convention)
141
+ convention = { cas: 'cas', name: 'name', cid: 'pubchem-cid',
142
+ inchikey: 'inchikey', smiles: 'smiles' }.fetch(convention)
138
143
  substance = AsciiChem::Resolver[options[:source]].new.resolve(
139
144
  value: value, convention: convention, refresh: options[:refresh]
140
145
  )
@@ -146,37 +151,43 @@ module AsciiChem
146
151
  exit 4
147
152
  end
148
153
 
149
- desc "validate -i INPUT", "Offline identifier validation"
150
- method_option :input, aliases: "-i", type: :string, required: true
154
+ desc 'validate -i INPUT', 'Offline identifier validation'
155
+ method_option :input, aliases: '-i', type: :string, required: true
151
156
  def validate
152
157
  formula = AsciiChem.parse(options[:input])
153
158
  annotations = formula.nodes.grep(AsciiChem::Model::Molecule).flat_map(&:identifiers)
154
159
  if annotations.empty?
155
- puts "no identifier annotations found"
160
+ puts 'no identifier annotations found'
156
161
  return
157
162
  end
158
163
  annotations.each do |identifier|
159
164
  known = AsciiChem::Identifiers.known?(identifier.convention)
160
165
  valid = known && AsciiChem::Identifiers.valid?(identifier.convention, identifier.value)
161
- status = known ? (valid ? "ok" : "INVALID") : "unknown convention"
162
- puts format("%-12s %-40s %s", identifier.convention, identifier.value, status)
166
+ status = if known
167
+ valid ? 'ok' : 'INVALID'
168
+ else
169
+ 'unknown convention'
170
+ end
171
+ puts format('%-12s %-40s %s', identifier.convention, identifier.value, status)
172
+ end
173
+ exit 1 if annotations.any? do |i|
174
+ AsciiChem::Identifiers.known?(i.convention) &&
175
+ !AsciiChem::Identifiers.valid?(i.convention, i.value)
163
176
  end
164
- exit 1 if annotations.any? { |i| AsciiChem::Identifiers.known?(i.convention) &&
165
- !AsciiChem::Identifiers.valid?(i.convention, i.value) }
166
177
  rescue AsciiChem::ParseError => e
167
178
  warn "Parse error: #{e.message}"
168
179
  exit 1
169
180
  end
170
181
 
171
- desc "identity -i INPUT", "Derive InChI/InChIKey locally from the structure (offline; requires an InChI engine)"
172
- method_option :input, aliases: "-i", type: :string,
173
- desc: "Source text (or '-' for stdin)"
174
- method_option :file, aliases: "-f", type: :string,
175
- desc: "Read source from a file"
176
- method_option :from, type: :string, default: "asciichem",
177
- desc: "Input grammar: asciichem|smiles|molfile"
182
+ desc 'identity -i INPUT', 'Derive InChI/InChIKey locally from the structure (offline; requires an InChI engine)'
183
+ method_option :input, aliases: '-i', type: :string,
184
+ desc: "Source text (or '-' for stdin)"
185
+ method_option :file, aliases: '-f', type: :string,
186
+ desc: 'Read source from a file'
187
+ method_option :from, type: :string, default: 'asciichem',
188
+ desc: 'Input grammar: asciichem|smiles|molfile'
178
189
  method_option :engine_bin, type: :string,
179
- desc: "Path to the inchi-1 binary (overrides the configured engine)"
190
+ desc: 'Path to the inchi-1 binary (overrides the configured engine)'
180
191
  def identity
181
192
  formula = ingest(read_source, options[:from])
182
193
  molecules = molecules_in(formula)
@@ -195,9 +206,18 @@ module AsciiChem
195
206
 
196
207
  private
197
208
 
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
+
198
218
  def read_source
199
219
  return File.read(options[:file]) if options[:file]
200
- return $stdin.read if options[:input] == "-"
220
+ return $stdin.read if options[:input] == '-'
201
221
 
202
222
  options[:input]
203
223
  end
@@ -207,9 +227,9 @@ module AsciiChem
207
227
  # format works regardless of the input language.
208
228
  def ingest(source, from)
209
229
  case from.to_s
210
- when "asciichem" then AsciiChem.parse(source)
211
- when "smiles" then AsciiChem.parse_smiles(source)
212
- when "molfile" then molfile_formula(source)
230
+ when 'asciichem' then AsciiChem.parse(source)
231
+ when 'smiles' then AsciiChem.parse_smiles(source)
232
+ when 'molfile' then molfile_formula(source)
213
233
  else
214
234
  raise AsciiChem::ParseError, "unknown --from grammar: #{from}"
215
235
  end
@@ -224,7 +244,7 @@ module AsciiChem
224
244
 
225
245
  def render(formula, format)
226
246
  return formula.to_cml if format.to_sym == :cml
227
- return formula.to_model_json if format.to_sym == :"model-json"
247
+ return formula.to_model_json if format.to_sym == :'model-json'
228
248
  return formula.to_smiles if format.to_sym == :smiles
229
249
  return formula.nodes.first.to_molfile if format.to_sym == :molfile
230
250
 
@@ -246,14 +266,14 @@ module AsciiChem
246
266
 
247
267
  def output_lint(diagnostics, format)
248
268
  case format.to_s
249
- when "json" then output_lint_json(diagnostics)
269
+ when 'json' then output_lint_json(diagnostics)
250
270
  else
251
271
  diagnostics.each { |d| puts d }
252
272
  end
253
273
  end
254
274
 
255
275
  def output_lint_json(diagnostics)
256
- require "json"
276
+ require 'json'
257
277
  payload = diagnostics.map do |d|
258
278
  {
259
279
  severity: d.severity.to_s,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AsciiChem
4
- VERSION = "0.29.0"
4
+ VERSION = "0.29.1"
5
5
  end
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.29.0
4
+ version: 0.29.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.