roll-parser 3.0.0-beta.0 → 3.0.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.
Files changed (159) hide show
  1. package/CHANGELOG.md +107 -9
  2. package/MIGRATION.md +147 -0
  3. package/README.md +960 -154
  4. package/dist/cli/args.d.ts +1 -0
  5. package/dist/cli/args.d.ts.map +1 -1
  6. package/dist/cli/args.js +81 -0
  7. package/dist/cli/args.js.map +1 -0
  8. package/dist/cli/format.d.ts +15 -3
  9. package/dist/cli/format.d.ts.map +1 -1
  10. package/dist/cli/format.js +17 -0
  11. package/dist/cli/format.js.map +1 -0
  12. package/dist/cli/index.d.ts +3 -0
  13. package/dist/cli/index.d.ts.map +1 -1
  14. package/dist/cli/index.js +14 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/cli/main.d.ts +36 -0
  17. package/dist/cli/main.d.ts.map +1 -0
  18. package/dist/cli/main.js +83 -0
  19. package/dist/cli/main.js.map +1 -0
  20. package/dist/errors.d.ts +332 -17
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +141 -0
  23. package/dist/errors.js.map +1 -0
  24. package/dist/evaluator/die.d.ts +26 -0
  25. package/dist/evaluator/die.d.ts.map +1 -0
  26. package/dist/evaluator/die.js +19 -0
  27. package/dist/evaluator/die.js.map +1 -0
  28. package/dist/evaluator/env.d.ts +58 -0
  29. package/dist/evaluator/env.d.ts.map +1 -0
  30. package/dist/evaluator/env.js +11 -0
  31. package/dist/evaluator/env.js.map +1 -0
  32. package/dist/evaluator/evaluator.d.ts +41 -75
  33. package/dist/evaluator/evaluator.d.ts.map +1 -1
  34. package/dist/evaluator/evaluator.js +906 -0
  35. package/dist/evaluator/evaluator.js.map +1 -0
  36. package/dist/evaluator/modifiers/compare.js +15 -0
  37. package/dist/evaluator/modifiers/compare.js.map +1 -0
  38. package/dist/evaluator/modifiers/crit-threshold.d.ts +0 -1
  39. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  40. package/dist/evaluator/modifiers/crit-threshold.js +23 -0
  41. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
  42. package/dist/evaluator/modifiers/die-bound.d.ts +26 -0
  43. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
  44. package/dist/evaluator/modifiers/die-bound.js +14 -0
  45. package/dist/evaluator/modifiers/die-bound.js.map +1 -0
  46. package/dist/evaluator/modifiers/explode.d.ts +13 -5
  47. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  48. package/dist/evaluator/modifiers/explode.js +103 -0
  49. package/dist/evaluator/modifiers/explode.js.map +1 -0
  50. package/dist/evaluator/modifiers/flags.d.ts +37 -0
  51. package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
  52. package/dist/evaluator/modifiers/flags.js +18 -0
  53. package/dist/evaluator/modifiers/flags.js.map +1 -0
  54. package/dist/evaluator/modifiers/keep-drop.d.ts +12 -28
  55. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  56. package/dist/evaluator/modifiers/keep-drop.js +82 -0
  57. package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
  58. package/dist/evaluator/modifiers/reroll.d.ts +12 -4
  59. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  60. package/dist/evaluator/modifiers/reroll.js +62 -0
  61. package/dist/evaluator/modifiers/reroll.js.map +1 -0
  62. package/dist/evaluator/modifiers/sort.d.ts +4 -0
  63. package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
  64. package/dist/evaluator/modifiers/sort.js +13 -0
  65. package/dist/evaluator/modifiers/sort.js.map +1 -0
  66. package/dist/evaluator/modifiers/success-count.d.ts +2 -6
  67. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  68. package/dist/evaluator/modifiers/success-count.js +24 -0
  69. package/dist/evaluator/modifiers/success-count.js.map +1 -0
  70. package/dist/index.d.ts +33 -11
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +12 -2560
  73. package/dist/index.js.map +1 -26
  74. package/dist/lexer/lexer.d.ts +50 -5
  75. package/dist/lexer/lexer.d.ts.map +1 -1
  76. package/dist/lexer/lexer.js +260 -0
  77. package/dist/lexer/lexer.js.map +1 -0
  78. package/dist/lexer/tokens.d.ts +31 -6
  79. package/dist/lexer/tokens.d.ts.map +1 -1
  80. package/dist/lexer/tokens.js +42 -0
  81. package/dist/lexer/tokens.js.map +1 -0
  82. package/dist/parser/ast.d.ts +314 -160
  83. package/dist/parser/ast.d.ts.map +1 -1
  84. package/dist/parser/ast.js +52 -0
  85. package/dist/parser/ast.js.map +1 -0
  86. package/dist/parser/guards.d.ts +106 -0
  87. package/dist/parser/guards.d.ts.map +1 -0
  88. package/dist/parser/guards.js +121 -0
  89. package/dist/parser/guards.js.map +1 -0
  90. package/dist/parser/parser.d.ts +118 -14
  91. package/dist/parser/parser.d.ts.map +1 -1
  92. package/dist/parser/parser.js +751 -0
  93. package/dist/parser/parser.js.map +1 -0
  94. package/dist/rng/mock.d.ts +73 -12
  95. package/dist/rng/mock.d.ts.map +1 -1
  96. package/dist/rng/mock.js +30 -0
  97. package/dist/rng/mock.js.map +1 -0
  98. package/dist/rng/seeded.d.ts +141 -9
  99. package/dist/rng/seeded.d.ts.map +1 -1
  100. package/dist/rng/seeded.js +138 -0
  101. package/dist/rng/seeded.js.map +1 -0
  102. package/dist/rng/types.d.ts +57 -0
  103. package/dist/rng/types.d.ts.map +1 -1
  104. package/dist/rng/types.js +2 -0
  105. package/dist/rng/types.js.map +1 -0
  106. package/dist/roll.d.ts +58 -28
  107. package/dist/roll.d.ts.map +1 -1
  108. package/dist/roll.js +8 -0
  109. package/dist/roll.js.map +1 -0
  110. package/dist/testing.d.ts +5 -4
  111. package/dist/testing.d.ts.map +1 -1
  112. package/dist/testing.js +2 -41
  113. package/dist/testing.js.map +1 -11
  114. package/dist/types.d.ts +306 -44
  115. package/dist/types.d.ts.map +1 -1
  116. package/dist/types.js +8 -0
  117. package/dist/types.js.map +1 -0
  118. package/dist/version.d.ts +2 -0
  119. package/dist/version.d.ts.map +1 -0
  120. package/dist/version.js +2 -0
  121. package/dist/version.js.map +1 -0
  122. package/package.json +83 -34
  123. package/src/cli/args.ts +66 -10
  124. package/src/cli/format.ts +22 -3
  125. package/src/cli/index.ts +27 -84
  126. package/src/cli/main.ts +129 -0
  127. package/src/errors.ts +480 -27
  128. package/src/evaluator/die.ts +50 -0
  129. package/src/evaluator/env.ts +73 -0
  130. package/src/evaluator/evaluator.ts +653 -421
  131. package/src/evaluator/modifiers/crit-threshold.ts +3 -6
  132. package/src/evaluator/modifiers/die-bound.ts +39 -0
  133. package/src/evaluator/modifiers/explode.ts +60 -58
  134. package/src/evaluator/modifiers/flags.ts +61 -0
  135. package/src/evaluator/modifiers/keep-drop.ts +124 -126
  136. package/src/evaluator/modifiers/reroll.ts +28 -49
  137. package/src/evaluator/modifiers/sort.ts +14 -1
  138. package/src/evaluator/modifiers/success-count.ts +5 -8
  139. package/src/index.ts +56 -35
  140. package/src/lexer/lexer.ts +107 -34
  141. package/src/lexer/tokens.ts +31 -6
  142. package/src/parser/ast.ts +323 -341
  143. package/src/parser/guards.ts +248 -0
  144. package/src/parser/parser.ts +419 -242
  145. package/src/rng/mock.ts +74 -13
  146. package/src/rng/seeded.ts +299 -64
  147. package/src/rng/types.ts +57 -0
  148. package/src/roll.ts +64 -47
  149. package/src/testing.ts +5 -9
  150. package/src/types.ts +310 -43
  151. package/src/version.ts +2 -0
  152. package/dist/cli.js +0 -2608
  153. package/dist/cli.js.map +0 -28
  154. package/dist/evaluator/index.d.ts +0 -8
  155. package/dist/evaluator/index.d.ts.map +0 -1
  156. package/dist/rng/index.d.ts +0 -8
  157. package/dist/rng/index.d.ts.map +0 -1
  158. package/src/evaluator/index.ts +0 -14
  159. package/src/rng/index.ts +0 -8
