asciichem 0.3.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 +7 -0
- data/.github/workflows/ci.yml +21 -0
- data/.gitignore +14 -0
- data/.rubocop.yml +50 -0
- data/ARCHITECTURE.adoc +239 -0
- data/CHANGELOG.md +17 -0
- data/CLAUDE.md +318 -0
- data/Gemfile +14 -0
- data/LICENSE +24 -0
- data/README.adoc +55 -0
- data/RELEASING.md +102 -0
- data/Rakefile +8 -0
- data/asciichem.gemspec +39 -0
- data/benchmarks/RESULTS.md +49 -0
- data/benchmarks/benchmark.rb +106 -0
- data/exe/asciichem +6 -0
- data/lib/asciichem/cli.rb +96 -0
- data/lib/asciichem/cml/extensions.rb +360 -0
- data/lib/asciichem/cml/group_extensions.rb +290 -0
- data/lib/asciichem/cml/translator.rb +69 -0
- data/lib/asciichem/cml.rb +40 -0
- data/lib/asciichem/errors.rb +9 -0
- data/lib/asciichem/formatter/base.rb +21 -0
- data/lib/asciichem/formatter/html.rb +111 -0
- data/lib/asciichem/formatter/latex.rb +173 -0
- data/lib/asciichem/formatter/mathml.rb +309 -0
- data/lib/asciichem/formatter/structural_svg.rb +286 -0
- data/lib/asciichem/formatter/svg.rb +141 -0
- data/lib/asciichem/formatter/text.rb +143 -0
- data/lib/asciichem/formatter.rb +43 -0
- data/lib/asciichem/grammar.rb +344 -0
- data/lib/asciichem/greek.rb +80 -0
- data/lib/asciichem/layout.rb +313 -0
- data/lib/asciichem/linter/balance_check.rb +103 -0
- data/lib/asciichem/linter/base.rb +52 -0
- data/lib/asciichem/linter/bracket_balance_check.rb +44 -0
- data/lib/asciichem/linter/diagnostic.rb +16 -0
- data/lib/asciichem/linter/element_validation_check.rb +39 -0
- data/lib/asciichem/linter/isotope_sanity_check.rb +47 -0
- data/lib/asciichem/linter/registry.rb +31 -0
- data/lib/asciichem/linter/unclosed_ring_check.rb +27 -0
- data/lib/asciichem/linter/valence_check.rb +91 -0
- data/lib/asciichem/linter.rb +40 -0
- data/lib/asciichem/model/atom.rb +73 -0
- data/lib/asciichem/model/bond.rb +41 -0
- data/lib/asciichem/model/electron_configuration.rb +36 -0
- data/lib/asciichem/model/embedded_math.rb +26 -0
- data/lib/asciichem/model/formula.rb +31 -0
- data/lib/asciichem/model/group.rb +50 -0
- data/lib/asciichem/model/identifier.rb +14 -0
- data/lib/asciichem/model/molecule.rb +70 -0
- data/lib/asciichem/model/name.rb +15 -0
- data/lib/asciichem/model/node.rb +100 -0
- data/lib/asciichem/model/reaction.rb +49 -0
- data/lib/asciichem/model/reaction_cascade.rb +33 -0
- data/lib/asciichem/model/text.rb +23 -0
- data/lib/asciichem/model.rb +23 -0
- data/lib/asciichem/model_adapter/from_canonical.rb +272 -0
- data/lib/asciichem/model_adapter/to_canonical.rb +429 -0
- data/lib/asciichem/model_adapter.rb +56 -0
- data/lib/asciichem/parser.rb +36 -0
- data/lib/asciichem/periodic_table.rb +112 -0
- data/lib/asciichem/ring_bonds.rb +68 -0
- data/lib/asciichem/transform.rb +513 -0
- data/lib/asciichem/version.rb +5 -0
- data/lib/asciichem/xml_builder.rb +9 -0
- data/lib/asciichem.rb +34 -0
- metadata +197 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Linter
|
|
5
|
+
# Check registry. Each check self-registers at file-load time via
|
|
6
|
+
# `Registry.add(:name, Klass)`. The registry is a Hash; iteration
|
|
7
|
+
# order is insertion order.
|
|
8
|
+
module Registry
|
|
9
|
+
@checks = []
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def add(name, klass)
|
|
13
|
+
@checks << [name, klass] unless @checks.any? { |n, _k| n == name }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def all
|
|
17
|
+
@checks.map { |(_name, klass)| klass }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def names
|
|
21
|
+
@checks.map { |(name, _klass)| name }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# For testing: clear the registry.
|
|
25
|
+
def reset
|
|
26
|
+
@checks.clear
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Linter
|
|
5
|
+
# Flags ring closure digits that have no matching partner. Catches
|
|
6
|
+
# typos like `C1-C-C` (digit 1 opened but never closed). Uses
|
|
7
|
+
# `AsciiChem::RingBonds.unclosed_atoms` as the source of truth.
|
|
8
|
+
class UnclosedRingCheck < Base
|
|
9
|
+
register :unclosed_ring
|
|
10
|
+
|
|
11
|
+
def run(formula)
|
|
12
|
+
diagnostics = []
|
|
13
|
+
walk(formula) do |node|
|
|
14
|
+
next unless node.is_a?(AsciiChem::Model::Molecule)
|
|
15
|
+
|
|
16
|
+
AsciiChem::RingBonds.unclosed_atoms(node).each do |atom|
|
|
17
|
+
diagnostics << error(
|
|
18
|
+
"Atom has unmatched ring closure digit(s) #{atom.ring_closures.inspect}",
|
|
19
|
+
node: atom
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
diagnostics
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Linter
|
|
5
|
+
# Verifies that each atom's total bond order plus |charge| does not
|
|
6
|
+
# exceed the element's typical valence. Catches typos like `CH_5`
|
|
7
|
+
# (carbon with five bonds).
|
|
8
|
+
#
|
|
9
|
+
# The valence table lists common valences per element. Unknown
|
|
10
|
+
# elements produce an info diagnostic, not an error.
|
|
11
|
+
class ValenceCheck < Base
|
|
12
|
+
register :valence
|
|
13
|
+
|
|
14
|
+
def run(formula)
|
|
15
|
+
diagnostics = []
|
|
16
|
+
# Walk molecules so we know which atom is the bonding context.
|
|
17
|
+
walk(formula) do |node|
|
|
18
|
+
next unless node.is_a?(AsciiChem::Model::Molecule)
|
|
19
|
+
|
|
20
|
+
check_molecule(node, diagnostics)
|
|
21
|
+
end
|
|
22
|
+
diagnostics
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def check_molecule(molecule, diagnostics)
|
|
28
|
+
# Walk the molecule's nodes; pair each atom with the bonds
|
|
29
|
+
# immediately adjacent to it in the chain.
|
|
30
|
+
bond_count = 0
|
|
31
|
+
molecule.children.each do |node|
|
|
32
|
+
case node
|
|
33
|
+
when AsciiChem::Model::Bond
|
|
34
|
+
bond_count += bond_order(node)
|
|
35
|
+
when AsciiChem::Model::Atom
|
|
36
|
+
check_atom(node, bond_count, diagnostics)
|
|
37
|
+
bond_count = 0
|
|
38
|
+
when AsciiChem::Model::Group
|
|
39
|
+
# Group's internal bonds aren't visible here; reset.
|
|
40
|
+
bond_count = 0
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def bond_order(bond)
|
|
46
|
+
case bond.kind
|
|
47
|
+
when :single then 1
|
|
48
|
+
when :double then 2
|
|
49
|
+
when :triple then 3
|
|
50
|
+
when :quadruple then 4
|
|
51
|
+
else 1
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def check_atom(atom, incoming_bond_order, diagnostics)
|
|
56
|
+
max = AsciiChem::PeriodicTable.max_valence(atom.element)
|
|
57
|
+
if max.nil?
|
|
58
|
+
diagnostics << info(
|
|
59
|
+
"Element #{atom.element.inspect} not in valence table; skipping",
|
|
60
|
+
node: atom
|
|
61
|
+
)
|
|
62
|
+
return
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Approximate: an atom with subscript has multiplicity, not
|
|
66
|
+
# extra bonds. We use incoming_bond_order + |charge| as the
|
|
67
|
+
# rough load.
|
|
68
|
+
charge = atom.charge.to_s
|
|
69
|
+
charge_value = parse_charge_magnitude(charge)
|
|
70
|
+
load = incoming_bond_order + charge_value
|
|
71
|
+
return if load <= max
|
|
72
|
+
|
|
73
|
+
diagnostics << error(
|
|
74
|
+
"Atom #{atom.element} has load #{load} (bond order #{incoming_bond_order} + charge #{charge_value}); max valence is #{max}",
|
|
75
|
+
node: atom
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def parse_charge_magnitude(charge)
|
|
80
|
+
return 0 if charge.nil? || charge.empty?
|
|
81
|
+
|
|
82
|
+
match = charge.match(/\A(?<n>\d*)(?<sign>[+-])\z/) ||
|
|
83
|
+
charge.match(/\A(?<sign>[+-])(?<n>\d*)\z/)
|
|
84
|
+
return 0 unless match
|
|
85
|
+
|
|
86
|
+
n = match[:n].empty? ? 1 : match[:n].to_i
|
|
87
|
+
n
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
# Linter framework. The parser is total — it accepts any syntactically
|
|
5
|
+
# valid input without judging chemistry correctness. The linter is a
|
|
6
|
+
# separate opt-in pass that walks the model and reports chemistry
|
|
7
|
+
# errors (unbalanced reactions, valence violations, etc.).
|
|
8
|
+
#
|
|
9
|
+
# Checks register themselves via `Linter.register(:name, Klass)`. The
|
|
10
|
+
# registry is open for extension; adding a new check is a single new
|
|
11
|
+
# file plus one autoload entry.
|
|
12
|
+
module Linter
|
|
13
|
+
autoload :Base, "asciichem/linter/base"
|
|
14
|
+
autoload :BalanceCheck, "asciichem/linter/balance_check"
|
|
15
|
+
autoload :BracketBalanceCheck, "asciichem/linter/bracket_balance_check"
|
|
16
|
+
autoload :ElementValidationCheck, "asciichem/linter/element_validation_check"
|
|
17
|
+
autoload :Diagnostic, "asciichem/linter/diagnostic"
|
|
18
|
+
autoload :IsotopeSanityCheck, "asciichem/linter/isotope_sanity_check"
|
|
19
|
+
autoload :Registry, "asciichem/linter/registry"
|
|
20
|
+
autoload :UnclosedRingCheck, "asciichem/linter/unclosed_ring_check"
|
|
21
|
+
autoload :ValenceCheck, "asciichem/linter/valence_check"
|
|
22
|
+
|
|
23
|
+
SEVERITIES = %i[error warning info].freeze
|
|
24
|
+
|
|
25
|
+
# Run all registered checks against the model. Returns an array of
|
|
26
|
+
# Diagnostic objects (empty if no issues).
|
|
27
|
+
def self.run(formula)
|
|
28
|
+
Registry.all.flat_map { |check| check.new.run(formula) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Convenience: returns true if any check produced an error.
|
|
32
|
+
def self.errors?(formula)
|
|
33
|
+
run(formula).any? { |d| d.severity == :error }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Eagerly trigger every autoload in this module so check files can
|
|
37
|
+
# self-register. Runs once at module-load time.
|
|
38
|
+
constants.each { |name| const_get(name) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Model
|
|
5
|
+
# A chemical atom: element symbol plus optional isotope, charge,
|
|
6
|
+
# oxidation state, Lewis markers (lone pairs, radicals), and ring
|
|
7
|
+
# closure digits.
|
|
8
|
+
#
|
|
9
|
+
# The semantic fix over AsciiMath: the prefix isotope (e.g. `^14` in
|
|
10
|
+
# `^14C`) binds to THIS atom as the `isotope` field, not to a phantom
|
|
11
|
+
# preceding element. The transform enforces the binding.
|
|
12
|
+
#
|
|
13
|
+
# Ring closures (SMILES-style): a digit suffix on an atom opens or
|
|
14
|
+
# closes a ring. Two atoms with the same digit become bonded.
|
|
15
|
+
# `C1-C-C-C-C-C1` is cyclohexane. The `ring_closures` field carries
|
|
16
|
+
# the digit string (e.g. `"1"`); multiple digits mean multiple
|
|
17
|
+
# open/close points (e.g. `"12"` opens/closes rings 1 and 2).
|
|
18
|
+
class Atom < Node
|
|
19
|
+
attr_accessor :element, :isotope, :subscript, :superscript,
|
|
20
|
+
:charge, :oxidation_state,
|
|
21
|
+
:lone_pairs, :radical_electrons,
|
|
22
|
+
:ring_closures,
|
|
23
|
+
:x2, :y2, :z2, :atom_parity
|
|
24
|
+
|
|
25
|
+
def initialize(element:, isotope: nil, subscript: nil,
|
|
26
|
+
superscript: nil, charge: nil, oxidation_state: nil,
|
|
27
|
+
lone_pairs: nil, radical_electrons: nil,
|
|
28
|
+
ring_closures: nil,
|
|
29
|
+
x2: nil, y2: nil, z2: nil, atom_parity: nil)
|
|
30
|
+
@element = element
|
|
31
|
+
@isotope = isotope
|
|
32
|
+
@subscript = subscript
|
|
33
|
+
@superscript = superscript
|
|
34
|
+
@charge = charge
|
|
35
|
+
@oxidation_state = oxidation_state
|
|
36
|
+
@lone_pairs = lone_pairs
|
|
37
|
+
@radical_electrons = radical_electrons
|
|
38
|
+
@ring_closures = ring_closures
|
|
39
|
+
@x2 = x2
|
|
40
|
+
@y2 = y2
|
|
41
|
+
@z2 = z2
|
|
42
|
+
@atom_parity = atom_parity
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def value_attributes
|
|
46
|
+
{ element: element, isotope: isotope, subscript: subscript,
|
|
47
|
+
superscript: superscript, charge: charge,
|
|
48
|
+
oxidation_state: oxidation_state,
|
|
49
|
+
lone_pairs: lone_pairs, radical_electrons: radical_electrons,
|
|
50
|
+
ring_closures: ring_closures,
|
|
51
|
+
x2: x2, y2: y2, z2: z2, atom_parity: atom_parity }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def diagnostic_label
|
|
55
|
+
"Atom(#{element})"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def to_s
|
|
59
|
+
parts = []
|
|
60
|
+
parts << ":#{lone_pairs}" if lone_pairs
|
|
61
|
+
parts << element.to_s
|
|
62
|
+
parts << "_#{subscript}" if subscript
|
|
63
|
+
parts << "^#{isotope}" if isotope
|
|
64
|
+
parts << "^#{superscript}" if superscript
|
|
65
|
+
parts << "^#{charge}" if charge
|
|
66
|
+
parts << "^(#{oxidation_state})" if oxidation_state
|
|
67
|
+
parts << ".#{radical_electrons}" if radical_electrons
|
|
68
|
+
parts << ring_closures.to_s if ring_closures
|
|
69
|
+
"Atom(#{parts.join})"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Model
|
|
5
|
+
# A bond between two adjacent nodes in a structural chain.
|
|
6
|
+
class Bond < Node
|
|
7
|
+
attr_accessor :kind
|
|
8
|
+
|
|
9
|
+
KINDS = {
|
|
10
|
+
single: { ascii: "-", mathml_entity: "-" },
|
|
11
|
+
double: { ascii: "=", mathml_entity: "=" },
|
|
12
|
+
triple: { ascii: "#", mathml_entity: "≡" },
|
|
13
|
+
quadruple: { ascii: "##", mathml_entity: "≣" },
|
|
14
|
+
wedge: { ascii: ">-", mathml_entity: "↑" },
|
|
15
|
+
hash: { ascii: "-<", mathml_entity: "↓" },
|
|
16
|
+
dative: { ascii: "~>", mathml_entity: "→" },
|
|
17
|
+
wavy: { ascii: "~~", mathml_entity: "∼" }
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(kind: :single)
|
|
21
|
+
@kind = kind
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def value_attributes
|
|
25
|
+
{ kind: kind }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ascii
|
|
29
|
+
KINDS.fetch(kind).fetch(:ascii)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def entity
|
|
33
|
+
KINDS.fetch(kind).fetch(:mathml_entity)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_s
|
|
37
|
+
"Bond(#{kind})"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Model
|
|
5
|
+
# Orbital occupancy: `1s^2 2s^2 2p^6`. Each entry is `[orbital,
|
|
6
|
+
# occupancy]` where orbital is the string label (`"1s"`, `"2p"`) and
|
|
7
|
+
# occupancy is the electron count as a string.
|
|
8
|
+
#
|
|
9
|
+
# Term symbols (^{multiplicity}L_J) live on the optional
|
|
10
|
+
# `term_symbol` field.
|
|
11
|
+
class ElectronConfiguration < Node
|
|
12
|
+
TermSymbol = Struct.new(:multiplicity, :letter, :j_value, keyword_init: true) do
|
|
13
|
+
def to_s
|
|
14
|
+
"^#{multiplicity}#{letter}_#{j_value}"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_accessor :orbitals, :term_symbol
|
|
19
|
+
|
|
20
|
+
def initialize(orbitals:, term_symbol: nil)
|
|
21
|
+
@orbitals = orbitals
|
|
22
|
+
@term_symbol = term_symbol
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def value_attributes
|
|
26
|
+
{ orbitals: orbitals, term_symbol: term_symbol }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_s
|
|
30
|
+
parts = orbitals.map { |o, n| "#{o}^#{n}" }
|
|
31
|
+
parts << term_symbol.to_s if term_symbol
|
|
32
|
+
"ElectronConfig(#{parts.join(' ')})"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Model
|
|
5
|
+
# A run of mathematics embedded in chemistry. Wraps a
|
|
6
|
+
# `Plurimath::Math::Formula` so we never reinvent math typography.
|
|
7
|
+
#
|
|
8
|
+
# Source spelling: backtick-delimited, e.g. `` `K_c = [P]/[R]` ``.
|
|
9
|
+
class EmbeddedMath < Node
|
|
10
|
+
attr_accessor :formula, :source
|
|
11
|
+
|
|
12
|
+
def initialize(formula:, source: nil)
|
|
13
|
+
@formula = formula
|
|
14
|
+
@source = source
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def value_attributes
|
|
18
|
+
{ formula: formula, source: source }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_s
|
|
22
|
+
"EmbeddedMath(#{source || formula.inspect})"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Model
|
|
5
|
+
# Top-level container: the root of every AsciiChem parse.
|
|
6
|
+
class Formula < Node
|
|
7
|
+
attr_accessor :nodes
|
|
8
|
+
|
|
9
|
+
def initialize(nodes: [])
|
|
10
|
+
@nodes = nodes
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def <<(node)
|
|
14
|
+
nodes << node
|
|
15
|
+
self
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def value_attributes
|
|
19
|
+
{ nodes: nodes }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def children
|
|
23
|
+
nodes
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_s
|
|
27
|
+
"Formula[#{nodes.map(&:to_s).join(', ')}]"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Model
|
|
5
|
+
# A parenthesised sub-formula with an outer multiplicity.
|
|
6
|
+
class Group < Node
|
|
7
|
+
attr_accessor :nodes, :multiplicity, :bracket
|
|
8
|
+
|
|
9
|
+
def initialize(nodes:, multiplicity: nil, bracket: :paren)
|
|
10
|
+
@nodes = nodes
|
|
11
|
+
@multiplicity = multiplicity
|
|
12
|
+
@bracket = bracket
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def value_attributes
|
|
16
|
+
{ nodes: nodes, multiplicity: multiplicity, bracket: bracket }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def children
|
|
20
|
+
nodes
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_s
|
|
24
|
+
open, close = brackets
|
|
25
|
+
inner = nodes.map(&:to_s).join(", ")
|
|
26
|
+
suffix = multiplicity ? "_#{multiplicity}" : ""
|
|
27
|
+
"Group#{open}#{inner}#{close}#{suffix}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def open_char
|
|
31
|
+
brackets.first
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def close_char
|
|
35
|
+
brackets.last
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def brackets
|
|
41
|
+
case bracket
|
|
42
|
+
when :paren then ["(", ")"]
|
|
43
|
+
when :square then ["[", "]"]
|
|
44
|
+
when :brace then ["{", "}"]
|
|
45
|
+
else ["(", ")"]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Model
|
|
5
|
+
# A molecule identifier. Maps to CML `<identifier>` element.
|
|
6
|
+
# Carries the identifier value plus `convention` (e.g. "inchi",
|
|
7
|
+
# "smiles", "cas") and optional `dict_ref`.
|
|
8
|
+
Identifier = Struct.new(:value, :convention, :dict_ref, keyword_init: true) do
|
|
9
|
+
def to_s
|
|
10
|
+
"#{convention}:#{value}"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Model
|
|
5
|
+
# A molecule: an ordered sequence of atoms/groups with optional
|
|
6
|
+
# leading stoichiometric coefficient, optional stereochemistry
|
|
7
|
+
# marker (R/S/E/Z/α/β), and optional names/identifiers for CML
|
|
8
|
+
# metadata round-trip.
|
|
9
|
+
class Molecule < Node
|
|
10
|
+
STEREO_LETTERS = {
|
|
11
|
+
"R" => :R,
|
|
12
|
+
"S" => :S,
|
|
13
|
+
"E" => :E,
|
|
14
|
+
"Z" => :Z,
|
|
15
|
+
"a" => :alpha,
|
|
16
|
+
"alpha" => :alpha,
|
|
17
|
+
"α" => :alpha,
|
|
18
|
+
"b" => :beta,
|
|
19
|
+
"beta" => :beta,
|
|
20
|
+
"β" => :beta
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
STEREO_TO_LETTER = {
|
|
24
|
+
R: "R", S: "S", E: "E", Z: "Z",
|
|
25
|
+
alpha: "alpha", beta: "beta"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
attr_accessor :coefficient, :nodes, :stereo, :names, :identifiers,
|
|
29
|
+
:title, :formulas, :properties, :labels, :metadata
|
|
30
|
+
|
|
31
|
+
def initialize(nodes:, coefficient: nil, stereo: nil,
|
|
32
|
+
names: [], identifiers: [], title: nil,
|
|
33
|
+
formulas: [], properties: [], labels: [], metadata: [])
|
|
34
|
+
@nodes = nodes
|
|
35
|
+
@coefficient = coefficient
|
|
36
|
+
@stereo = stereo
|
|
37
|
+
@names = names
|
|
38
|
+
@identifiers = identifiers
|
|
39
|
+
@title = title
|
|
40
|
+
@formulas = formulas
|
|
41
|
+
@properties = properties
|
|
42
|
+
@labels = labels
|
|
43
|
+
@metadata = metadata
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def value_attributes
|
|
47
|
+
{ nodes: nodes, coefficient: coefficient, stereo: stereo,
|
|
48
|
+
names: names, identifiers: identifiers, title: title,
|
|
49
|
+
formulas: formulas, properties: properties, labels: labels,
|
|
50
|
+
metadata: metadata }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def children
|
|
54
|
+
nodes
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def stereo_letter
|
|
58
|
+
STEREO_TO_LETTER.fetch(stereo) if stereo
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to_s
|
|
62
|
+
prefix = coefficient ? "#{coefficient}" : ""
|
|
63
|
+
stereo_str = stereo ? "(#{stereo_letter})-" : ""
|
|
64
|
+
name_str = names.empty? ? "" : " #{names.map(&:to_s).join(', ')}"
|
|
65
|
+
id_str = identifiers.empty? ? "" : " #{identifiers.map(&:to_s).join(', ')}"
|
|
66
|
+
"#{stereo_str}#{prefix}Molecule[#{nodes.map(&:to_s).join(', ')}]#{name_str}#{id_str}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Model
|
|
5
|
+
# A molecule name. Maps to CML `<name>` element. Carries the
|
|
6
|
+
# name content plus optional `convention` and `dict_ref`
|
|
7
|
+
# attributes for names sourced from dictionaries (IUPAC, CAS,
|
|
8
|
+
# trivial, etc.).
|
|
9
|
+
Name = Struct.new(:content, :convention, :dict_ref, keyword_init: true) do
|
|
10
|
+
def to_s
|
|
11
|
+
content.to_s
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Model
|
|
5
|
+
# Base class for every model node. Provides:
|
|
6
|
+
# - structural equality (class + declared attributes)
|
|
7
|
+
# - visitor dispatch (`accept`)
|
|
8
|
+
# - convenience formatter shortcuts (`to_mathml`, `to_text`, ...)
|
|
9
|
+
#
|
|
10
|
+
# Subclasses declare their comparable attributes by overriding
|
|
11
|
+
# `value_attributes` (returns a hash of `{ name => value }`). This
|
|
12
|
+
# avoids reflective instance-variable access and keeps equality
|
|
13
|
+
# correct as fields are added.
|
|
14
|
+
class Node
|
|
15
|
+
def accept(visitor)
|
|
16
|
+
visitor.public_send(:"visit_#{self.class.short_name}", self)
|
|
17
|
+
rescue NoMethodError => e
|
|
18
|
+
raise unless e.name == :"visit_#{self.class.short_name}"
|
|
19
|
+
|
|
20
|
+
raise NotImplementedError,
|
|
21
|
+
"#{visitor.class} does not implement visit_#{self.class.short_name}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def ==(other)
|
|
25
|
+
other.is_a?(self.class) && value_attributes == other.value_attributes
|
|
26
|
+
end
|
|
27
|
+
alias eql? ==
|
|
28
|
+
|
|
29
|
+
def hash
|
|
30
|
+
[self.class, value_attributes].hash
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_mathml
|
|
34
|
+
AsciiChem::Formatter.render(:mathml, self)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_text
|
|
38
|
+
AsciiChem::Formatter.render(:text, self)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_html
|
|
42
|
+
AsciiChem::Formatter.render(:html, self)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def to_latex
|
|
46
|
+
AsciiChem::Formatter.render(:latex, self)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_svg
|
|
50
|
+
AsciiChem::Formatter.render(:svg, self)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def to_cml
|
|
54
|
+
AsciiChem::Cml.from_asciichem(self)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Subclasses override to expose the attributes that participate in
|
|
58
|
+
# equality. Default: empty (so two bare Nodes are equal).
|
|
59
|
+
def value_attributes
|
|
60
|
+
{}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Child nodes for traversal. Default: no children. Container
|
|
64
|
+
# classes (Formula, Molecule, Group, Reaction, ReactionCascade)
|
|
65
|
+
# override to expose their contents; leaves (Atom, Bond,
|
|
66
|
+
# EmbeddedMath, Text) inherit the empty default.
|
|
67
|
+
#
|
|
68
|
+
# Used by Linter::Base#walk. Adding a new container class means
|
|
69
|
+
# defining `children` on it — no edits to the linter.
|
|
70
|
+
def children
|
|
71
|
+
[]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Snake-case form of the class basename, used to derive the visitor
|
|
75
|
+
# method (`Atom` -> `visit_atom`, `ElectronConfiguration` ->
|
|
76
|
+
# `visit_electron_configuration`). Memoised per class so the
|
|
77
|
+
# string transformation runs once per class, not once per visit.
|
|
78
|
+
def self.short_name
|
|
79
|
+
@short_name ||= begin
|
|
80
|
+
snake = name.split("::").last
|
|
81
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
82
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
83
|
+
snake.downcase
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Human-readable label for linter diagnostics. Default: the
|
|
88
|
+
# class basename with words separated by spaces
|
|
89
|
+
# (`"Reaction Cascade"`, `"Electron Configuration"`).
|
|
90
|
+
# Subclasses with identifying fields override (e.g. `Atom`
|
|
91
|
+
# includes its element symbol). Keeping this on the model keeps
|
|
92
|
+
# the linter OCP-clean — adding a new model class means
|
|
93
|
+
# overriding the method on that class, not editing
|
|
94
|
+
# `Linter::Diagnostic`'s case statement.
|
|
95
|
+
def diagnostic_label
|
|
96
|
+
self.class.short_name.split("_").map(&:capitalize).join(" ")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|