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.
- checksums.yaml +7 -0
- data/LICENSE.md +31 -0
- data/README.adoc +108 -0
- data/artifacts/asciimath.checksums.txt +10 -0
- data/artifacts/asciimath.json +1 -0
- data/corpora/uncertainty-shared-exponent.yml +119 -0
- data/docs/parser-contract.adoc +257 -0
- data/lib/amath/artifact.rb +52 -0
- data/lib/amath/configuration.rb +24 -0
- data/lib/amath/engine.rb +148 -0
- data/lib/amath/error_position.rb +68 -0
- data/lib/amath/errors.rb +39 -0
- data/lib/amath/native_recursion_guard.rb +44 -0
- data/lib/amath/parser.rb +44 -0
- data/lib/amath/renester.rb +133 -0
- data/lib/amath/version.rb +5 -0
- data/lib/amath.rb +73 -0
- metadata +79 -0
data/lib/amath/errors.rb
ADDED
|
@@ -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
|
data/lib/amath/parser.rb
ADDED
|
@@ -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
|
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: []
|