roll-parser 3.0.0-beta.0 → 3.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.
- package/CHANGELOG.md +142 -9
- package/MIGRATION.md +219 -0
- package/README.md +1026 -154
- package/dist/cli/args.d.ts +1 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +81 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/format.d.ts +15 -3
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +18 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +14 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/main.d.ts +36 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +83 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/errors.d.ts +332 -17
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +141 -0
- package/dist/errors.js.map +1 -0
- package/dist/evaluator/die.d.ts +27 -0
- package/dist/evaluator/die.d.ts.map +1 -0
- package/dist/evaluator/die.js +19 -0
- package/dist/evaluator/die.js.map +1 -0
- package/dist/evaluator/env.d.ts +89 -0
- package/dist/evaluator/env.d.ts.map +1 -0
- package/dist/evaluator/env.js +11 -0
- package/dist/evaluator/env.js.map +1 -0
- package/dist/evaluator/evaluator.d.ts +41 -75
- package/dist/evaluator/evaluator.d.ts.map +1 -1
- package/dist/evaluator/evaluator.js +914 -0
- package/dist/evaluator/evaluator.js.map +1 -0
- package/dist/evaluator/modifiers/compare.js +15 -0
- package/dist/evaluator/modifiers/compare.js.map +1 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts +57 -5
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
- package/dist/evaluator/modifiers/crit-threshold.js +42 -0
- package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
- package/dist/evaluator/modifiers/die-bound.d.ts +29 -0
- package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
- package/dist/evaluator/modifiers/die-bound.js +17 -0
- package/dist/evaluator/modifiers/die-bound.js.map +1 -0
- package/dist/evaluator/modifiers/explode.d.ts +13 -5
- package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
- package/dist/evaluator/modifiers/explode.js +105 -0
- package/dist/evaluator/modifiers/explode.js.map +1 -0
- package/dist/evaluator/modifiers/flags.d.ts +47 -0
- package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
- package/dist/evaluator/modifiers/flags.js +25 -0
- package/dist/evaluator/modifiers/flags.js.map +1 -0
- package/dist/evaluator/modifiers/keep-drop.d.ts +15 -29
- package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
- package/dist/evaluator/modifiers/keep-drop.js +82 -0
- package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
- package/dist/evaluator/modifiers/reroll.d.ts +12 -4
- package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
- package/dist/evaluator/modifiers/reroll.js +68 -0
- package/dist/evaluator/modifiers/reroll.js.map +1 -0
- package/dist/evaluator/modifiers/sort.d.ts +5 -1
- package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
- package/dist/evaluator/modifiers/sort.js +13 -0
- package/dist/evaluator/modifiers/sort.js.map +1 -0
- package/dist/evaluator/modifiers/success-count.d.ts +6 -7
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
- package/dist/evaluator/modifiers/success-count.js +25 -0
- package/dist/evaluator/modifiers/success-count.js.map +1 -0
- package/dist/index.d.ts +33 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2560
- package/dist/index.js.map +1 -26
- package/dist/lexer/lexer.d.ts +50 -5
- package/dist/lexer/lexer.d.ts.map +1 -1
- package/dist/lexer/lexer.js +260 -0
- package/dist/lexer/lexer.js.map +1 -0
- package/dist/lexer/tokens.d.ts +31 -6
- package/dist/lexer/tokens.d.ts.map +1 -1
- package/dist/lexer/tokens.js +42 -0
- package/dist/lexer/tokens.js.map +1 -0
- package/dist/parser/ast.d.ts +324 -165
- package/dist/parser/ast.d.ts.map +1 -1
- package/dist/parser/ast.js +52 -0
- package/dist/parser/ast.js.map +1 -0
- package/dist/parser/guards.d.ts +106 -0
- package/dist/parser/guards.d.ts.map +1 -0
- package/dist/parser/guards.js +121 -0
- package/dist/parser/guards.js.map +1 -0
- package/dist/parser/parser.d.ts +118 -14
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/parser/parser.js +751 -0
- package/dist/parser/parser.js.map +1 -0
- package/dist/render.d.ts +95 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +227 -0
- package/dist/render.js.map +1 -0
- package/dist/rng/mock.d.ts +73 -12
- package/dist/rng/mock.d.ts.map +1 -1
- package/dist/rng/mock.js +30 -0
- package/dist/rng/mock.js.map +1 -0
- package/dist/rng/seeded.d.ts +141 -9
- package/dist/rng/seeded.d.ts.map +1 -1
- package/dist/rng/seeded.js +138 -0
- package/dist/rng/seeded.js.map +1 -0
- package/dist/rng/types.d.ts +57 -0
- package/dist/rng/types.d.ts.map +1 -1
- package/dist/rng/types.js +2 -0
- package/dist/rng/types.js.map +1 -0
- package/dist/roll.d.ts +58 -28
- package/dist/roll.d.ts.map +1 -1
- package/dist/roll.js +8 -0
- package/dist/roll.js.map +1 -0
- package/dist/testing.d.ts +5 -4
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +2 -41
- package/dist/testing.js.map +1 -11
- package/dist/types.d.ts +349 -47
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/package.json +93 -34
- package/src/cli/args.ts +66 -10
- package/src/cli/format.ts +37 -26
- package/src/cli/index.ts +27 -84
- package/src/cli/main.ts +129 -0
- package/src/errors.ts +480 -27
- package/src/evaluator/die.ts +51 -0
- package/src/evaluator/env.ts +105 -0
- package/src/evaluator/evaluator.ts +693 -434
- package/src/evaluator/modifiers/crit-threshold.ts +96 -14
- package/src/evaluator/modifiers/die-bound.ts +48 -0
- package/src/evaluator/modifiers/explode.ts +70 -62
- package/src/evaluator/modifiers/flags.ts +78 -0
- package/src/evaluator/modifiers/keep-drop.ts +129 -127
- package/src/evaluator/modifiers/reroll.ts +44 -56
- package/src/evaluator/modifiers/sort.ts +21 -2
- package/src/evaluator/modifiers/success-count.ts +24 -12
- package/src/index.ts +56 -35
- package/src/lexer/lexer.ts +107 -34
- package/src/lexer/tokens.ts +31 -6
- package/src/parser/ast.ts +333 -346
- package/src/parser/guards.ts +248 -0
- package/src/parser/parser.ts +419 -242
- package/src/render.ts +392 -0
- package/src/rng/mock.ts +74 -13
- package/src/rng/seeded.ts +299 -64
- package/src/rng/types.ts +57 -0
- package/src/roll.ts +64 -47
- package/src/testing.ts +5 -9
- package/src/types.ts +353 -46
- package/src/version.ts +2 -0
- package/dist/cli.js +0 -2608
- package/dist/cli.js.map +0 -28
- package/dist/evaluator/index.d.ts +0 -8
- package/dist/evaluator/index.d.ts.map +0 -1
- package/dist/rng/index.d.ts +0 -8
- package/dist/rng/index.d.ts.map +0 -1
- package/src/evaluator/index.ts +0 -14
- package/src/rng/index.ts +0 -8
package/src/errors.ts
CHANGED
|
@@ -1,27 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Common error base class
|
|
2
|
+
* Common error base class, error codes, and span extraction for roll-parser.
|
|
3
3
|
*
|
|
4
4
|
* @module errors
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { ASTNode } from './parser/ast.js';
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* Every roll-parser error code, as a readonly tuple. Single source of truth —
|
|
11
|
+
* the {@link RollParserErrorCode} union is derived from it, and it is the
|
|
12
|
+
* runtime counterpart for the type: validating an untrusted string, driving an
|
|
13
|
+
* exhaustive UI (a message-catalog completeness check, a settings list), or
|
|
14
|
+
* iterating the codes in tests.
|
|
11
15
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* `INVALID_VARIABLE_VALUE`, `NON_FINITE_RESULT`
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* import { ROLL_PARSER_ERROR_CODES, type RollParserErrorCode } from 'roll-parser';
|
|
19
|
+
*
|
|
20
|
+
* function isKnownCode(value: string): value is RollParserErrorCode {
|
|
21
|
+
* return (ROLL_PARSER_ERROR_CODES as readonly string[]).includes(value);
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @category Errors
|
|
23
26
|
*/
|
|
24
|
-
const ROLL_PARSER_ERROR_CODES = [
|
|
27
|
+
export const ROLL_PARSER_ERROR_CODES = [
|
|
25
28
|
'UNEXPECTED_CHARACTER',
|
|
26
29
|
'UNEXPECTED_IDENTIFIER',
|
|
27
30
|
'UNEXPECTED_TOKEN',
|
|
@@ -34,8 +37,8 @@ const ROLL_PARSER_ERROR_CODES = [
|
|
|
34
37
|
'MODULO_BY_ZERO',
|
|
35
38
|
'UNKNOWN_OPERATOR',
|
|
36
39
|
'UNKNOWN_NODE_TYPE',
|
|
37
|
-
'
|
|
38
|
-
'
|
|
40
|
+
'INVALID_KEEP_DROP_COUNT',
|
|
41
|
+
'INVALID_KEEP_DROP_TARGET',
|
|
39
42
|
'EXPLODE_LIMIT_EXCEEDED',
|
|
40
43
|
'INVALID_EXPLODE_TARGET',
|
|
41
44
|
'REROLL_LIMIT_EXCEEDED',
|
|
@@ -43,6 +46,7 @@ const ROLL_PARSER_ERROR_CODES = [
|
|
|
43
46
|
'INVALID_SUCCESS_COUNT_TARGET',
|
|
44
47
|
'INVALID_SORT_TARGET',
|
|
45
48
|
'INVALID_CRIT_THRESHOLD_TARGET',
|
|
49
|
+
'INVALID_DIE_BOUND_TARGET',
|
|
46
50
|
'INVALID_THRESHOLD',
|
|
47
51
|
'NESTED_VERSUS',
|
|
48
52
|
'INVALID_FUNCTION_ARITY',
|
|
@@ -52,39 +56,488 @@ const ROLL_PARSER_ERROR_CODES = [
|
|
|
52
56
|
'AMBIGUOUS_DICE_CHAIN',
|
|
53
57
|
'MAX_DEPTH_EXCEEDED',
|
|
54
58
|
'NON_FINITE_RESULT',
|
|
59
|
+
'INCOMPATIBLE_RNG_STATE',
|
|
60
|
+
'INVALID_EVALUATION_LIMIT',
|
|
61
|
+
'INVALID_NOTATION_TYPE',
|
|
55
62
|
] as const;
|
|
56
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Programmatic identifier carried by every roll-parser error, grouped by the
|
|
66
|
+
* stage that raises it. The runtime list behind this union is exported as
|
|
67
|
+
* {@link ROLL_PARSER_ERROR_CODES}.
|
|
68
|
+
*
|
|
69
|
+
* Lexer: `UNEXPECTED_CHARACTER`, `UNEXPECTED_IDENTIFIER`
|
|
70
|
+
*
|
|
71
|
+
* Parser: `UNEXPECTED_TOKEN`, `UNEXPECTED_END`, `EXPECTED_TOKEN`,
|
|
72
|
+
* `INVALID_KEEP_DROP_TARGET`, `INVALID_EXPLODE_TARGET`, `INVALID_REROLL_TARGET`,
|
|
73
|
+
* `INVALID_SUCCESS_COUNT_TARGET`, `INVALID_SORT_TARGET`,
|
|
74
|
+
* `INVALID_CRIT_THRESHOLD_TARGET`, `INVALID_DIE_BOUND_TARGET`, `NESTED_VERSUS`,
|
|
75
|
+
* `INVALID_FUNCTION_ARITY`, `AMBIGUOUS_DICE_CHAIN`, `MAX_DEPTH_EXCEEDED`
|
|
76
|
+
*
|
|
77
|
+
* Evaluator: `INVALID_DICE_COUNT`, `INVALID_DICE_SIDES`, `DICE_LIMIT_EXCEEDED`,
|
|
78
|
+
* `DIVISION_BY_ZERO`, `MODULO_BY_ZERO`, `UNKNOWN_OPERATOR`, `UNKNOWN_NODE_TYPE`,
|
|
79
|
+
* `INVALID_KEEP_DROP_COUNT`, `EXPLODE_LIMIT_EXCEEDED`, `REROLL_LIMIT_EXCEEDED`,
|
|
80
|
+
* `INVALID_THRESHOLD`, `NESTED_VERSUS`, `UNKNOWN_FUNCTION`, `UNDEFINED_VARIABLE`,
|
|
81
|
+
* `INVALID_VARIABLE_VALUE`, `NON_FINITE_RESULT`
|
|
82
|
+
*
|
|
83
|
+
* RNG: `INCOMPATIBLE_RNG_STATE`
|
|
84
|
+
*
|
|
85
|
+
* Options: `INVALID_EVALUATION_LIMIT` — raised before evaluation begins, from
|
|
86
|
+
* the options object rather than from the notation, so it carries no span.
|
|
87
|
+
*
|
|
88
|
+
* Input: `INVALID_NOTATION_TYPE` — raised before lexing, when `notation` is not
|
|
89
|
+
* a string, so it carries no span either.
|
|
90
|
+
*
|
|
91
|
+
* New codes are only ever introduced in a minor release, never a patch. Treat
|
|
92
|
+
* the union as open when you switch over it: give the switch a `default` arm
|
|
93
|
+
* rather than relying on exhaustiveness, or a minor upgrade turns a new code
|
|
94
|
+
* into a silent fall-through.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```typescript
|
|
98
|
+
* import { isRollParserError, roll, type RollParserErrorCode } from 'roll-parser';
|
|
99
|
+
*
|
|
100
|
+
* const MESSAGES: Partial<Record<RollParserErrorCode, string>> = {
|
|
101
|
+
* DICE_LIMIT_EXCEEDED: 'That is too many dice.',
|
|
102
|
+
* DIVISION_BY_ZERO: 'Cannot divide by zero.',
|
|
103
|
+
* UNEXPECTED_CHARACTER: 'That is not valid dice notation.',
|
|
104
|
+
* };
|
|
105
|
+
*
|
|
106
|
+
* try {
|
|
107
|
+
* roll(userInput);
|
|
108
|
+
* } catch (error) {
|
|
109
|
+
* if (isRollParserError(error)) {
|
|
110
|
+
* reply(MESSAGES[error.code] ?? error.message);
|
|
111
|
+
* }
|
|
112
|
+
* }
|
|
113
|
+
* ```
|
|
114
|
+
*
|
|
115
|
+
* @category Errors
|
|
116
|
+
*/
|
|
57
117
|
export type RollParserErrorCode = (typeof ROLL_PARSER_ERROR_CODES)[number];
|
|
58
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The subset of {@link ROLL_PARSER_ERROR_CODES} the *input* is answerable for,
|
|
121
|
+
* as a readonly tuple. Runtime counterpart of {@link NotationErrorCode} and the
|
|
122
|
+
* list {@link isNotationError} matches against.
|
|
123
|
+
*
|
|
124
|
+
* A code is in when `roll(notation)` can raise it for some notation string,
|
|
125
|
+
* given valid options and a valid `context` — so the right response is to tell
|
|
126
|
+
* whoever supplied the notation that it was rejected. Six codes are out, because
|
|
127
|
+
* for each of them the notation is innocent:
|
|
128
|
+
*
|
|
129
|
+
* Calling code: `INVALID_EVALUATION_LIMIT` (a bad `maxDice`,
|
|
130
|
+
* `maxExplodeIterations`, or `maxRerollIterations`), `INVALID_VARIABLE_VALUE` (a
|
|
131
|
+
* non-finite entry in `context`), `INCOMPATIBLE_RNG_STATE` (a snapshot from
|
|
132
|
+
* another version)
|
|
133
|
+
*
|
|
134
|
+
* Library invariant: `UNKNOWN_NODE_TYPE`, `UNKNOWN_OPERATOR`,
|
|
135
|
+
* `UNKNOWN_FUNCTION` — the lexer and parser only ever hand the evaluator shapes
|
|
136
|
+
* it already covers, so reaching one means a hand-built AST or a bug in here.
|
|
137
|
+
*
|
|
138
|
+
* Two boundaries are worth knowing. `DIVISION_BY_ZERO`, `MODULO_BY_ZERO`, and
|
|
139
|
+
* `NON_FINITE_RESULT` are in because notation alone reaches them (`1d6/0`,
|
|
140
|
+
* `10**400`), but a `context` variable reaches them too, so a `true` is not
|
|
141
|
+
* proof the notation was at fault. `INVALID_NOTATION_TYPE` is in even though no
|
|
142
|
+
* user can type a non-string: it means the `notation` you were handed was
|
|
143
|
+
* `null` or `undefined` — an absent slash-command option, a missing JSON field —
|
|
144
|
+
* and "give me a dice expression" is the reply that fits.
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```typescript
|
|
148
|
+
* import { NOTATION_ERROR_CODES } from 'roll-parser';
|
|
149
|
+
*
|
|
150
|
+
* // Prompt copy is only worth writing for the codes a user can actually cause.
|
|
151
|
+
* const needsCopy = new Set(NOTATION_ERROR_CODES);
|
|
152
|
+
* ```
|
|
153
|
+
*
|
|
154
|
+
* @category Errors
|
|
155
|
+
*/
|
|
156
|
+
export const NOTATION_ERROR_CODES = [
|
|
157
|
+
'UNEXPECTED_CHARACTER',
|
|
158
|
+
'UNEXPECTED_IDENTIFIER',
|
|
159
|
+
'UNEXPECTED_TOKEN',
|
|
160
|
+
'UNEXPECTED_END',
|
|
161
|
+
'EXPECTED_TOKEN',
|
|
162
|
+
'INVALID_DICE_COUNT',
|
|
163
|
+
'INVALID_DICE_SIDES',
|
|
164
|
+
'DICE_LIMIT_EXCEEDED',
|
|
165
|
+
'DIVISION_BY_ZERO',
|
|
166
|
+
'MODULO_BY_ZERO',
|
|
167
|
+
'INVALID_KEEP_DROP_COUNT',
|
|
168
|
+
'INVALID_KEEP_DROP_TARGET',
|
|
169
|
+
'EXPLODE_LIMIT_EXCEEDED',
|
|
170
|
+
'INVALID_EXPLODE_TARGET',
|
|
171
|
+
'REROLL_LIMIT_EXCEEDED',
|
|
172
|
+
'INVALID_REROLL_TARGET',
|
|
173
|
+
'INVALID_SUCCESS_COUNT_TARGET',
|
|
174
|
+
'INVALID_SORT_TARGET',
|
|
175
|
+
'INVALID_CRIT_THRESHOLD_TARGET',
|
|
176
|
+
'INVALID_DIE_BOUND_TARGET',
|
|
177
|
+
'INVALID_THRESHOLD',
|
|
178
|
+
'NESTED_VERSUS',
|
|
179
|
+
'INVALID_FUNCTION_ARITY',
|
|
180
|
+
'UNDEFINED_VARIABLE',
|
|
181
|
+
'AMBIGUOUS_DICE_CHAIN',
|
|
182
|
+
'MAX_DEPTH_EXCEEDED',
|
|
183
|
+
'NON_FINITE_RESULT',
|
|
184
|
+
'INVALID_NOTATION_TYPE',
|
|
185
|
+
] as const;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A {@link RollParserErrorCode} the input is answerable for. The runtime list
|
|
189
|
+
* behind this union is exported as {@link NOTATION_ERROR_CODES}, which documents
|
|
190
|
+
* where the line falls.
|
|
191
|
+
*
|
|
192
|
+
* @category Errors
|
|
193
|
+
*/
|
|
194
|
+
export type NotationErrorCode = (typeof NOTATION_ERROR_CODES)[number];
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Key of the brand {@link isRollParserError} matches on. A *registered* symbol,
|
|
198
|
+
* because the global symbol registry is shared by every realm in an agent — so
|
|
199
|
+
* the same key resolves from an iframe or a `vm` context, and from a second copy
|
|
200
|
+
* of the library in `node_modules`, provided that copy is new enough to carry
|
|
201
|
+
* the brand at all.
|
|
202
|
+
*/
|
|
203
|
+
// ! The string is wire format between library copies. Changing it severs
|
|
204
|
+
// ! recognition across versions, so it is fixed for the lifetime of the package.
|
|
205
|
+
const ERROR_BRAND = Symbol.for('roll-parser.error');
|
|
206
|
+
|
|
59
207
|
/**
|
|
60
208
|
* Base error class for all roll-parser errors.
|
|
61
209
|
*
|
|
62
210
|
* Provides a typed `code` field for programmatic error handling.
|
|
63
211
|
* All library errors (`LexerError`, `ParseError`, `EvaluatorError`)
|
|
64
212
|
* extend this class.
|
|
213
|
+
*
|
|
214
|
+
* Error messages never embed the source position — every subclass reports it
|
|
215
|
+
* through structured fields instead, readable uniformly via
|
|
216
|
+
* {@link getErrorSpan}. Prefer {@link isRollParserError} over `instanceof`: it
|
|
217
|
+
* also matches errors from another realm or a duplicate copy of the library.
|
|
218
|
+
*
|
|
219
|
+
* @example
|
|
220
|
+
* ```typescript
|
|
221
|
+
* import { roll, RollParserError } from 'roll-parser';
|
|
222
|
+
*
|
|
223
|
+
* try {
|
|
224
|
+
* roll('1d6/0');
|
|
225
|
+
* } catch (error) {
|
|
226
|
+
* const typed = error as RollParserError;
|
|
227
|
+
* typed.name; // 'EvaluatorError'
|
|
228
|
+
* typed.code; // 'DIVISION_BY_ZERO'
|
|
229
|
+
* typed.message; // 'Division by zero'
|
|
230
|
+
* }
|
|
231
|
+
* ```
|
|
232
|
+
*
|
|
233
|
+
* @category Errors
|
|
65
234
|
*/
|
|
66
235
|
export class RollParserError extends Error {
|
|
236
|
+
/**
|
|
237
|
+
* Stable programmatic identifier for the failure. Branch on this rather
|
|
238
|
+
* than on `message`, which is free to change between releases.
|
|
239
|
+
*/
|
|
67
240
|
readonly code: RollParserErrorCode;
|
|
68
241
|
|
|
69
|
-
constructor(message: string, code: RollParserErrorCode) {
|
|
70
|
-
super(message);
|
|
242
|
+
constructor(message: string, code: RollParserErrorCode, options?: ErrorOptions) {
|
|
243
|
+
super(message, options);
|
|
71
244
|
this.name = 'RollParserError';
|
|
72
245
|
this.code = code;
|
|
73
246
|
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Brand {@link isRollParserError} looks for.
|
|
250
|
+
*
|
|
251
|
+
* An accessor, not a field or a module-level `defineProperty`: it lands on the
|
|
252
|
+
* prototype — free per error, inherited by every subclass, invisible to spread
|
|
253
|
+
* and `JSON.stringify` — without a top-level statement, which would contradict
|
|
254
|
+
* the package's side-effect-free declaration.
|
|
255
|
+
*
|
|
256
|
+
* @internal `stripInternal` drops it from the published `.d.ts`.
|
|
257
|
+
*/
|
|
258
|
+
get [ERROR_BRAND](): true {
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Records the source span of the node being evaluated on an
|
|
265
|
+
* {@link EvaluatorError}. Idempotent — the first stamp wins, so the innermost
|
|
266
|
+
* `evalNode` frame keeps the tightest span as the error bubbles up.
|
|
267
|
+
*
|
|
268
|
+
* @internal Called only by `evalNode`; `stripInternal` drops it from the
|
|
269
|
+
* published `.d.ts`.
|
|
270
|
+
*/
|
|
271
|
+
// ! A module-scoped function, not a method: `EvaluatorError` is exported, so a
|
|
272
|
+
// ! method would let any consumer overwrite a caught error's span. Assigned from
|
|
273
|
+
// ! the `static` block below — the only scope `#start` / `#end` are reachable
|
|
274
|
+
// ! from — and never re-exported from `index.ts`.
|
|
275
|
+
export let stampEvaluatorSpan!: (
|
|
276
|
+
error: EvaluatorError,
|
|
277
|
+
start: number,
|
|
278
|
+
end: number | undefined,
|
|
279
|
+
) => void;
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Error thrown during AST evaluation.
|
|
283
|
+
*
|
|
284
|
+
* Lives here rather than in `evaluator/evaluator.ts` so the modifier
|
|
285
|
+
* implementations can throw it without importing back into the evaluator —
|
|
286
|
+
* that value-level round trip was a genuine ESM cycle. `evaluator.ts`
|
|
287
|
+
* re-exports the class for existing importers.
|
|
288
|
+
*
|
|
289
|
+
* Unlike `LexerError` / `ParseError` — which point at a single offset — an
|
|
290
|
+
* `EvaluatorError` carries a full `start`/`end` span covering the failing
|
|
291
|
+
* sub-expression, because by evaluation time the AST knows its own extent.
|
|
292
|
+
*
|
|
293
|
+
* @example
|
|
294
|
+
* ```typescript
|
|
295
|
+
* import { getErrorSpan, roll } from 'roll-parser';
|
|
296
|
+
*
|
|
297
|
+
* try {
|
|
298
|
+
* roll('2d6+1d0+3');
|
|
299
|
+
* } catch (error) {
|
|
300
|
+
* (error as Error).name; // 'EvaluatorError'
|
|
301
|
+
* getErrorSpan(error); // { start: 4, end: 7 } — the '1d0' sub-expression
|
|
302
|
+
* }
|
|
303
|
+
* ```
|
|
304
|
+
*
|
|
305
|
+
* @category Errors
|
|
306
|
+
*/
|
|
307
|
+
export class EvaluatorError extends RollParserError {
|
|
308
|
+
/**
|
|
309
|
+
* `ASTNode.type` of the node that raised the error (`'Dice'`, `'BinaryOp'`,
|
|
310
|
+
* …). `undefined` for failures raised outside a node context, such as the
|
|
311
|
+
* whole-expression dice budget.
|
|
312
|
+
*/
|
|
313
|
+
readonly nodeType: ASTNode['type'] | undefined;
|
|
314
|
+
|
|
315
|
+
#start: number | undefined;
|
|
316
|
+
#end: number | undefined;
|
|
317
|
+
|
|
318
|
+
constructor(
|
|
319
|
+
message: string,
|
|
320
|
+
code: RollParserErrorCode,
|
|
321
|
+
nodeType?: ASTNode['type'],
|
|
322
|
+
options?: ErrorOptions,
|
|
323
|
+
) {
|
|
324
|
+
super(message, code, options);
|
|
325
|
+
this.name = 'EvaluatorError';
|
|
326
|
+
this.nodeType = nodeType;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Start offset of the tightest AST node that was being evaluated when the
|
|
331
|
+
* error was thrown. `undefined` when the AST was built without parser spans.
|
|
332
|
+
*/
|
|
333
|
+
get start(): number | undefined {
|
|
334
|
+
return this.#start;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Exclusive end offset of the span described by `start`. */
|
|
338
|
+
get end(): number | undefined {
|
|
339
|
+
return this.#end;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
static {
|
|
343
|
+
stampEvaluatorSpan = (error, start, end) => {
|
|
344
|
+
if (error.#start != null) return;
|
|
345
|
+
error.#start = start;
|
|
346
|
+
error.#end = end;
|
|
347
|
+
};
|
|
348
|
+
}
|
|
74
349
|
}
|
|
75
350
|
|
|
76
|
-
|
|
351
|
+
/**
|
|
352
|
+
* Source span of an error, in UTF-16 code units into the original notation.
|
|
353
|
+
* `start` is inclusive; `end` is exclusive and present only when the error
|
|
354
|
+
* carries a full span (evaluator errors) rather than a single offset.
|
|
355
|
+
*
|
|
356
|
+
* @category Errors
|
|
357
|
+
*/
|
|
358
|
+
export type ErrorSpan = {
|
|
359
|
+
start: number;
|
|
360
|
+
end?: number;
|
|
361
|
+
};
|
|
77
362
|
|
|
78
363
|
/**
|
|
79
|
-
* Type guard for roll-parser errors. Checks `instanceof` first, then
|
|
80
|
-
*
|
|
364
|
+
* Type guard for roll-parser errors. Checks `instanceof` first, then a brand
|
|
365
|
+
* carried on the error's prototype — so it still matches when `instanceof`
|
|
366
|
+
* cannot, namely an error from an iframe or `vm` context, or from a second copy
|
|
367
|
+
* of the library in `node_modules` at this version or newer.
|
|
368
|
+
*
|
|
369
|
+
* Use it as the outer filter in every `catch`: anything it rejects came from
|
|
370
|
+
* somewhere else and should be rethrown. The brand is what makes that sound — a
|
|
371
|
+
* foreign error is never accepted just for carrying a `code` that happens to
|
|
372
|
+
* collide with one of ours.
|
|
373
|
+
*
|
|
374
|
+
* What it answers is "did this come from us", not "whose fault was it". A `true`
|
|
375
|
+
* covers bad notation, a bad options object, and a broken invariant in here
|
|
376
|
+
* alike, so it is the wrong test to hang a user-facing message on. Reach for
|
|
377
|
+
* {@link isNotationError} for that.
|
|
378
|
+
*
|
|
379
|
+
* Only this library's own error prototype carries the brand, so holding it is
|
|
380
|
+
* proof of origin, and the `code` is trusted rather than re-validated: an error
|
|
381
|
+
* from a newer minor passes with a code this build has never heard of, which is
|
|
382
|
+
* what {@link RollParserErrorCode} being an open union already implies. A value
|
|
383
|
+
* that forges the brand is out of scope, as it is for any brand check.
|
|
384
|
+
*
|
|
385
|
+
* The one boundary it cannot cross is a worker. `postMessage` and
|
|
386
|
+
* `structuredClone` rebuild an `Error` from `message` and `stack` alone,
|
|
387
|
+
* discarding `code`, `name`, and the prototype with it, so the value that
|
|
388
|
+
* arrives is no longer recognizable as anything. Send `error.code` yourself as
|
|
389
|
+
* part of the message payload if the other side needs it.
|
|
390
|
+
*
|
|
391
|
+
* @param value - The caught value, of unknown type
|
|
392
|
+
* @returns `true` when `value` is a roll-parser error
|
|
393
|
+
*
|
|
394
|
+
* @example
|
|
395
|
+
* ```typescript
|
|
396
|
+
* import { isRollParserError, roll } from 'roll-parser';
|
|
397
|
+
*
|
|
398
|
+
* try {
|
|
399
|
+
* roll('2d6+&');
|
|
400
|
+
* } catch (error) {
|
|
401
|
+
* if (!isRollParserError(error)) throw error;
|
|
402
|
+
* error.code; // 'UNEXPECTED_CHARACTER'
|
|
403
|
+
* error.message; // "Unexpected character: '&'"
|
|
404
|
+
* }
|
|
405
|
+
* ```
|
|
406
|
+
*
|
|
407
|
+
* @category Errors
|
|
81
408
|
*/
|
|
82
409
|
export function isRollParserError(value: unknown): value is RollParserError {
|
|
83
410
|
if (value instanceof RollParserError) return true;
|
|
84
411
|
return (
|
|
85
|
-
value
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
VALID_CODES.has((value as RollParserError).code)
|
|
412
|
+
typeof value === 'object' &&
|
|
413
|
+
value !== null &&
|
|
414
|
+
(value as Record<symbol, unknown>)[ERROR_BRAND] === true
|
|
89
415
|
);
|
|
90
416
|
}
|
|
417
|
+
|
|
418
|
+
const NOTATION_CODES: ReadonlySet<string> = new Set(NOTATION_ERROR_CODES);
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Type guard for the failures the input is answerable for: a roll-parser error
|
|
422
|
+
* whose `code` is one of {@link NOTATION_ERROR_CODES}. This is the test to hang
|
|
423
|
+
* a user-facing message on — {@link isRollParserError} only establishes origin,
|
|
424
|
+
* and answers `true` for a bad options object and a broken library invariant
|
|
425
|
+
* too, both of which should page you instead.
|
|
426
|
+
*
|
|
427
|
+
* Pair the two: the outer filter rethrows what is not ours, and this one splits
|
|
428
|
+
* what is left into "tell the user" and "report a bug".
|
|
429
|
+
*
|
|
430
|
+
* Unlike {@link isRollParserError}, this one has to read the `code` against the
|
|
431
|
+
* list this build carries — attribution is not something a brand can express. So
|
|
432
|
+
* where the outer filter accepts a code it has never heard of, this one rejects
|
|
433
|
+
* it: an error from a newer minor carrying a notation code added after this build
|
|
434
|
+
* reads as `false` and is misfiled as internal. That is the safe direction — it
|
|
435
|
+
* pages a developer rather than blaming a user — but keep the library and its
|
|
436
|
+
* consumers on one version when the distinction drives more than a message.
|
|
437
|
+
*
|
|
438
|
+
* @param value - The caught value, of unknown type
|
|
439
|
+
* @returns `true` when `value` is a roll-parser error the input caused
|
|
440
|
+
*
|
|
441
|
+
* @example Two channels, one catch
|
|
442
|
+
* ```typescript
|
|
443
|
+
* import { isNotationError, isRollParserError, roll } from 'roll-parser';
|
|
444
|
+
*
|
|
445
|
+
* try {
|
|
446
|
+
* roll(userInput);
|
|
447
|
+
* } catch (error) {
|
|
448
|
+
* if (!isRollParserError(error)) throw error;
|
|
449
|
+
* if (isNotationError(error)) reply(`Bad notation: ${error.message}`);
|
|
450
|
+
* else report(error); // our bug or yours — never the user's
|
|
451
|
+
* }
|
|
452
|
+
* ```
|
|
453
|
+
*
|
|
454
|
+
* @category Errors
|
|
455
|
+
*/
|
|
456
|
+
export function isNotationError(
|
|
457
|
+
value: unknown,
|
|
458
|
+
): value is RollParserError & { code: NotationErrorCode } {
|
|
459
|
+
return isRollParserError(value) && NOTATION_CODES.has(value.code);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Renders a rejected value for an error message, never coercing an object:
|
|
464
|
+
* `String(Object.create(null))` throws, and a hostile `toString` can too —
|
|
465
|
+
* either would replace the typed error with a raw `TypeError`.
|
|
466
|
+
*
|
|
467
|
+
* Module-level export, deliberately absent from `src/index.ts` — the package
|
|
468
|
+
* surface never mentions it.
|
|
469
|
+
*/
|
|
470
|
+
export function describeValue(value: unknown): string {
|
|
471
|
+
// Quoted, so the message tells `'5'` and `5` apart.
|
|
472
|
+
if (typeof value === 'string') return JSON.stringify(value);
|
|
473
|
+
// Ahead of the `typeof` checks: `typeof null` is `'object'`.
|
|
474
|
+
if (value === null) return 'null';
|
|
475
|
+
if (typeof value === 'object' || typeof value === 'function') return typeof value;
|
|
476
|
+
return String(value);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** True for finite, non-negative integer offsets. */
|
|
480
|
+
function isOffset(value: unknown): value is number {
|
|
481
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Normalizes the three error position shapes into one span.
|
|
486
|
+
*
|
|
487
|
+
* `LexerError` and `ParseError` expose a single `position`; `EvaluatorError`
|
|
488
|
+
* exposes `start`/`end`. Returns `undefined` for errors that are not
|
|
489
|
+
* roll-parser errors, or that carry no usable offset (an `EvaluatorError`
|
|
490
|
+
* raised on a hand-built AST, for instance).
|
|
491
|
+
*
|
|
492
|
+
* @param error - The caught value, of unknown type
|
|
493
|
+
* @returns The span, or `undefined` when none is available
|
|
494
|
+
*
|
|
495
|
+
* @example Rendering a caret, the way the CLI does
|
|
496
|
+
* ```typescript
|
|
497
|
+
* import { getErrorSpan, isRollParserError, roll } from 'roll-parser';
|
|
498
|
+
*
|
|
499
|
+
* function explain(notation: string): string | undefined {
|
|
500
|
+
* try {
|
|
501
|
+
* roll(notation);
|
|
502
|
+
* return undefined;
|
|
503
|
+
* } catch (error) {
|
|
504
|
+
* if (!isRollParserError(error)) throw error;
|
|
505
|
+
* const span = getErrorSpan(error);
|
|
506
|
+
* if (span == null) return error.message;
|
|
507
|
+
* const width = (span.end ?? span.start + 1) - span.start;
|
|
508
|
+
* return [
|
|
509
|
+
* error.message,
|
|
510
|
+
* notation,
|
|
511
|
+
* ' '.repeat(span.start) + '^'.repeat(width),
|
|
512
|
+
* ].join('\n');
|
|
513
|
+
* }
|
|
514
|
+
* }
|
|
515
|
+
*
|
|
516
|
+
* explain('2d6+&');
|
|
517
|
+
* // Unexpected character: '&'
|
|
518
|
+
* // 2d6+&
|
|
519
|
+
* // ^
|
|
520
|
+
*
|
|
521
|
+
* explain('2d6+1d0+3');
|
|
522
|
+
* // Invalid dice sides: 0
|
|
523
|
+
* // 2d6+1d0+3
|
|
524
|
+
* // ^^^
|
|
525
|
+
* ```
|
|
526
|
+
*
|
|
527
|
+
* @category Errors
|
|
528
|
+
*/
|
|
529
|
+
export function getErrorSpan(error: unknown): ErrorSpan | undefined {
|
|
530
|
+
if (!isRollParserError(error)) return undefined;
|
|
531
|
+
|
|
532
|
+
if ('position' in error && isOffset(error.position)) {
|
|
533
|
+
return { start: error.position };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if ('start' in error && isOffset(error.start)) {
|
|
537
|
+
const span: ErrorSpan = { start: error.start };
|
|
538
|
+
if ('end' in error && isOffset(error.end)) span.end = error.end;
|
|
539
|
+
return span;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return undefined;
|
|
543
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Die-result construction shared by the evaluator and the modifier
|
|
3
|
+
* implementations.
|
|
4
|
+
*
|
|
5
|
+
* Lives in its own module so the crit/fumble rule has exactly one definition —
|
|
6
|
+
* pool rolling (`evaluator.ts`), explosion continuation dice (`modifiers/
|
|
7
|
+
* explode.ts`), and reroll replacements (`modifiers/reroll.ts`) all build dice
|
|
8
|
+
* through here.
|
|
9
|
+
*
|
|
10
|
+
* @module evaluator/die
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { DieModifier, DieResult } from '../types.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Creates a die result with critical/fumble detection.
|
|
17
|
+
*
|
|
18
|
+
* `modifiers` is taken by the caller because the flags depend on how the die
|
|
19
|
+
* entered the pool (a fresh pool die starts bare, an explosion continuation
|
|
20
|
+
* die starts `['exploded', 'kept']`).
|
|
21
|
+
*/
|
|
22
|
+
export function createDieResult(
|
|
23
|
+
sides: number,
|
|
24
|
+
result: number,
|
|
25
|
+
modifiers: DieModifier[],
|
|
26
|
+
): DieResult {
|
|
27
|
+
// `sides > 1` guards both flags — a d1 always rolls 1, so it is neither
|
|
28
|
+
// an exceptional max (critical) nor an exceptional min (fumble).
|
|
29
|
+
return {
|
|
30
|
+
sides,
|
|
31
|
+
result,
|
|
32
|
+
modifiers,
|
|
33
|
+
critical: result === sides && sides > 1,
|
|
34
|
+
fumble: result === 1 && sides > 1,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Creates a Fate/Fudge die result. Uses `sides = 0` as a sentinel — Fate dice
|
|
40
|
+
* have no max-face concept, so `critical` and `fumble` start `false` and the
|
|
41
|
+
* default rule never sets them. An explicit `cs`/`cf` threshold still can.
|
|
42
|
+
*/
|
|
43
|
+
export function createFateDieResult(result: number, modifiers: DieModifier[]): DieResult {
|
|
44
|
+
return {
|
|
45
|
+
sides: 0,
|
|
46
|
+
result,
|
|
47
|
+
modifiers,
|
|
48
|
+
critical: false,
|
|
49
|
+
fumble: false,
|
|
50
|
+
};
|
|
51
|
+
}
|