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.
@@ -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
@@ -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
@@ -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