package/src/errors.ts CHANGED
@@ -1,27 +1,30 @@
1
1
  /**
2
- * Common error base class and error codes for roll-parser.
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
- * All known roll-parser error codes. Single source of truth — the
9
- * `RollParserErrorCode` type and the runtime `VALID_CODES` set are
10
- * both derived from this array.
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
- * Lexer: `UNEXPECTED_CHARACTER`, `UNEXPECTED_IDENTIFIER`
13
- * Parser: `UNEXPECTED_TOKEN`, `UNEXPECTED_END`, `EXPECTED_TOKEN`,
14
- * `INVALID_MODIFIER_TARGET`, `INVALID_EXPLODE_TARGET`, `INVALID_REROLL_TARGET`,
15
- * `INVALID_SUCCESS_COUNT_TARGET`, `INVALID_SORT_TARGET`,
16
- * `INVALID_CRIT_THRESHOLD_TARGET`, `NESTED_VERSUS`, `INVALID_FUNCTION_ARITY`,
17
- * `AMBIGUOUS_DICE_CHAIN`, `MAX_DEPTH_EXCEEDED`
18
- * Evaluator: `INVALID_DICE_COUNT`, `INVALID_DICE_SIDES`, `DICE_LIMIT_EXCEEDED`,
19
- * `DIVISION_BY_ZERO`, `MODULO_BY_ZERO`, `UNKNOWN_OPERATOR`, `UNKNOWN_NODE_TYPE`,
20
- * `INVALID_MODIFIER_COUNT`, `EXPLODE_LIMIT_EXCEEDED`, `REROLL_LIMIT_EXCEEDED`,
21
- * `INVALID_THRESHOLD`, `NESTED_VERSUS`, `UNKNOWN_FUNCTION`, `UNDEFINED_VARIABLE`,
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
- 'INVALID_MODIFIER_COUNT',
38
- 'INVALID_MODIFIER_TARGET',
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
- const VALID_CODES: Set<string> = new Set<string>(ROLL_PARSER_ERROR_CODES);
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
- * falls back to duck-typing for cross-realm safety.
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 instanceof Error &&
86
- 'code' in value &&
87
- typeof (value as RollParserError).code === 'string' &&
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,50 @@
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` are always `false`.
41
+ */
42
+ export function createFateDieResult(result: number, modifiers: DieModifier[]): DieResult {
43
+ return {
44
+ sides: 0,
45
+ result,
46
+ modifiers,
47
+ critical: false,
48
+ fumble: false,
49
+ };
50
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Per-evaluation shared environment.
3
+ *
4
+ * Lives in its own module so the modifier implementations under
5
+ * `./modifiers/*` can type their `env` parameter without importing
6
+ * `./evaluator.js`, which imports them back.
7
+ *
8
+ * @module evaluator/env
9
+ */
10
+
11
+ import { EvaluatorError } from '../errors.js';
12
+ import type { ASTNode } from '../parser/ast.js';
13
+
14
+ /**
15
+ * Per-evaluation shared environment (created once, shared across all branches).
16
+ *
17
+ * Exported for use by modifier implementations under `./modifiers/*`. Not part
18
+ * of the public library API.
19
+ */
20
+ export type EvalEnv = {
21
+ readonly maxDice: number;
22
+ readonly maxExplodeIterations: number;
23
+ readonly maxRerollIterations: number;
24
+ totalDiceRolled: number;
25
+ /**
26
+ * Set to `true` by `evalSuccessCount`. Propagates through the shared env
27
+ * so `evaluate()` can include `successes`/`failures` fields even when no
28
+ * die was tagged (impossible threshold).
29
+ */
30
+ hasSuccessCount: boolean;
31
+ /**
32
+ * `true` while the evaluator is inside a `VersusNode`'s roll or DC
33
+ * sub-evaluation. `evalVersus` rejects nesting via this flag — catches
34
+ * paren-nested versus (`1d20 vs (5 vs 3)`) that slip past the parser's
35
+ * left-chain check.
36
+ */
37
+ insideVersus: boolean;
38
+ /**
39
+ * User-supplied variable map for `@name` / `@{name}` references. Always
40
+ * defined — `evaluate()` defaults to an empty object so lookups can be
41
+ * branch-free on presence.
42
+ */
43
+ readonly context: Readonly<Record<string, number>>;
44
+ /**
45
+ * Behavior when a referenced variable is missing from `context`. Always
46
+ * defined — `evaluate()` defaults to `'throw'`.
47
+ */
48
+ readonly onMissingVariable: 'throw' | 'zero';
49
+ };
50
+
51
+ /**
52
+ * Reserves `count` dice against the global `maxDice` budget, throwing
53
+ * `DICE_LIMIT_EXCEEDED` when the reservation would overshoot.
54
+ *
55
+ * Every path that consumes RNG draws for dice charges through here — initial
56
+ * pools (`evalDice` / `evalFateDice`), explosion continuations, and reroll
57
+ * replacements — so the limit and its message have one definition.
58
+ */
59
+ export function chargeDice(env: EvalEnv, count: number, nodeType: ASTNode['type']): void {
60
+ if (env.totalDiceRolled + count > env.maxDice) {
61
+ throw new EvaluatorError(
62
+ `Total dice count ${env.totalDiceRolled + count} exceeds limit of ${env.maxDice}`,
63
+ 'DICE_LIMIT_EXCEEDED',
64
+ nodeType,
65
+ );
66
+ }
67
+ env.totalDiceRolled += count;
68
+ }
69
+
70
+ /** Single-die shorthand for {@link chargeDice}. */
71
+ export function chargeDie(env: EvalEnv, nodeType: ASTNode['type']): void {
72
+ chargeDice(env, 1, nodeType);
73
+ }