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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +21 -0
  3. data/.gitignore +14 -0
  4. data/.rubocop.yml +50 -0
  5. data/ARCHITECTURE.adoc +239 -0
  6. data/CHANGELOG.md +17 -0
  7. data/CLAUDE.md +318 -0
  8. data/Gemfile +14 -0
  9. data/LICENSE +24 -0
  10. data/README.adoc +55 -0
  11. data/RELEASING.md +102 -0
  12. data/Rakefile +8 -0
  13. data/asciichem.gemspec +39 -0
  14. data/benchmarks/RESULTS.md +49 -0
  15. data/benchmarks/benchmark.rb +106 -0
  16. data/exe/asciichem +6 -0
  17. data/lib/asciichem/cli.rb +96 -0
  18. data/lib/asciichem/cml/extensions.rb +360 -0
  19. data/lib/asciichem/cml/group_extensions.rb +290 -0
  20. data/lib/asciichem/cml/translator.rb +69 -0
  21. data/lib/asciichem/cml.rb +40 -0
  22. data/lib/asciichem/errors.rb +9 -0
  23. data/lib/asciichem/formatter/base.rb +21 -0
  24. data/lib/asciichem/formatter/html.rb +111 -0
  25. data/lib/asciichem/formatter/latex.rb +173 -0
  26. data/lib/asciichem/formatter/mathml.rb +309 -0
  27. data/lib/asciichem/formatter/structural_svg.rb +286 -0
  28. data/lib/asciichem/formatter/svg.rb +141 -0
  29. data/lib/asciichem/formatter/text.rb +143 -0
  30. data/lib/asciichem/formatter.rb +43 -0
  31. data/lib/asciichem/grammar.rb +344 -0
  32. data/lib/asciichem/greek.rb +80 -0
  33. data/lib/asciichem/layout.rb +313 -0
  34. data/lib/asciichem/linter/balance_check.rb +103 -0
  35. data/lib/asciichem/linter/base.rb +52 -0
  36. data/lib/asciichem/linter/bracket_balance_check.rb +44 -0
  37. data/lib/asciichem/linter/diagnostic.rb +16 -0
  38. data/lib/asciichem/linter/element_validation_check.rb +39 -0
  39. data/lib/asciichem/linter/isotope_sanity_check.rb +47 -0
  40. data/lib/asciichem/linter/registry.rb +31 -0
  41. data/lib/asciichem/linter/unclosed_ring_check.rb +27 -0
  42. data/lib/asciichem/linter/valence_check.rb +91 -0
  43. data/lib/asciichem/linter.rb +40 -0
  44. data/lib/asciichem/model/atom.rb +73 -0
  45. data/lib/asciichem/model/bond.rb +41 -0
  46. data/lib/asciichem/model/electron_configuration.rb +36 -0
  47. data/lib/asciichem/model/embedded_math.rb +26 -0
  48. data/lib/asciichem/model/formula.rb +31 -0
  49. data/lib/asciichem/model/group.rb +50 -0
  50. data/lib/asciichem/model/identifier.rb +14 -0
  51. data/lib/asciichem/model/molecule.rb +70 -0
  52. data/lib/asciichem/model/name.rb +15 -0
  53. data/lib/asciichem/model/node.rb +100 -0
  54. data/lib/asciichem/model/reaction.rb +49 -0
  55. data/lib/asciichem/model/reaction_cascade.rb +33 -0
  56. data/lib/asciichem/model/text.rb +23 -0
  57. data/lib/asciichem/model.rb +23 -0
  58. data/lib/asciichem/model_adapter/from_canonical.rb +272 -0
  59. data/lib/asciichem/model_adapter/to_canonical.rb +429 -0
  60. data/lib/asciichem/model_adapter.rb +56 -0
  61. data/lib/asciichem/parser.rb +36 -0
  62. data/lib/asciichem/periodic_table.rb +112 -0
  63. data/lib/asciichem/ring_bonds.rb +68 -0
  64. data/lib/asciichem/transform.rb +513 -0
  65. data/lib/asciichem/version.rb +5 -0
  66. data/lib/asciichem/xml_builder.rb +9 -0
  67. data/lib/asciichem.rb +34 -0
  68. 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