amath 0.1.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.
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Amath
4
+ # Base class for every error Amath raises on purpose.
5
+ class Error < StandardError; end
6
+
7
+ # The input is not valid AsciiMath for the selected decimal marker.
8
+ #
9
+ # Positions are zero-based and match what Plurimath's Parslet parser
10
+ # reported, on either engine: the deepest leaf of the tree of failures
11
+ # that caused the rejection. +byte_offset+ counts UTF-8 bytes into the
12
+ # input (Parsanol's native unit); +char_offset+ counts characters, the
13
+ # unit Parslet used.
14
+ class ParseError < Error
15
+ attr_reader :input, :byte_offset, :char_offset
16
+
17
+ def initialize(message, input:, byte_offset:, char_offset:)
18
+ super(message)
19
+ @input = input
20
+ @byte_offset = byte_offset
21
+ @char_offset = char_offset
22
+ end
23
+ end
24
+
25
+ # The input nests deeper than the engine that ran can handle: the native
26
+ # engine's recursion limit under +engine: :native+, or an exhausted Ruby
27
+ # stack. A ParseError so existing rescues keep working; the offsets are
28
+ # nil because no rejection position exists.
29
+ class NestingTooDeep < ParseError; end
30
+
31
+ # The compiled grammar artifact is missing, unreadable, malformed, fails
32
+ # its checksum, declares an unsupported shape, or cannot be compiled by the
33
+ # installed Parsanol. Amath never falls back to another grammar.
34
+ class ArtifactError < Error; end
35
+
36
+ # +engine: :native+ was requested but Parsanol's native extension is not
37
+ # available on this platform.
38
+ class EngineUnavailable < Error; end
39
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Amath
4
+ # Raised inside Parsanol's native error path when the native engine hit its
5
+ # recursion limit, before Parsanol's own interpreter reparse runs (that
6
+ # reparse can take minutes or exhaust the Ruby stack on deep input).
7
+ class NativeRecursionLimit < Error; end
8
+ private_constant :NativeRecursionLimit
9
+
10
+ # Parsanol::Native.raise_native_parse_error translates a native failure.
11
+ # For a recursion-limit failure (which carries no position) it reparses
12
+ # through the Ruby interpreter with a Deepest reporter. While amath's own
13
+ # native call is running on this thread, this hook turns that failure into
14
+ # NativeRecursionLimit instead; every other failure, and every other
15
+ # caller of Parsanol, is unaffected.
16
+ module NativeRecursionGuard
17
+ MESSAGE = /\A(?:Maximum recursion depth \(\d+\) exceeded|Recursion limit exceeded\b)/
18
+ KEY = :amath_native_recursion_guard
19
+
20
+ def self.watch
21
+ outer = Thread.current[KEY]
22
+ Thread.current[KEY] = true
23
+ yield
24
+ ensure
25
+ Thread.current[KEY] = outer
26
+ end
27
+
28
+ def self.installed?
29
+ Parsanol::Native.singleton_class.ancestors.include?(self)
30
+ end
31
+
32
+ def raise_native_parse_error(error, grammar, input)
33
+ if Thread.current[KEY] && error.message.match?(MESSAGE)
34
+ raise NativeRecursionLimit, error.message.lines.first.strip, cause: nil
35
+ end
36
+
37
+ super
38
+ end
39
+ end
40
+
41
+ if Parsanol::Native.singleton_class.method_defined?(:raise_native_parse_error)
42
+ Parsanol::Native.singleton_class.prepend(NativeRecursionGuard)
43
+ end
44
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Amath
4
+ # Checks the arguments of Amath.parse, picks the artifact entry for the
5
+ # decimal marker and hands the input to the engine.
6
+ module Parser
7
+ module_function
8
+
9
+ def parse(input, decimal:, engine:)
10
+ entry = entry_for(decimal)
11
+ text = normalize_input(input)
12
+ mode = Engine.resolve(engine)
13
+ tree = Engine.run(Amath.artifact, entry, text, mode,
14
+ fallback: engine == :auto)
15
+ Renester.call(tree)
16
+ end
17
+
18
+ def entry_for(decimal)
19
+ Configuration::DECIMAL_ENTRIES.fetch(decimal) do
20
+ raise ArgumentError,
21
+ "unsupported decimal marker #{decimal.inspect} " \
22
+ "(supported: #{Configuration::DECIMAL_ENTRIES.keys.map(&:inspect).join(', ')})"
23
+ end
24
+ end
25
+
26
+ # +input+ as valid UTF-8, or an ArgumentError.
27
+ def normalize_input(input)
28
+ unless input.is_a?(String)
29
+ raise ArgumentError,
30
+ "input must be a String, got #{input.class}"
31
+ end
32
+
33
+ text = input.encoding == Encoding::UTF_8 ? input : input.encode(Encoding::UTF_8)
34
+ unless text.valid_encoding?
35
+ raise ArgumentError,
36
+ "input is not valid UTF-8"
37
+ end
38
+
39
+ text
40
+ rescue EncodingError => e
41
+ raise ArgumentError, "input cannot be converted to UTF-8: #{e.message}"
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Amath
4
+ # Rebuilds the nested tree of a flattened expression chain.
5
+ #
6
+ # The grammar parses a run of terms (a sum, a sequence) as a repetition
7
+ # instead of right recursion, so the engines need no stack depth
8
+ # proportional to the number of terms. It returns such a run as
9
+ #
10
+ # {expr: {amath_chain: [{amath_step: part_1}, {amath_lr: part_2}, ...,
11
+ # {amath_step: part_n}, {amath_end: rest}]}}
12
+ #
13
+ # (the +amath_end+ item is optional), where the recursive rule returned
14
+ #
15
+ # {expr: part_1 + {left_right: part_2 + ... {expr: part_n + rest}}}
16
+ #
17
+ # with + the parser's merge of sequence results: each +amath_step+ part
18
+ # becomes an :expr level and each +amath_lr+ part a :left_right level.
19
+ # The grammar already puts the chain node under its outermost level's
20
+ # key (:expr above), so sibling merges see the oracle's keys.
21
+ # Renester.call replaces every chain node with the value of that
22
+ # outermost level. It
23
+ # never recurses, so trees of any depth are safe. Every Hash and Array
24
+ # of the result is new; leaves are shared with the input tree.
25
+ module Renester
26
+ CHAIN = :amath_chain
27
+ FINAL = :amath_end
28
+ # Chain item key => key of the level it becomes.
29
+ LEVELS = { amath_step: :expr, amath_lr: :left_right }.freeze
30
+
31
+ module_function
32
+
33
+ # +tree+ with every chain node rebuilt.
34
+ def call(tree)
35
+ return tree unless container?(tree)
36
+
37
+ done = []
38
+ todo = [[tree, false]]
39
+ step(todo, done, *todo.pop) until todo.empty?
40
+ done.first
41
+ end
42
+
43
+ # One move of the post-order walk: a leaf is done; a container is
44
+ # rebuilt once its children are done, after queueing them first.
45
+ def step(todo, done, node, children_done)
46
+ if !container?(node)
47
+ done << node
48
+ elsif children_done
49
+ done << rebuild(node, done.pop(node.size))
50
+ else
51
+ todo << [node, true]
52
+ children = node.is_a?(Hash) ? node.values : node
53
+ children.reverse_each { |child| todo << [child, false] }
54
+ end
55
+ end
56
+
57
+ def container?(node)
58
+ node.instance_of?(Hash) || node.instance_of?(Array)
59
+ end
60
+
61
+ # A chain node ({amath_chain: items}, always alone in its Hash) becomes
62
+ # the value of its outermost level; any other container gets +values+
63
+ # (already rebuilt).
64
+ def rebuild(node, values)
65
+ return values unless node.is_a?(Hash)
66
+ return renest(values.first).each_value.first if node.key?(CHAIN)
67
+
68
+ node.keys.zip(values).to_h
69
+ end
70
+
71
+ # The nested tree of a chain's items, built from the innermost level
72
+ # outwards.
73
+ def renest(items)
74
+ last = items.last
75
+ rest = last.instance_of?(Hash) && last.key?(FINAL) ? last[FINAL] : ""
76
+ tree = rest
77
+ items.reverse_each do |item|
78
+ item.each do |key, part|
79
+ level = LEVELS[key]
80
+ tree = { level => fold(part, tree) } if level
81
+ end
82
+ end
83
+ tree
84
+ end
85
+
86
+ # Parsanol's result of a sequence whose (flattened) elements are +left+
87
+ # and +right+: nil elements are skipped, all nil is "", otherwise the
88
+ # two are merged.
89
+ def fold(left, right)
90
+ return right.nil? ? "" : right if left.nil?
91
+ return left if right.nil?
92
+
93
+ merge(left, right)
94
+ end
95
+
96
+ # Parsanol::Atoms::CanFlatten#merge_fold, without its duplicate-key
97
+ # warning.
98
+ def merge(left, right)
99
+ if left.instance_of?(right.class)
100
+ left.is_a?(Hash) ? left.merge(right) : left + right
101
+ elsif text?(left) || text?(right)
102
+ merge_text(left, right)
103
+ else
104
+ merge_list(left, right)
105
+ end
106
+ end
107
+
108
+ # A Hash and an Array: the Hash joins the Array.
109
+ def merge_list(left, right)
110
+ return left + [right] if right.instance_of?(Hash)
111
+ return [left] + right if left.instance_of?(Hash)
112
+
113
+ raise Error, "cannot merge #{left.class} with #{right.class}"
114
+ end
115
+
116
+ # At least one side is text: the other side wins over text, and a
117
+ # Slice wins over a String.
118
+ def merge_text(left, right)
119
+ return left unless text?(left)
120
+ return right unless text?(right)
121
+
122
+ right.instance_of?(Parsanol::Slice) ? right : left
123
+ end
124
+
125
+ def text?(value)
126
+ value.instance_of?(String) || value.instance_of?(Parsanol::Slice)
127
+ end
128
+
129
+ private_class_method :step, :container?, :rebuild,
130
+ :renest, :fold, :merge, :merge_text, :merge_list,
131
+ :text?
132
+ end
133
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Amath
4
+ VERSION = "0.1.0"
5
+ end
data/lib/amath.rb ADDED
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parsanol"
4
+
5
+ # AsciiMath parser backed by a compiled Parsanol (PARG) grammar artifact.
6
+ #
7
+ # See docs/parser-contract.adoc for the full contract.
8
+ module Amath
9
+ autoload :VERSION, "amath/version"
10
+ autoload :Error, "amath/errors"
11
+ autoload :ParseError, "amath/errors"
12
+ autoload :NestingTooDeep, "amath/errors"
13
+ autoload :ArtifactError, "amath/errors"
14
+ autoload :EngineUnavailable, "amath/errors"
15
+ autoload :Artifact, "amath/artifact"
16
+ autoload :Configuration, "amath/configuration"
17
+ autoload :Engine, "amath/engine"
18
+ autoload :ErrorPosition, "amath/error_position"
19
+ autoload :Parser, "amath/parser"
20
+ autoload :Renester, "amath/renester"
21
+
22
+ # Decimal marker => artifact entry name.
23
+ DECIMAL_ENTRIES = Configuration::DECIMAL_ENTRIES
24
+ ENGINES = Configuration::ENGINES
25
+ THREAD_NATIVE_DEPTH = Configuration::THREAD_NATIVE_DEPTH
26
+ OPENING = Configuration::OPENING
27
+ CLOSING = Configuration::CLOSING
28
+ DEFAULT_ARTIFACT_PATH = Configuration::DEFAULT_ARTIFACT_PATH
29
+
30
+ @artifact = Artifact.new(DEFAULT_ARTIFACT_PATH)
31
+
32
+ module_function
33
+
34
+ # Parse +input+ and return the Parsanol tree (Hash/Array of
35
+ # Parsanol::Slice leaves, or a plain "" for empty input).
36
+ #
37
+ # The engine that ran is available afterwards from Amath.last_engine.
38
+ def parse(input, decimal: ".", engine: :auto)
39
+ Parser.parse(input, decimal: decimal, engine: engine)
40
+ end
41
+
42
+ # The engine (:native or :ruby) that produced the result (tree or
43
+ # rejection) of the last Amath.parse call on the current thread that
44
+ # reached the parser; nil before any parse. Under engine: :auto this is
45
+ # :ruby when the native engine hit its recursion limit and amath reran
46
+ # the input on the Ruby engine.
47
+ def last_engine
48
+ Engine.last_engine
49
+ end
50
+
51
+ def native_available?
52
+ Engine.native_available?
53
+ end
54
+
55
+ def artifact_path
56
+ @artifact.path
57
+ end
58
+
59
+ # Point Amath at another artifact (mainly for tests). Clears the cache so
60
+ # the next parse loads it.
61
+ def artifact_path=(path)
62
+ @artifact.path = path
63
+ end
64
+
65
+ # The loaded Parsanol::PARG::Artifact, loaded and verified once.
66
+ def artifact
67
+ @artifact.fetch
68
+ end
69
+ end
70
+
71
+ # Installs a hook into Parsanol at load time, so it is required, not
72
+ # autoloaded.
73
+ require_relative "amath/native_recursion_guard"
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: amath
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ribose Inc.
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: parsanol
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.3'
19
+ - - ">="
20
+ - !ruby/object:Gem::Version
21
+ version: 1.3.107
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - "~>"
27
+ - !ruby/object:Gem::Version
28
+ version: '1.3'
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: 1.3.107
32
+ description: |
33
+ AsciiMath parser that runs a compiled Parsanol grammar artifact and
34
+ returns a parse tree. Used by Plurimath.
35
+ email:
36
+ - open.source@ribose.com
37
+ executables: []
38
+ extensions: []
39
+ extra_rdoc_files: []
40
+ files:
41
+ - LICENSE.md
42
+ - README.adoc
43
+ - artifacts/asciimath.checksums.txt
44
+ - artifacts/asciimath.json
45
+ - corpora/uncertainty-shared-exponent.yml
46
+ - docs/parser-contract.adoc
47
+ - lib/amath.rb
48
+ - lib/amath/artifact.rb
49
+ - lib/amath/configuration.rb
50
+ - lib/amath/engine.rb
51
+ - lib/amath/error_position.rb
52
+ - lib/amath/errors.rb
53
+ - lib/amath/native_recursion_guard.rb
54
+ - lib/amath/parser.rb
55
+ - lib/amath/renester.rb
56
+ - lib/amath/version.rb
57
+ homepage: https://github.com/plurimath/amath
58
+ licenses:
59
+ - BSD-2-Clause
60
+ metadata:
61
+ rubygems_mfa_required: 'true'
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: 3.2.0
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 3.6.9
77
+ specification_version: 4
78
+ summary: AsciiMath parser by Plurimath.
79
+ test_files: []