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
|
@@ -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
|
data/lib/amath/engine.rb
ADDED
|
@@ -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
|