umath 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/CHANGELOG.md +27 -0
- data/LICENSE.md +33 -0
- data/README.adoc +155 -0
- data/artifacts/umath.bindings.ruby.json +1 -0
- data/artifacts/umath.json +1 -0
- data/data/umath-symbols.json +13145 -0
- data/docs/model-contract.adoc +184 -0
- data/docs/parser-contract.adoc +341 -0
- data/docs/testsuite-pin.adoc +95 -0
- data/lib/umath/artifact.rb +84 -0
- data/lib/umath/configuration.rb +32 -0
- data/lib/umath/engine.rb +163 -0
- data/lib/umath/errors.rb +38 -0
- data/lib/umath/model/nodes.rb +737 -0
- data/lib/umath/model.rb +30 -0
- data/lib/umath/native_recursion_guard.rb +43 -0
- data/lib/umath/parser.rb +50 -0
- data/lib/umath/preprocessor.rb +91 -0
- data/lib/umath/raw_parser.rb +59 -0
- data/lib/umath/source_map.rb +146 -0
- data/lib/umath/symbols.rb +71 -0
- data/lib/umath/version.rb +5 -0
- data/lib/umath.rb +73 -0
- metadata +110 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Umath
|
|
6
|
+
# The compiled grammar and its declared Ruby bindings, loaded, verified
|
|
7
|
+
# and paired once per pair of paths.
|
|
8
|
+
class Artifact
|
|
9
|
+
Loaded = Struct.new(:parse, :bindings)
|
|
10
|
+
|
|
11
|
+
# Loads the Parsanol::PARG::Artifact at +path+ and checks it has an
|
|
12
|
+
# entry for every decimal marker.
|
|
13
|
+
def self.load_parse(path)
|
|
14
|
+
raise ArtifactError, "artifact not found: #{path}" unless File.file?(path)
|
|
15
|
+
|
|
16
|
+
artifact = Parsanol::PARG::Artifact.load(path)
|
|
17
|
+
missing = Configuration::DECIMAL_ENTRIES.values - artifact.entries
|
|
18
|
+
unless missing.empty?
|
|
19
|
+
raise ArtifactError, "artifact #{path} lacks required entries: #{missing.join(', ')}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
artifact
|
|
23
|
+
rescue JSON::ParserError => e
|
|
24
|
+
raise ArtifactError, "artifact #{path} is not valid JSON: #{e.message.lines.first&.strip}"
|
|
25
|
+
rescue Parsanol::PARG::Error, SystemCallError, KeyError, TypeError, NoMethodError => e
|
|
26
|
+
raise ArtifactError, "artifact #{path} is invalid: #{e.class}: #{e.message}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Loads the Ruby bindings artifact at +path+: kind, checksum, target and
|
|
30
|
+
# binding version are verified, then it is paired with +parse+ (same
|
|
31
|
+
# grammar, version and source hash). Every entry must bind the tree
|
|
32
|
+
# capture twice: raw (the tree with slices) and as plain data (the model input).
|
|
33
|
+
def self.load_bindings(path, parse)
|
|
34
|
+
raise ArtifactError, "bindings artifact not found: #{path}" unless File.file?(path)
|
|
35
|
+
|
|
36
|
+
bindings = Parsanol::PARG::Bindings::Artifact.from_json(File.read(path, encoding: "UTF-8"),
|
|
37
|
+
target: "ruby")
|
|
38
|
+
bindings.pair_with!(parse)
|
|
39
|
+
Configuration::DECIMAL_ENTRIES.each_value do |entry|
|
|
40
|
+
declared = bindings.bindings(entry).map { |b| [b["capture"], b["path"], b["type"]] }
|
|
41
|
+
required = [[Configuration::TREE_CAPTURE.to_s, Configuration::TREE_CAPTURE.to_s, "raw"],
|
|
42
|
+
[Configuration::TREE_CAPTURE.to_s, Configuration::MODEL_BINDING.to_s, "any"]]
|
|
43
|
+
missing = required - declared
|
|
44
|
+
next if missing.empty?
|
|
45
|
+
|
|
46
|
+
raise ArtifactError, "bindings artifact #{path} does not bind " \
|
|
47
|
+
"#{missing.map { |c, p, t| "#{c} -> #{p} (#{t})" }.join(', ')} for entry #{entry}"
|
|
48
|
+
end
|
|
49
|
+
bindings
|
|
50
|
+
rescue JSON::ParserError => e
|
|
51
|
+
raise ArtifactError, "bindings artifact #{path} is not valid JSON: #{e.message.lines.first&.strip}"
|
|
52
|
+
rescue Parsanol::PARG::Error, SystemCallError, KeyError, TypeError, NoMethodError => e
|
|
53
|
+
raise ArtifactError, "bindings artifact #{path} is invalid: #{e.class}: #{e.message}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
attr_reader :path, :bindings_path
|
|
57
|
+
|
|
58
|
+
def initialize(path, bindings_path)
|
|
59
|
+
@path = path
|
|
60
|
+
@bindings_path = bindings_path
|
|
61
|
+
@loaded = nil
|
|
62
|
+
@lock = Mutex.new
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Points at other artifacts (mainly for tests); the next #fetch loads them.
|
|
66
|
+
def use(path, bindings_path)
|
|
67
|
+
@lock.synchronize do
|
|
68
|
+
@path = path.to_s
|
|
69
|
+
@bindings_path = bindings_path.to_s
|
|
70
|
+
@loaded = nil
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The loaded pair, loaded on first use.
|
|
75
|
+
def fetch
|
|
76
|
+
@lock.synchronize do
|
|
77
|
+
@loaded ||= begin
|
|
78
|
+
parse = self.class.load_parse(@path)
|
|
79
|
+
Loaded.new(parse, self.class.load_bindings(@bindings_path, parse))
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Umath
|
|
4
|
+
# Fixed settings: the artifact entries, the engine names and where the
|
|
5
|
+
# bundled artifacts live.
|
|
6
|
+
module Configuration
|
|
7
|
+
# Decimal marker => artifact entry name.
|
|
8
|
+
DECIMAL_ENTRIES = {
|
|
9
|
+
"." => "dot",
|
|
10
|
+
"," => "comma",
|
|
11
|
+
"٫" => "arabic", # ARABIC DECIMAL SEPARATOR
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
ENGINES = %i[auto native ruby].freeze
|
|
15
|
+
|
|
16
|
+
ARTIFACT_DIR = File.expand_path("../../artifacts", __dir__)
|
|
17
|
+
DEFAULT_ARTIFACT_PATH = File.join(ARTIFACT_DIR, "umath.json")
|
|
18
|
+
DEFAULT_BINDINGS_PATH = File.join(ARTIFACT_DIR, "umath.bindings.ruby.json")
|
|
19
|
+
DATA_DIR = File.expand_path("../../data", __dir__)
|
|
20
|
+
SYMBOLS_PATH = File.join(DATA_DIR, "umath-symbols.json")
|
|
21
|
+
|
|
22
|
+
# The capture under which the declared bindings carry the raw tree.
|
|
23
|
+
TREE_CAPTURE = :umath_tree
|
|
24
|
+
# The binding path under which they carry the same tree as plain data,
|
|
25
|
+
# the input of the typed model's construction.
|
|
26
|
+
MODEL_BINDING = :model
|
|
27
|
+
|
|
28
|
+
# Deepest bracket nesting sent to the native engine off the main thread,
|
|
29
|
+
# where the machine stack is small.
|
|
30
|
+
THREAD_NATIVE_DEPTH = 25
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/umath/engine.rb
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Umath
|
|
4
|
+
# Runs the grammar on the native or Ruby Parsanol engine, always with
|
|
5
|
+
# Parslet-compatible memo decisions (the legacy grammar's mode), maps
|
|
6
|
+
# failures to Umath errors and records the engine that produced the
|
|
7
|
+
# result per thread.
|
|
8
|
+
#
|
|
9
|
+
# Internal.
|
|
10
|
+
module Engine
|
|
11
|
+
LAST_ENGINE_KEY = :umath_last_engine
|
|
12
|
+
OPENING = "([{".chars.freeze
|
|
13
|
+
CLOSING = ")]}".chars.freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def last_engine
|
|
18
|
+
Thread.current[LAST_ENGINE_KEY]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def native_available?
|
|
22
|
+
Parsanol::Native.available?
|
|
23
|
+
rescue LoadError, StandardError
|
|
24
|
+
false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The engine (:native or :ruby) that +engine+ (one of ENGINES) runs.
|
|
28
|
+
def resolve(engine)
|
|
29
|
+
case engine
|
|
30
|
+
when :auto then native_available? ? :native : :ruby
|
|
31
|
+
when :ruby then :ruby
|
|
32
|
+
when :native
|
|
33
|
+
return :native if native_available?
|
|
34
|
+
|
|
35
|
+
raise EngineUnavailable,
|
|
36
|
+
"engine: :native requested but the Parsanol native extension " \
|
|
37
|
+
"is not available on this platform (#{RUBY_PLATFORM}, Ruby #{RUBY_VERSION})"
|
|
38
|
+
else
|
|
39
|
+
raise ArgumentError,
|
|
40
|
+
"unknown engine #{engine.inspect} (use #{Configuration::ENGINES.map(&:inspect).join(', ')})"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Parses +text+ from +entry+ of the parse artifact +artifact+ on +mode+
|
|
45
|
+
# and returns the parse shape. Under +fallback+ (engine: :auto) input
|
|
46
|
+
# too deep for the native engine is rerun on Ruby.
|
|
47
|
+
def run(artifact, entry, text, mode, fallback:)
|
|
48
|
+
shape, ran = run_with_fallback(artifact, entry, text, mode, fallback: fallback)
|
|
49
|
+
record(ran)
|
|
50
|
+
shape
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def run_with_fallback(artifact, entry, text, mode, fallback:)
|
|
54
|
+
if mode == :native && too_deep_for_native_thread?(text)
|
|
55
|
+
record(:native)
|
|
56
|
+
unless fallback
|
|
57
|
+
raise native_too_deep(text, "nesting depth over #{Configuration::THREAD_NATIVE_DEPTH} off the main thread")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
return [run_mode(artifact, entry, text, :ruby), :ruby]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
[run_mode(artifact, entry, text, mode), mode]
|
|
64
|
+
rescue NativeRecursionLimit => e
|
|
65
|
+
record(:native)
|
|
66
|
+
raise native_too_deep(text, e.message) unless fallback
|
|
67
|
+
|
|
68
|
+
[run_mode(artifact, entry, text, :ruby), :ruby]
|
|
69
|
+
rescue NestingTooDeep
|
|
70
|
+
raise unless fallback && mode == :native
|
|
71
|
+
|
|
72
|
+
[run_mode(artifact, entry, text, :ruby), :ruby]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def too_deep_for_native_thread?(text)
|
|
76
|
+
return false if Thread.current.equal?(Thread.main)
|
|
77
|
+
|
|
78
|
+
depth = 0
|
|
79
|
+
max = 0
|
|
80
|
+
text.each_char do |char|
|
|
81
|
+
if OPENING.include?(char)
|
|
82
|
+
depth += 1
|
|
83
|
+
max = depth if depth > max
|
|
84
|
+
elsif CLOSING.include?(char)
|
|
85
|
+
depth -= 1 if depth.positive?
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
max > Configuration::THREAD_NATIVE_DEPTH
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def native_too_deep(text, detail)
|
|
92
|
+
NestingTooDeep.new(
|
|
93
|
+
"UnicodeMath input nests deeper than the native engine's recursion limit " \
|
|
94
|
+
"(#{detail}); use engine: :ruby or engine: :auto",
|
|
95
|
+
input: text, byte_offset: nil, char_offset: nil,
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def run_mode(artifact, entry, text, mode)
|
|
100
|
+
root = artifact.root_atom(entry)
|
|
101
|
+
if mode == :native
|
|
102
|
+
NativeRecursionGuard.watch do
|
|
103
|
+
Parsanol::Native.parse(root, text, compatibility: :parslet)
|
|
104
|
+
end
|
|
105
|
+
else
|
|
106
|
+
root.parse(text, compatibility: :parslet)
|
|
107
|
+
end
|
|
108
|
+
rescue Parsanol::ParseFailed => e
|
|
109
|
+
record(mode)
|
|
110
|
+
raise parse_error(e, root, text, mode)
|
|
111
|
+
rescue SystemStackError
|
|
112
|
+
record(mode)
|
|
113
|
+
raise NestingTooDeep.new(
|
|
114
|
+
"UnicodeMath input nests too deeply for the #{mode} engine (Ruby stack exhausted)",
|
|
115
|
+
input: text, byte_offset: nil, char_offset: nil,
|
|
116
|
+
)
|
|
117
|
+
rescue Parsanol::PARG::Error, Parsanol::GrammarError, Parsanol::Native::UnsupportedGrammar => e
|
|
118
|
+
raise ArtifactError, "the grammar artifact cannot be used: #{e.class}: #{e.message}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The legacy grammar runs on the native engine wherever it is available
|
|
122
|
+
# (Parsanol::Parser picks it for Parslet-compatible parses), so the
|
|
123
|
+
# position it reports is the native engine's deepest failure. The Ruby
|
|
124
|
+
# engine reports the same position through a Deepest reporter.
|
|
125
|
+
def parse_error(error, root, text, mode)
|
|
126
|
+
cause = mode == :ruby ? ruby_cause(root, text) : error.parse_failure_cause
|
|
127
|
+
byte = deepest_position(cause)
|
|
128
|
+
char = byte && text.byteslice(0, byte).force_encoding(Encoding::UTF_8).scrub.length
|
|
129
|
+
where = byte ? " at character #{char} (byte #{byte})" : ""
|
|
130
|
+
ParseError.new("UnicodeMath input rejected#{where}: #{error.message.lines.first.to_s.strip[0, 200]}",
|
|
131
|
+
input: text, byte_offset: byte, char_offset: char)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def ruby_cause(root, text)
|
|
135
|
+
root.parse(text, compatibility: :parslet, reporter: Parsanol::ErrorReporter::Deepest.new)
|
|
136
|
+
nil
|
|
137
|
+
rescue Parsanol::ParseFailed => e
|
|
138
|
+
e.parse_failure_cause
|
|
139
|
+
rescue SystemStackError
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Iterative: the cause tree of deeply nested input is as deep as the
|
|
144
|
+
# input.
|
|
145
|
+
def deepest_position(cause)
|
|
146
|
+
deepest = nil
|
|
147
|
+
stack = [cause].compact
|
|
148
|
+
until stack.empty?
|
|
149
|
+
node = stack.pop
|
|
150
|
+
deepest = node.position if node.position && (deepest.nil? || node.position > deepest)
|
|
151
|
+
stack.concat(node.children.compact)
|
|
152
|
+
end
|
|
153
|
+
deepest
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def record(mode)
|
|
157
|
+
Thread.current[LAST_ENGINE_KEY] = mode
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private_class_method :run_with_fallback, :native_too_deep, :run_mode, :parse_error,
|
|
161
|
+
:ruby_cause, :deepest_position, :record, :too_deep_for_native_thread?
|
|
162
|
+
end
|
|
163
|
+
end
|
data/lib/umath/errors.rb
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Umath
|
|
4
|
+
# Base class for every error Umath raises on purpose, except
|
|
5
|
+
# ArgumentError for invalid arguments.
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# The grammar refused the input.
|
|
9
|
+
#
|
|
10
|
+
# +input+ is the text the grammar ran on and the offsets are zero-based
|
|
11
|
+
# positions in it: +byte_offset+ in UTF-8 bytes, +char_offset+ in
|
|
12
|
+
# characters. They are the position the legacy Plurimath grammar reports
|
|
13
|
+
# for the same text, on either engine.
|
|
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 engine that ran cannot handle the input's nesting depth: the native
|
|
26
|
+
# engine's recursion limit, or an exhausted Ruby stack. The offsets are
|
|
27
|
+
# nil; this does not claim that the input is invalid.
|
|
28
|
+
class NestingTooDeep < ParseError; end
|
|
29
|
+
|
|
30
|
+
# A parse or bindings artifact is missing, malformed, fails its checksum,
|
|
31
|
+
# declares the wrong target or kind, or does not pair with the other.
|
|
32
|
+
# Umath never falls back to another artifact.
|
|
33
|
+
class ArtifactError < Error; end
|
|
34
|
+
|
|
35
|
+
# engine: :native was requested but Parsanol's native extension is not
|
|
36
|
+
# available on this platform.
|
|
37
|
+
class EngineUnavailable < Error; end
|
|
38
|
+
end
|