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,119 @@
1
+ schema_version: 1
2
+ issue: https://github.com/plurimath/amath/issues/13
3
+ contract: Original source leaves and optional shared_exponent; normalized_model_text
4
+ is the exact-decimal consuming-reader expectation, not a transformed Amath slice.
5
+ cases:
6
+ - id: standard.positive
7
+ input: ustd((3.85, 0.23)e4)
8
+ tree:
9
+ uncertainty_class: ustd
10
+ value: '3.85'
11
+ magnitude: '0.23'
12
+ shared_exponent: '4'
13
+ normalized_model_text:
14
+ value: '38500'
15
+ magnitude: '2300'
16
+ - id: standard.negative
17
+ input: ustd((-3.850, 0.230)e+4)
18
+ tree:
19
+ uncertainty_class: ustd
20
+ value: '-3.850'
21
+ magnitude: '0.230'
22
+ shared_exponent: '+4'
23
+ normalized_model_text:
24
+ value: '-38500'
25
+ magnitude: '2300'
26
+ - id: standard.leading_zeroes
27
+ input: ustd((001.2300, 000.0040)e-2)
28
+ tree:
29
+ uncertainty_class: ustd
30
+ value: '001.2300'
31
+ magnitude: '000.0040'
32
+ shared_exponent: '-2'
33
+ normalized_model_text:
34
+ value: '0.012300'
35
+ magnitude: '0.000040'
36
+ - id: standard.zero_exponent
37
+ input: ustd((1.230, 0.004)e0)
38
+ tree:
39
+ uncertainty_class: ustd
40
+ value: '1.230'
41
+ magnitude: '0.004'
42
+ shared_exponent: '0'
43
+ normalized_model_text:
44
+ value: '1.230'
45
+ magnitude: '0.004'
46
+ - id: concise.negative_exponent
47
+ input: ustd(680(41)e-3)
48
+ tree:
49
+ uncertainty_class: ustd
50
+ value: '680'
51
+ concise_digits: '41'
52
+ shared_exponent: '-3'
53
+ normalized_model_text:
54
+ value: '0.680'
55
+ magnitude: '0.041'
56
+ - id: concise.zero_exponent
57
+ input: ustd(1.230(4)e0)
58
+ tree:
59
+ uncertainty_class: ustd
60
+ value: '1.230'
61
+ concise_digits: '4'
62
+ shared_exponent: '0'
63
+ normalized_model_text:
64
+ value: '1.230'
65
+ magnitude: '0.004'
66
+ - id: concise.unit
67
+ input: ustd(680(41)e-3, "unitsml(kg)")
68
+ tree:
69
+ uncertainty_class: ustd
70
+ value: '680'
71
+ concise_digits: '41'
72
+ shared_exponent: '-3'
73
+ unit: kg
74
+ normalized_model_text:
75
+ value: '0.680'
76
+ magnitude: '0.041'
77
+ - id: expanded.dimensionless_fields
78
+ input: uexp((3.85, 0.23)e4, 2.00, 0.950, "unitsml(m)")
79
+ tree:
80
+ uncertainty_class: uexp
81
+ value: '3.85'
82
+ magnitude: '0.23'
83
+ shared_exponent: '4'
84
+ coverage_factor: '2.00'
85
+ coverage_probability: '0.950'
86
+ unit: m
87
+ normalized_model_text:
88
+ value: '38500'
89
+ magnitude: '2300'
90
+ coverage_factor: '2.00'
91
+ coverage_probability: '0.950'
92
+ - id: expanded.negative_exponent
93
+ input: uexp((100.02147, 0.00079)e-2, 2)
94
+ tree:
95
+ uncertainty_class: uexp
96
+ value: '100.02147'
97
+ magnitude: '0.00079'
98
+ shared_exponent: '-2'
99
+ coverage_factor: '2'
100
+ normalized_model_text:
101
+ value: '1.0002147'
102
+ magnitude: '0.0000079'
103
+ coverage_factor: '2'
104
+ - id: interval.shared_exponent
105
+ input: ucov((1.024, 0.983, 1.088)e-3, 0.95, shortest, "unitsml(V)")
106
+ tree:
107
+ uncertainty_class: ucov
108
+ value: '1.024'
109
+ lower: '0.983'
110
+ upper: '1.088'
111
+ shared_exponent: '-3'
112
+ coverage_probability: '0.95'
113
+ interval_kind: shortest
114
+ unit: V
115
+ normalized_model_text:
116
+ value: '0.001024'
117
+ lower: '0.000983'
118
+ upper: '0.001088'
119
+ coverage_probability: '0.95'
@@ -0,0 +1,257 @@
1
+ = Amath parser contract
2
+ :toc:
3
+
4
+ This document is the contract between amath and its callers (first of all
5
+ Plurimath's AsciiMath transform). It covers the public API, decimal markers,
6
+ engine policy, errors and positions. Anything not written here is not part of
7
+ the contract.
8
+
9
+ Runtime dependency: `parsanol ~> 1.3, >= 1.3.107`. amath never requires
10
+ Plurimath or reads Plurimath constants.
11
+
12
+ == API
13
+
14
+ [source,ruby]
15
+ ----
16
+ Amath.parse(input, decimal: ".", engine: :auto) # => tree
17
+ Amath.last_engine # => :native, :ruby or nil
18
+ Amath.native_available? # => true / false
19
+ Amath.artifact_path # => String
20
+ Amath.artifact_path = path # tests and tooling only
21
+ Amath::VERSION
22
+ ----
23
+
24
+ `input`:: a `String`. Non-UTF-8 strings are transcoded to UTF-8; a string that
25
+ is not valid UTF-8 (or cannot be transcoded) raises `ArgumentError`. amath does
26
+ no preprocessing: the caller passes the exact string to parse, and every
27
+ position refers to that string.
28
+
29
+ `decimal`:: the decimal marker for numbers. See <<decimal>>.
30
+
31
+ `engine`:: `:auto`, `:native` or `:ruby`. See <<engine>>.
32
+
33
+ === Successful result
34
+
35
+ The return value is the Parsanol tree of the selected entry:
36
+
37
+ * `Hash` with Symbol keys, `Array` (order preserved), and `Parsanol::Slice`
38
+ leaves;
39
+ * empty input returns a plain `""` (a `String`, not a `Slice`). This is the
40
+ grammar's end-of-input alternative and callers must handle it the same way
41
+ they handled Parslet's empty match.
42
+
43
+ The tree's key names and nesting are defined by the grammar artifact
44
+ (`artifacts/asciimath.json`) and are pinned by the conformance corpus, not by
45
+ this document.
46
+
47
+ `Parsanol::Slice` is not `Parslet::Slice`. Callers that check the leaf class
48
+ exactly (Plurimath's transform does) need an adapter; that is the caller's
49
+ concern.
50
+
51
+ === Shared exponents in uncertainty calls
52
+
53
+ An uncertainty call can put its measured fields in a group followed immediately
54
+ by `e` and a signed or unsigned integer exponent:
55
+
56
+ [source,asciimath]
57
+ ----
58
+ ustd((3.85, 0.23)e4)
59
+ ustd(680(41)e-3)
60
+ uexp((3.85, 0.23)e+4, 2, 0.95)
61
+ ucov((1.024, 0.983, 1.088)e-3, 0.95, shortest)
62
+ ----
63
+
64
+ These forms add `shared_exponent` to the existing `uncertainty` hash. Its
65
+ value is the original `Parsanol::Slice` for the exponent digits and optional
66
+ sign, without the `e`. All mantissas, concise digits, units and dimensionless
67
+ coverage fields remain unchanged source slices with their original byte offsets.
68
+ Calls without an exponent keep their existing trees.
69
+
70
+ The consuming reader shifts the decimal point exactly in the value and
71
+ magnitude, or in the value and both interval endpoints. For concise notation,
72
+ it first derives the magnitude from the mantissa's decimal places. Thus
73
+ `ustd(680(41)e-3)` means value `0.680` and magnitude `0.041`.
74
+ Coverage factors and probabilities are not scaled. No float conversion is
75
+ needed, and amath does not replace source slices with calculated text.
76
+
77
+ Whitespace before `e`, incomplete exponents and a minus sign on a magnitude
78
+ remain outside the uncertainty syntax and follow the ordinary grammar fallback.
79
+ The separate expression-language forms `(3.85 +/- 0.23)e4` and `680(41)e-3`
80
+ remain ordinary AsciiMath here; this extension uses explicit uncertainty calls.
81
+ The input/tree and consuming-reader decimal expectations are recorded in
82
+ `corpora/uncertainty-shared-exponent.yml`.
83
+
84
+ [[decimal]]
85
+ == Decimal markers
86
+
87
+ The artifact carries one entry per supported marker. `decimal:` selects the
88
+ entry; there is no locale lookup inside amath.
89
+
90
+ [cols="1,1,2"]
91
+ |===
92
+ | `decimal:` | Artifact entry | Marker
93
+
94
+ | `"."` (default) | `dot` | FULL STOP, U+002E
95
+ | `","` | `comma` | COMMA, U+002C
96
+ | `"٫"` | `arabic` | ARABIC DECIMAL SEPARATOR, U+066B
97
+ |===
98
+
99
+ Any other value raises `ArgumentError` before the artifact is touched. An
100
+ artifact that lacks any of the three entries is rejected with
101
+ `Amath::ArtifactError`.
102
+
103
+ [[engine]]
104
+ == Engine policy
105
+
106
+ Parsanol has a native (Rust) engine and a pure-Ruby engine. Its own
107
+ `mode: :native` silently runs the Ruby engine when the extension is missing,
108
+ so amath decides the engine itself before calling Parsanol:
109
+
110
+ [cols="1,3"]
111
+ |===
112
+ | `engine:` | Behaviour
113
+
114
+ | `:auto` (default)
115
+ | Native if `Parsanol::Native.available?`, otherwise Ruby. Input nested too
116
+ deeply for the native engine (see <<deep-nesting>>) is parsed again with the
117
+ Ruby engine.
118
+
119
+ | `:native`
120
+ | Native. If the extension is not available, raises
121
+ `Amath::EngineUnavailable`; never runs Ruby instead. Input nested too
122
+ deeply for the native engine raises `Amath::NestingTooDeep`; never runs
123
+ Ruby instead.
124
+
125
+ | `:ruby`
126
+ | Ruby engine only; the native extension is not called.
127
+ |===
128
+
129
+ After every call that reaches the parser (success or `ParseError`),
130
+ `Amath.last_engine` returns the engine that produced the result, `:native` or
131
+ `:ruby`: under `:auto` it is `:ruby` when deep input was parsed again with the
132
+ Ruby engine. The value is thread-local; it is `nil` on a thread that has not
133
+ parsed yet.
134
+
135
+ Limits of what `last_engine` proves:
136
+
137
+ * It records the engine amath ran. Inside `Parsanol::Native.parse`,
138
+ Parsanol may still run its interpreter for grammar constructs native cannot
139
+ express (custom atoms). The amath grammar is expected to contain none; the
140
+ differential check in `scripts/check.rb` is where internal fallback is
141
+ detected, not this flag.
142
+ * Parsanol 1.3.107 ships precompiled native extensions for x86_64 Linux on
143
+ Ruby 3.2 to 4.0. CI runs Ruby 3.2, 3.3 and 3.4 on ubuntu-latest with the
144
+ native examples required. Other platforms are not yet verified.
145
+
146
+ [[deep-nesting]]
147
+ === Deep nesting
148
+
149
+ Parsanol's native engine stops at a fixed recursion depth ("Maximum recursion
150
+ depth (500) exceeded" or "Recursion limit exceeded: depth 1000"). With the
151
+ AsciiMath artifact that is about 110 levels of `(` and about 80 of `{`; other
152
+ constructs (`sqrt`, `/`, unary `-`) reach it at other depths. On a thread
153
+ other than the main one, the native call can also exhaust the thread's Ruby
154
+ stack (1 MiB by default) much earlier (at about 40 levels of `(` on x86_64 Linux); Ruby reports
155
+ that as `SystemStackError`.
156
+
157
+ Length is not depth. A run of terms (a sum such as `x + x + ... + x`, a
158
+ sequence of symbols, a comma list) is parsed as a repetition, not by one
159
+ nested rule call per term, so neither engine gets deeper as the run gets
160
+ longer. The tree is still the nested one, `{expr: term + {expr: ...}}`, one
161
+ level per term: amath rebuilds it after the engine returns, without
162
+ recursion (`Amath::Renester`), with the same keys, order, slices and
163
+ offsets as the recursive grammar produced. Code that walks the returned
164
+ tree recursively must still be ready for one level per term. When the
165
+ input after a run fails to parse, the grammar falls back to its recursive
166
+ rule to find the same tree or rejection as before; such input can still
167
+ hit the limits below.
168
+
169
+ For such a recursion-limit failure Parsanol's own error path would reparse
170
+ the input through its Ruby interpreter with a diagnostic reporter, which on
171
+ deep input takes minutes or exhausts the stack. amath therefore hooks
172
+ `Parsanol::Native.raise_native_parse_error` (a prepended module, active only
173
+ on the current thread while amath's own native call runs) and stops at the
174
+ native failure. Then:
175
+
176
+ * `engine: :auto` parses the input with the Ruby engine (`last_engine` is
177
+ `:ruby`), as it also does when the native call raises `SystemStackError`;
178
+ * `engine: :native` raises `Amath::NestingTooDeep` (`last_engine` is
179
+ `:native`).
180
+
181
+ The Ruby engine has no fixed limit but is bounded by the Ruby stack. On the
182
+ main thread it parses 1,000 levels of `(`; rejecting deep invalid input needs
183
+ more stack, because Parsanol reparses it with its recursive interpreter to
184
+ find the position, so a rejection of input a few hundred levels deep can
185
+ raise `Amath::NestingTooDeep` instead of a positioned `ParseError`. amath
186
+ never lets `SystemStackError` escape from `Amath.parse`.
187
+
188
+ == Errors
189
+
190
+ All deliberate errors inherit from `Amath::Error < StandardError`.
191
+
192
+ `Amath::ParseError`::
193
+ The input is not valid AsciiMath for the selected decimal marker. Full
194
+ consumption is required: a valid prefix followed by invalid text is a
195
+ rejection, never a partial tree. Attributes:
196
+ +
197
+ * `byte_offset` — zero-based UTF-8 byte offset of the deepest failure;
198
+ * `char_offset` — the same position in characters;
199
+ * `input` — the (UTF-8) string that was parsed.
200
+ +
201
+ The message text is for humans and is not part of the contract. The original
202
+ `Parsanol::ParseFailed` is available as `cause`.
203
+
204
+ `Amath::NestingTooDeep < Amath::ParseError`::
205
+ The input nests deeper than the engine that ran can handle: the native
206
+ engine's recursion limit under `engine: :native`, or an exhausted Ruby stack
207
+ under any engine. See <<deep-nesting>>. `byte_offset` and `char_offset` are
208
+ `nil`, since no rejection position was found; `input` is set. Whether such
209
+ input is valid AsciiMath is not known.
210
+
211
+ `Amath::ArtifactError`::
212
+ The artifact is missing, is not valid JSON, is not a JSON object, fails its
213
+ checksum, declares an unsupported shape, lacks a required entry, or cannot be
214
+ compiled or registered by the installed Parsanol. Raised under every engine
215
+ setting. amath never falls back to another artifact or grammar and never
216
+ recompiles anything except the artifact's own embedded source (Parsanol's
217
+ documented pure-Ruby path).
218
+
219
+ `Amath::EngineUnavailable`::
220
+ `engine: :native` was requested and the native extension is not available.
221
+
222
+ `ArgumentError`::
223
+ Unknown `decimal:` or `engine:`, non-String input, or input that is not valid
224
+ UTF-8.
225
+
226
+ == Positions: bytes versus characters
227
+
228
+ Parsanol reports positions in *bytes*; Parslet reported them in *characters*.
229
+ They differ as soon as the input contains non-ASCII text.
230
+
231
+ * `Parsanol::Slice#offset` is a zero-based byte offset. Note that
232
+ `Parsanol::Slice#charpos` is an alias of `offset` and is *also* a byte
233
+ offset.
234
+ * `Amath::ParseError#byte_offset` is a byte offset and `#char_offset` is the
235
+ character offset computed by amath.
236
+
237
+ Example: in `text(é) x`, the leaf `x` has `offset == 9`, while it is
238
+ character 8. To convert a byte offset to Parslet's character offset:
239
+
240
+ [source,ruby]
241
+ ----
242
+ char = input.byteslice(0, byte_offset).length
243
+ ----
244
+
245
+ where `input` is the UTF-8 string passed to `Amath.parse`.
246
+
247
+ Known difference from Parslet: the deepest rejection position can differ by
248
+ one. For `x 1.` the A0 spike measured Parslet's deepest failure at 3 and
249
+ Parsanol's at 4. The conformance corpus records the agreed position per case.
250
+
251
+ == Artifact loading
252
+
253
+ The artifact path defaults to `artifacts/asciimath.json` inside the installed
254
+ gem (`Amath::DEFAULT_ARTIFACT_PATH`). It is loaded and checksum-verified once,
255
+ on first use, and cached for the process. Assigning `Amath.artifact_path=`
256
+ clears the cache; it exists for tests and tooling, not for selecting
257
+ alternative grammars in production.
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Amath
6
+ # The compiled grammar artifact Amath parses with: loaded and verified
7
+ # once per path, and reloaded after the path changes.
8
+ class Artifact
9
+ # Loads the Parsanol::PARG::Artifact at +path+ and checks it has an
10
+ # entry for every decimal marker. Every load failure is an ArtifactError.
11
+ def self.load(path)
12
+ raise ArtifactError, "artifact not found: #{path}" unless File.file?(path)
13
+
14
+ artifact = Parsanol::PARG::Artifact.load(path)
15
+ missing = Configuration::DECIMAL_ENTRIES.values - artifact.entries
16
+ unless missing.empty?
17
+ raise ArtifactError,
18
+ "artifact #{path} lacks required entries: #{missing.join(', ')}"
19
+ end
20
+
21
+ artifact
22
+ rescue JSON::ParserError => e
23
+ raise ArtifactError,
24
+ "artifact #{path} is not valid JSON: #{e.message.lines.first&.strip}"
25
+ rescue Parsanol::PARG::Error, SystemCallError, KeyError, TypeError,
26
+ NoMethodError => e
27
+ raise ArtifactError,
28
+ "artifact #{path} is invalid: #{e.class}: #{e.message}"
29
+ end
30
+
31
+ attr_reader :path
32
+
33
+ def initialize(path)
34
+ @path = path
35
+ @loaded = nil
36
+ @lock = Mutex.new
37
+ end
38
+
39
+ # Clears the loaded artifact so the next #fetch loads +path+.
40
+ def path=(path)
41
+ @lock.synchronize do
42
+ @path = path.to_s
43
+ @loaded = nil
44
+ end
45
+ end
46
+
47
+ # The Parsanol::PARG::Artifact at #path, loaded on first use.
48
+ def fetch
49
+ @lock.synchronize { @loaded ||= self.class.load(@path) }
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Amath
4
+ # Fixed settings: the artifact entries, the engine names and where the
5
+ # bundled artifact lives. Amath re-exports each constant.
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
+ # Deepest bracket nesting sent to the native engine off the main thread.
17
+ THREAD_NATIVE_DEPTH = 25
18
+ OPENING = "([{\u2112\u1455".chars.freeze
19
+ CLOSING = ")]}\u211b\u1450".chars.freeze
20
+
21
+ DEFAULT_ARTIFACT_PATH = File.expand_path("../../artifacts/asciimath.json",
22
+ __dir__)
23
+ end
24
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Amath
4
+ # Runs a parse on the native or Ruby Parsanol engine: resolves the
5
+ # requested engine, reruns input too deep for native on Ruby under
6
+ # engine: :auto, maps Parsanol failures to Amath errors, and records the
7
+ # engine that produced the result per thread.
8
+ module Engine
9
+ LAST_ENGINE_KEY = :amath_last_engine
10
+
11
+ module_function
12
+
13
+ # See Amath.last_engine.
14
+ def last_engine
15
+ Thread.current[LAST_ENGINE_KEY]
16
+ end
17
+
18
+ def native_available?
19
+ Parsanol::Native.available?
20
+ rescue LoadError, StandardError
21
+ false
22
+ end
23
+
24
+ # The engine (:native or :ruby) that +engine+ (one of ENGINES) runs.
25
+ def resolve(engine)
26
+ case engine
27
+ when :auto then native_available? ? :native : :ruby
28
+ when :ruby then :ruby
29
+ when :native
30
+ return :native if native_available?
31
+
32
+ raise EngineUnavailable,
33
+ "engine: :native requested but the Parsanol native extension " \
34
+ "is not available on this platform (#{RUBY_PLATFORM}, Ruby #{RUBY_VERSION})"
35
+ else
36
+ raise ArgumentError,
37
+ "unknown engine #{engine.inspect} (use #{Configuration::ENGINES.map(&:inspect).join(', ')})"
38
+ end
39
+ end
40
+
41
+ # Parses +text+ from +entry+ on +mode+ and returns the tree.
42
+ def run(artifact, entry, text, mode, fallback:)
43
+ tree, ran = run_with_fallback(artifact, entry, text, mode,
44
+ fallback: fallback)
45
+ record(ran)
46
+ tree
47
+ end
48
+
49
+ # Runs +mode+ and returns [tree, engine that produced it]. Under :auto
50
+ # (+fallback+), input nested too deeply for the native engine is rerun
51
+ # on the Ruby engine: either native's own recursion limit, or the Ruby
52
+ # stack running out inside the native call (threads other than the
53
+ # main one have a 1 MiB machine stack by default).
54
+ def run_with_fallback(artifact, entry, text, mode, fallback:)
55
+ if mode == :native && too_deep_for_native_thread?(text)
56
+ record(:native)
57
+ unless fallback
58
+ raise native_too_deep(
59
+ text,
60
+ "nesting depth over #{Configuration::THREAD_NATIVE_DEPTH} " \
61
+ "off the main thread",
62
+ )
63
+ end
64
+
65
+ return [run_mode(artifact, entry, text, :ruby), :ruby]
66
+ end
67
+
68
+ [run_mode(artifact, entry, text, mode), mode]
69
+ rescue NativeRecursionLimit => e
70
+ record(:native)
71
+ raise native_too_deep(text, e.message) unless fallback
72
+
73
+ [run_mode(artifact, entry, text, :ruby), :ruby]
74
+ rescue NestingTooDeep
75
+ raise unless fallback && mode == :native
76
+
77
+ [run_mode(artifact, entry, text, :ruby), :ruby]
78
+ end
79
+
80
+ # Off the main thread the machine stack is small (1 MiB by default) and
81
+ # the native engine overflows it at about 40 levels of brackets. A Ruby
82
+ # stack overflow inside the native call crashes the process instead of
83
+ # raising, so deep input never reaches native there.
84
+ def too_deep_for_native_thread?(text)
85
+ return false if Thread.current.equal?(Thread.main)
86
+
87
+ depth = 0
88
+ max = 0
89
+ text.each_char do |char|
90
+ if Configuration::OPENING.include?(char)
91
+ depth += 1
92
+ max = depth if depth > max
93
+ elsif Configuration::CLOSING.include?(char)
94
+ depth -= 1 if depth.positive?
95
+ end
96
+ end
97
+ max > Configuration::THREAD_NATIVE_DEPTH
98
+ end
99
+
100
+ def native_too_deep(text, detail)
101
+ NestingTooDeep.new(
102
+ "AsciiMath input nests deeper than the native engine's recursion limit " \
103
+ "(#{detail}); use engine: :ruby or engine: :auto",
104
+ input: text, byte_offset: nil, char_offset: nil,
105
+ )
106
+ end
107
+
108
+ def run_mode(artifact, entry, text, mode)
109
+ # Parsanol's mode: :native silently runs Ruby when the extension is
110
+ # missing; resolve has already checked availability.
111
+ if mode == :native
112
+ NativeRecursionGuard.watch do
113
+ artifact.parse(entry, text, mode: :native)
114
+ end
115
+ else
116
+ artifact.parse(entry, text, mode: mode)
117
+ end
118
+ rescue Parsanol::ParseFailed => e
119
+ record(mode)
120
+ raise parse_error(e, artifact, entry, text)
121
+ rescue SystemStackError
122
+ record(mode)
123
+ raise NestingTooDeep.new(
124
+ "AsciiMath input nests too deeply for the #{mode} engine (Ruby stack exhausted)",
125
+ input: text, byte_offset: nil, char_offset: nil,
126
+ )
127
+ rescue Parsanol::PARG::Error, Parsanol::GrammarError,
128
+ Parsanol::Native::UnsupportedGrammar => e
129
+ raise ArtifactError,
130
+ "artifact #{Amath.artifact_path} cannot be used: #{e.class}: #{e.message}"
131
+ end
132
+
133
+ def parse_error(error, artifact, entry, text)
134
+ byte = ErrorPosition.byte_offset(error, artifact, entry, text)
135
+ char = byte && ErrorPosition.char_offset(text, byte)
136
+ where = byte ? " at character #{char} (byte #{byte})" : ""
137
+ ParseError.new("AsciiMath input rejected#{where}: #{error.message.lines.first&.strip}",
138
+ input: text, byte_offset: byte, char_offset: char)
139
+ end
140
+
141
+ def record(mode)
142
+ Thread.current[LAST_ENGINE_KEY] = mode
143
+ end
144
+
145
+ private_class_method :run_with_fallback, :native_too_deep, :run_mode,
146
+ :parse_error, :record, :too_deep_for_native_thread?
147
+ end
148
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Amath
4
+ # Where a rejected input failed, reported the way Plurimath's Parslet
5
+ # parser did.
6
+ module ErrorPosition
7
+ module_function
8
+
9
+ # The zero-based byte offset of the rejection +failure+ (a
10
+ # Parsanol::ParseFailed for +text+ from +entry+), or nil if none is known.
11
+ def byte_offset(failure, artifact, entry, text)
12
+ parslet_position(artifact, entry,
13
+ text) || deepest_position(failure.parse_failure_cause)
14
+ end
15
+
16
+ # The character offset of byte offset +byte+ in +text+.
17
+ def char_offset(text, byte)
18
+ text.byteslice(0, byte).force_encoding(Encoding::UTF_8).scrub.length
19
+ end
20
+
21
+ # The rejection position Plurimath's Parslet parser reported: the
22
+ # deepest *leaf* of the cause tree built by a Parslet-style Tree
23
+ # reporter, which holds only the failures that caused the final
24
+ # rejection. Neither engine's own cause gives this: the native engine
25
+ # returns one childless cause at its farthest failure, and the Ruby
26
+ # engine's Deepest reporter grafts in failures from branches that
27
+ # later succeeded, so both can point past Parslet's position. The
28
+ # interpreter reparse runs only on rejection. Returns nil if that
29
+ # reparse unexpectedly accepts, so the caller falls back.
30
+ #
31
+ # The interpreter and deepest_leaf recurse once per nesting level; if
32
+ # that exhausts the stack on very deep input, this returns nil too.
33
+ def parslet_position(artifact, entry, text)
34
+ cause = begin
35
+ artifact.root_atom(entry).parse(text, reporter: Parsanol::ErrorReporter::Tree.new)
36
+ nil
37
+ rescue Parsanol::ParseFailed => e
38
+ e.parse_failure_cause
39
+ end
40
+ cause && deepest_leaf(cause)
41
+ rescue SystemStackError
42
+ nil
43
+ end
44
+
45
+ # Parslet's rule: a node with children reports no position of its own.
46
+ def deepest_leaf(cause)
47
+ children = cause.children.compact
48
+ return cause.position if children.empty?
49
+
50
+ children.filter_map { |c| deepest_leaf(c) }.max
51
+ end
52
+
53
+ # Iterative: the cause tree of a deeply nested input is as deep as the
54
+ # input, so recursion here could exhaust the stack.
55
+ def deepest_position(cause)
56
+ deepest = nil
57
+ stack = [cause].compact
58
+ until stack.empty?
59
+ node = stack.pop
60
+ deepest = node.position if node.position && (deepest.nil? || node.position > deepest)
61
+ stack.concat(node.children.compact)
62
+ end
63
+ deepest
64
+ end
65
+
66
+ private_class_method :parslet_position, :deepest_leaf, :deepest_position
67
+ end
68
+ end