roll-parser 3.0.0-alpha.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 (163) hide show
  1. package/CHANGELOG.md +189 -0
  2. package/MIGRATION.md +147 -0
  3. package/README.md +985 -43
  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 +16 -4
  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 -14
  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 +62 -40
  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.d.ts +1 -1
  37. package/dist/evaluator/modifiers/compare.d.ts.map +1 -1
  38. package/dist/evaluator/modifiers/compare.js +15 -0
  39. package/dist/evaluator/modifiers/compare.js.map +1 -0
  40. package/dist/evaluator/modifiers/crit-threshold.d.ts +27 -0
  41. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
  42. package/dist/evaluator/modifiers/crit-threshold.js +23 -0
  43. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
  44. package/dist/evaluator/modifiers/die-bound.d.ts +26 -0
  45. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
  46. package/dist/evaluator/modifiers/die-bound.js +14 -0
  47. package/dist/evaluator/modifiers/die-bound.js.map +1 -0
  48. package/dist/evaluator/modifiers/explode.d.ts +18 -6
  49. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  50. package/dist/evaluator/modifiers/explode.js +103 -0
  51. package/dist/evaluator/modifiers/explode.js.map +1 -0
  52. package/dist/evaluator/modifiers/flags.d.ts +37 -0
  53. package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
  54. package/dist/evaluator/modifiers/flags.js +18 -0
  55. package/dist/evaluator/modifiers/flags.js.map +1 -0
  56. package/dist/evaluator/modifiers/keep-drop.d.ts +12 -28
  57. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  58. package/dist/evaluator/modifiers/keep-drop.js +82 -0
  59. package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
  60. package/dist/evaluator/modifiers/reroll.d.ts +14 -6
  61. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  62. package/dist/evaluator/modifiers/reroll.js +62 -0
  63. package/dist/evaluator/modifiers/reroll.js.map +1 -0
  64. package/dist/evaluator/modifiers/sort.d.ts +27 -0
  65. package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
  66. package/dist/evaluator/modifiers/sort.js +13 -0
  67. package/dist/evaluator/modifiers/sort.js.map +1 -0
  68. package/dist/evaluator/modifiers/success-count.d.ts +2 -6
  69. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  70. package/dist/evaluator/modifiers/success-count.js +24 -0
  71. package/dist/evaluator/modifiers/success-count.js.map +1 -0
  72. package/dist/index.d.ts +35 -13
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +12 -1723
  75. package/dist/index.js.map +1 -0
  76. package/dist/lexer/lexer.d.ts +68 -8
  77. package/dist/lexer/lexer.d.ts.map +1 -1
  78. package/dist/lexer/lexer.js +260 -0
  79. package/dist/lexer/lexer.js.map +1 -0
  80. package/dist/lexer/tokens.d.ts +52 -7
  81. package/dist/lexer/tokens.d.ts.map +1 -1
  82. package/dist/lexer/tokens.js +42 -0
  83. package/dist/lexer/tokens.js.map +1 -0
  84. package/dist/parser/ast.d.ts +419 -80
  85. package/dist/parser/ast.d.ts.map +1 -1
  86. package/dist/parser/ast.js +52 -0
  87. package/dist/parser/ast.js.map +1 -0
  88. package/dist/parser/guards.d.ts +106 -0
  89. package/dist/parser/guards.d.ts.map +1 -0
  90. package/dist/parser/guards.js +121 -0
  91. package/dist/parser/guards.js.map +1 -0
  92. package/dist/parser/parser.d.ts +162 -15
  93. package/dist/parser/parser.d.ts.map +1 -1
  94. package/dist/parser/parser.js +751 -0
  95. package/dist/parser/parser.js.map +1 -0
  96. package/dist/rng/mock.d.ts +74 -13
  97. package/dist/rng/mock.d.ts.map +1 -1
  98. package/dist/rng/mock.js +30 -0
  99. package/dist/rng/mock.js.map +1 -0
  100. package/dist/rng/seeded.d.ts +149 -10
  101. package/dist/rng/seeded.d.ts.map +1 -1
  102. package/dist/rng/seeded.js +138 -0
  103. package/dist/rng/seeded.js.map +1 -0
  104. package/dist/rng/types.d.ts +57 -0
  105. package/dist/rng/types.d.ts.map +1 -1
  106. package/dist/rng/types.js +2 -0
  107. package/dist/rng/types.js.map +1 -0
  108. package/dist/roll.d.ts +59 -25
  109. package/dist/roll.d.ts.map +1 -1
  110. package/dist/roll.js +8 -0
  111. package/dist/roll.js.map +1 -0
  112. package/dist/testing.d.ts +5 -4
  113. package/dist/testing.d.ts.map +1 -1
  114. package/dist/testing.js +2 -38
  115. package/dist/testing.js.map +1 -0
  116. package/dist/types.d.ts +427 -24
  117. package/dist/types.d.ts.map +1 -1
  118. package/dist/types.js +8 -0
  119. package/dist/types.js.map +1 -0
  120. package/dist/version.d.ts +2 -0
  121. package/dist/version.d.ts.map +1 -0
  122. package/dist/version.js +2 -0
  123. package/dist/version.js.map +1 -0
  124. package/package.json +93 -40
  125. package/src/cli/args.ts +66 -9
  126. package/src/cli/format.ts +30 -7
  127. package/src/cli/index.ts +27 -67
  128. package/src/cli/main.ts +129 -0
  129. package/src/errors.ts +488 -23
  130. package/src/evaluator/die.ts +50 -0
  131. package/src/evaluator/env.ts +73 -0
  132. package/src/evaluator/evaluator.ts +1296 -349
  133. package/src/evaluator/modifiers/compare.ts +1 -1
  134. package/src/evaluator/modifiers/crit-threshold.ts +56 -0
  135. package/src/evaluator/modifiers/die-bound.ts +39 -0
  136. package/src/evaluator/modifiers/explode.ts +82 -76
  137. package/src/evaluator/modifiers/flags.ts +61 -0
  138. package/src/evaluator/modifiers/keep-drop.ts +124 -126
  139. package/src/evaluator/modifiers/reroll.ts +36 -64
  140. package/src/evaluator/modifiers/sort.ts +43 -0
  141. package/src/evaluator/modifiers/success-count.ts +6 -9
  142. package/src/index.ts +73 -34
  143. package/src/lexer/lexer.ts +201 -35
  144. package/src/lexer/tokens.ts +72 -7
  145. package/src/parser/ast.ts +453 -104
  146. package/src/parser/guards.ts +248 -0
  147. package/src/parser/parser.ts +835 -135
  148. package/src/rng/mock.ts +75 -14
  149. package/src/rng/seeded.ts +323 -58
  150. package/src/rng/types.ts +57 -0
  151. package/src/roll.ts +66 -41
  152. package/src/testing.ts +5 -9
  153. package/src/types.ts +416 -24
  154. package/src/version.ts +2 -0
  155. package/dist/cli.js +0 -1775
  156. package/dist/evaluator/index.d.ts +0 -8
  157. package/dist/evaluator/index.d.ts.map +0 -1
  158. package/dist/index.mjs +0 -1724
  159. package/dist/rng/index.d.ts +0 -8
  160. package/dist/rng/index.d.ts.map +0 -1
  161. package/dist/testing.mjs +0 -39
  162. package/src/evaluator/index.ts +0 -14
  163. package/src/rng/index.ts +0 -8
@@ -4,39 +4,87 @@
4
4
  * @module parser/parser
5
5
  */
6
6
 
7
- import type { RollParserErrorCode } from '../errors';
8
- import { RollParserError } from '../errors';
9
- import { lex } from '../lexer/lexer';
10
- import { type Token, TokenType } from '../lexer/tokens';
11
- import type { CompareOp, ComparePoint } from '../types';
7
+ import type { RollParserErrorCode } from '../errors.js';
8
+ import { RollParserError } from '../errors.js';
9
+ import { lex } from '../lexer/lexer.js';
10
+ import { type Token, TokenType } from '../lexer/tokens.js';
11
+ import type { CompareOp, ComparePoint } from '../types.js';
12
12
  import type {
13
13
  ASTNode,
14
14
  BinaryOpNode,
15
+ CritThreshold,
16
+ CritThresholdNode,
15
17
  DiceNode,
18
+ DieBoundNode,
16
19
  ExplodeNode,
17
20
  FateDiceNode,
18
21
  FunctionCallNode,
22
+ GroupedNode,
23
+ GroupNode,
24
+ KeepDropNode,
19
25
  LiteralNode,
20
- ModifierNode,
21
26
  RerollNode,
27
+ SortNode,
22
28
  SuccessCountNode,
23
29
  UnaryOpNode,
30
+ VariableNode,
24
31
  VersusNode,
25
- } from './ast';
26
- import { containsDice, isSuccessCount } from './ast';
32
+ } from './ast.js';
33
+ import { isCritThreshold, isSuccessCount } from './ast.js';
34
+ import {
35
+ containsDicePool,
36
+ containsFatePool,
37
+ containsMultiSubGroup,
38
+ containsVersus,
39
+ deepContainsDicePool,
40
+ unwrapAllTransparent,
41
+ unwrapGrouped,
42
+ } from './guards.js';
27
43
 
28
44
  /**
29
45
  * Error thrown when the parser encounters invalid syntax.
46
+ *
47
+ * `position` is a zero-based UTF-16 offset into the notation. It is
48
+ * deliberately absent from `message` — read it from the field, or uniformly
49
+ * across all roll-parser errors via `getErrorSpan`.
50
+ *
51
+ * @example
52
+ * ```typescript
53
+ * import { ParseError, roll, TokenType } from 'roll-parser';
54
+ *
55
+ * try {
56
+ * roll('4d6d1');
57
+ * } catch (error) {
58
+ * const typed = error as ParseError;
59
+ * typed.code; // 'AMBIGUOUS_DICE_CHAIN'
60
+ * typed.position; // 3
61
+ * typed.token?.type === TokenType.DICE; // true
62
+ * }
63
+ * ```
64
+ *
65
+ * @category Errors
30
66
  */
31
67
  export class ParseError extends RollParserError {
68
+ /** Zero-based UTF-16 offset in the notation where parsing failed. */
32
69
  readonly position: number;
70
+ /**
71
+ * The token the parser was looking at. `undefined` when the failure is not
72
+ * anchored to a token — currently only `UNEXPECTED_END`, raised when the
73
+ * input stops short.
74
+ */
33
75
  readonly token: Token | undefined;
34
76
 
35
- constructor(message: string, code: RollParserErrorCode, position: number, token?: Token) {
36
- super(`${message} at position ${position}`, code);
77
+ constructor(
78
+ message: string,
79
+ code: RollParserErrorCode,
80
+ position: number,
81
+ token?: Token,
82
+ options?: ErrorOptions,
83
+ ) {
84
+ super(message, code, options);
37
85
  this.name = 'ParseError';
38
86
  this.position = position;
39
- this.token = token ?? undefined;
87
+ this.token = token;
40
88
  }
41
89
  }
42
90
 
@@ -46,6 +94,7 @@ export class ParseError extends RollParserError {
46
94
  *
47
95
  * Precedence order (lowest to highest):
48
96
  * - Versus (`vs`): 2-3 (lowest — full expressions on both sides)
97
+ * - Comparison (success-count LED): 8
49
98
  * - Addition/subtraction: 10
50
99
  * - Multiplication/division/modulo: 20
51
100
  * - Unary minus: 25 (binds to complete dice expr: -1d4 = -(1d4))
@@ -54,27 +103,72 @@ export class ParseError extends RollParserError {
54
103
  * - Dice: 40-41
55
104
  */
56
105
  const BP = {
57
- // Versus (left-associative, lowest precedence `1d20+10 vs 25+10` = `(1d20+10) vs (25+10)`)
106
+ // Lowest, so both sides take a full expression: `1d20+10 vs 25+10` =
107
+ // `(1d20+10) vs (25+10)`.
58
108
  VS_LEFT: 2,
59
109
  VS_RIGHT: 3,
60
- // Addition/subtraction (left-associative)
110
+ // Below ADD/MUL so `XdY+N>T` parses as `(XdY+N)>T` and fails the pool-target
111
+ // guard with a clear error, instead of `>` stealing `N` from the `+`.
112
+ COMPARE: 8,
61
113
  ADD_LEFT: 10,
62
114
  ADD_RIGHT: 11,
63
- // Multiplication/division/modulo (left-associative)
64
115
  MUL_LEFT: 20,
65
116
  MUL_RIGHT: 21,
66
- // Unary minus: between mul and power so -1d4 = -(1d4) not (-1)d4
117
+ // Between MUL and POW so `-1d4` = `-(1d4)`, not `(-1)d4`.
67
118
  UNARY: 25,
68
- // Power (right-associative: left > right)
69
119
  POW_LEFT: 31,
70
120
  POW_RIGHT: 30,
71
- // Postfix modifiers: must be < DICE_RIGHT so they bind to complete dice expr
121
+ // Must stay below DICE_RIGHT so a postfix modifier binds to the whole dice
122
+ // expression: `4d6kh3` = `(4d6)kh3`.
72
123
  MODIFIER: 35,
73
- // Dice operator (highest math precedence)
74
124
  DICE_LEFT: 40,
75
125
  DICE_RIGHT: 41,
76
126
  } as const;
77
127
 
128
+ /**
129
+ * Maximum expression nesting depth. Far beyond any human-authored notation —
130
+ * exists so adversarial input like 20,000 nested parens throws a typed
131
+ * `ParseError` instead of an uncaught `RangeError` stack overflow (which
132
+ * would also break the `isRollParserError` contract). Bounding parse depth
133
+ * also bounds AST depth, protecting the recursive AST walkers and evaluator.
134
+ *
135
+ * Not configurable — unlike the {@link EvaluationOptions} caps, this one always
136
+ * applies, so untrusted notation can never blow the stack.
137
+ *
138
+ * @example
139
+ * ```typescript
140
+ * import { isRollParserError, MAX_PARSE_DEPTH, parse } from 'roll-parser';
141
+ *
142
+ * MAX_PARSE_DEPTH; // 128
143
+ *
144
+ * try {
145
+ * parse('('.repeat(20_000) + '1d6' + ')'.repeat(20_000));
146
+ * } catch (error) {
147
+ * isRollParserError(error) && error.code; // 'MAX_DEPTH_EXCEEDED'
148
+ * }
149
+ * ```
150
+ *
151
+ * @category Limits
152
+ */
153
+ export const MAX_PARSE_DEPTH = 128;
154
+
155
+ /**
156
+ * Human-readable symbols for the tokens `expect()` can be asked for.
157
+ *
158
+ * `satisfies` keeps the key literals, so `ExpectableToken` narrows `expect()`
159
+ * to exactly these tokens and the lookup can never miss.
160
+ */
161
+ const TOKEN_DISPLAY = {
162
+ [TokenType.LPAREN]: `'('`,
163
+ [TokenType.RPAREN]: `')'`,
164
+ [TokenType.LBRACE]: `'{'`,
165
+ [TokenType.RBRACE]: `'}'`,
166
+ [TokenType.COMMA]: `','`,
167
+ } satisfies Partial<Record<TokenType, string>>;
168
+
169
+ /** Token types that carry a display symbol and may be passed to `expect()`. */
170
+ type ExpectableToken = keyof typeof TOKEN_DISPLAY;
171
+
78
172
  /**
79
173
  * Arity table for math functions. `min` and `max` are inclusive.
80
174
  * `POSITIVE_INFINITY` means unbounded (variadic).
@@ -84,10 +178,26 @@ const FUNCTION_ARITY: Record<string, { min: number; max: number }> = {
84
178
  ceil: { min: 1, max: 1 },
85
179
  round: { min: 1, max: 1 },
86
180
  abs: { min: 1, max: 1 },
181
+ sqrt: { min: 1, max: 1 },
182
+ pow: { min: 2, max: 2 },
87
183
  max: { min: 2, max: Number.POSITIVE_INFINITY },
88
184
  min: { min: 2, max: Number.POSITIVE_INFINITY },
89
185
  };
90
186
 
187
+ /** Renders an arity range for the `INVALID_FUNCTION_ARITY` message. */
188
+ function formatArity(arity: { min: number; max: number }): string {
189
+ if (arity.max === Number.POSITIVE_INFINITY) return `at least ${arity.min}`;
190
+ if (arity.min === arity.max) return `${arity.min}`;
191
+ return `${arity.min}–${arity.max}`;
192
+ }
193
+
194
+ /** Explode variant produced by each explode token. */
195
+ const EXPLODE_VARIANTS: Partial<Record<TokenType, ExplodeNode['variant']>> = {
196
+ [TokenType.EXPLODE]: 'standard',
197
+ [TokenType.EXPLODE_COMPOUND]: 'compound',
198
+ [TokenType.EXPLODE_PENETRATING]: 'penetrating',
199
+ };
200
+
91
201
  /**
92
202
  * Pratt parser for dice notation.
93
203
  *
@@ -97,6 +207,7 @@ const FUNCTION_ARITY: Record<string, { min: number; max: number }> = {
97
207
  export class Parser {
98
208
  private readonly tokens: Token[];
99
209
  private pos = 0;
210
+ private depth = 0;
100
211
 
101
212
  constructor(tokens: Token[]) {
102
213
  this.tokens = tokens;
@@ -112,7 +223,6 @@ export class Parser {
112
223
 
113
224
  const ast = this.parseExpression(0);
114
225
 
115
- // Ensure we consumed all tokens
116
226
  if (this.peek().type !== TokenType.EOF) {
117
227
  const token = this.peek();
118
228
  throw new ParseError(
@@ -130,19 +240,47 @@ export class Parser {
130
240
  * Parse an expression with minimum binding power.
131
241
  */
132
242
  private parseExpression(minBp: number): ASTNode {
133
- let left = this.parseNud();
243
+ const entryDepth = this.depth;
244
+ this.depth += 1;
245
+ this.guardDepth();
134
246
 
135
- while (this.hasTokens()) {
136
- const token = this.peek();
137
- const leftBp = this.getLeftBp(token);
247
+ try {
248
+ let left = this.parseNud();
138
249
 
139
- if (leftBp < minBp) break;
250
+ while (this.hasTokens()) {
251
+ const token = this.peek();
252
+ const leftBp = this.getLeftBp(token);
140
253
 
141
- this.advance();
142
- left = this.parseLed(left, token);
254
+ if (leftBp < minBp) break;
255
+
256
+ this.advance();
257
+ left = this.parseLed(left, token);
258
+ // ! Each continuation wraps `left`, so the AST is one level deeper even
259
+ // ! though this loop never recursed. Drop this and the bound stops
260
+ // ! holding for left-associative chains.
261
+ this.depth += 1;
262
+ this.guardDepth();
263
+ }
264
+
265
+ return left;
266
+ } finally {
267
+ this.depth = entryDepth;
143
268
  }
269
+ }
144
270
 
145
- return left;
271
+ /**
272
+ * Throw once the AST under construction is deeper than the recursive walkers
273
+ * can safely descend.
274
+ */
275
+ private guardDepth(): void {
276
+ if (this.depth > MAX_PARSE_DEPTH) {
277
+ throw new ParseError(
278
+ `Expression nesting exceeds the maximum depth of ${MAX_PARSE_DEPTH}`,
279
+ 'MAX_DEPTH_EXCEEDED',
280
+ this.peek().position,
281
+ this.peek(),
282
+ );
283
+ }
146
284
  }
147
285
 
148
286
  /**
@@ -157,23 +295,29 @@ export class Parser {
157
295
  return this.parseLiteral(token);
158
296
 
159
297
  case TokenType.MINUS:
160
- return this.parseUnaryMinus();
298
+ return this.parseUnaryMinus(token);
161
299
 
162
300
  case TokenType.DICE:
163
- return this.parsePrefixDice();
301
+ return this.parsePrefixDice(token);
164
302
 
165
303
  case TokenType.DICE_PERCENT:
166
- return this.parsePrefixDicePercent();
304
+ return this.parsePrefixDicePercent(token);
167
305
 
168
306
  case TokenType.DICE_FATE:
169
- return this.parsePrefixFateDice();
307
+ return this.parsePrefixFateDice(token);
170
308
 
171
309
  case TokenType.LPAREN:
172
- return this.parseGrouped();
310
+ return this.parseGrouped(token);
311
+
312
+ case TokenType.LBRACE:
313
+ return this.parseGroup(token);
173
314
 
174
315
  case TokenType.FUNCTION:
175
316
  return this.parseFunctionCall(token);
176
317
 
318
+ case TokenType.AT:
319
+ return this.parseVariable(token);
320
+
177
321
  case TokenType.EOF:
178
322
  throw new ParseError('Unexpected end of input', 'UNEXPECTED_END', token.position);
179
323
 
@@ -194,13 +338,13 @@ export class Parser {
194
338
  private parseLed(left: ASTNode, token: Token): ASTNode {
195
339
  switch (token.type) {
196
340
  case TokenType.DICE:
197
- return this.parseInfixDice(left);
341
+ return this.parseInfixDice(left, token);
198
342
 
199
343
  case TokenType.DICE_PERCENT:
200
- return this.parseInfixDicePercent(left);
344
+ return this.parseInfixDicePercent(left, token);
201
345
 
202
346
  case TokenType.DICE_FATE:
203
- return this.parseInfixFateDice(left);
347
+ return this.parseInfixFateDice(left, token);
204
348
 
205
349
  case TokenType.PLUS:
206
350
  case TokenType.MINUS:
@@ -214,7 +358,7 @@ export class Parser {
214
358
  case TokenType.KEEP_LOW:
215
359
  case TokenType.DROP_HIGH:
216
360
  case TokenType.DROP_LOW:
217
- return this.parseModifier(left, token);
361
+ return this.parseKeepDrop(left, token);
218
362
 
219
363
  case TokenType.EXPLODE:
220
364
  case TokenType.EXPLODE_COMPOUND:
@@ -225,6 +369,19 @@ export class Parser {
225
369
  case TokenType.REROLL_ONCE:
226
370
  return this.parseReroll(left, token);
227
371
 
372
+ case TokenType.SORT_ASC:
373
+ case TokenType.SORT_DESC:
374
+ return this.parseSort(left, token);
375
+
376
+ case TokenType.CRIT_SUCCESS:
377
+ case TokenType.CRIT_FAIL:
378
+ return this.parseCritThreshold(left, token);
379
+
380
+ case TokenType.FUNCTION:
381
+ // Only `min`/`max` reach LED position — `getLeftBp` keeps every
382
+ // other function name at -1, ending the Pratt loop before dispatch.
383
+ return this.parseDieBound(left, token);
384
+
228
385
  case TokenType.GREATER:
229
386
  case TokenType.GREATER_EQUAL:
230
387
  case TokenType.LESS:
@@ -245,108 +402,176 @@ export class Parser {
245
402
  }
246
403
  }
247
404
 
405
+ //
248
406
  // * Node parsers
407
+ //
408
+
409
+ /**
410
+ * Zero-width span for synthetic nodes (implicit counts, `d%` sides) that
411
+ * have no source text of their own — anchored at the governing token.
412
+ */
413
+ private static syntheticLiteral(value: number, token: Token): LiteralNode {
414
+ return { type: 'Literal', value, start: token.position, end: token.position };
415
+ }
249
416
 
250
417
  private parseLiteral(token: Token): LiteralNode {
251
418
  return {
252
419
  type: 'Literal',
253
420
  value: Number.parseFloat(token.value),
421
+ start: token.position,
422
+ end: token.end,
254
423
  };
255
424
  }
256
425
 
257
- private parseUnaryMinus(): UnaryOpNode {
426
+ private parseUnaryMinus(token: Token): UnaryOpNode {
258
427
  const operand = this.parseExpression(BP.UNARY);
428
+ this.rejectSuccessCountTarget(operand, token);
259
429
  return {
260
430
  type: 'UnaryOp',
261
431
  operator: '-',
262
432
  operand,
433
+ start: token.position,
434
+ end: operand.end ?? token.end,
263
435
  };
264
436
  }
265
437
 
266
- private parsePrefixDice(): DiceNode {
267
- // d20 → Dice(1, 20)
438
+ private parsePrefixDice(token: Token): DiceNode {
268
439
  const sides = this.parseExpression(BP.DICE_RIGHT);
440
+ this.rejectSuccessCountTarget(sides, token);
441
+ this.rejectVersusMetaOperand(sides, token);
269
442
  return {
270
443
  type: 'Dice',
271
- count: { type: 'Literal', value: 1 },
444
+ count: Parser.syntheticLiteral(1, token),
272
445
  sides,
446
+ start: token.position,
447
+ end: sides.end ?? token.end,
273
448
  };
274
449
  }
275
450
 
276
- private parseInfixDice(left: ASTNode): DiceNode {
277
- // 4d6 → Dice(4, 6)
451
+ private parseInfixDice(left: ASTNode, token: Token): DiceNode {
452
+ this.rejectSuccessCountTarget(left, token);
453
+ this.rejectVersusMetaOperand(left, token);
454
+ this.rejectBareDiceChain(left, token);
278
455
  const sides = this.parseExpression(BP.DICE_RIGHT);
456
+ this.rejectSuccessCountTarget(sides, token);
457
+ this.rejectVersusMetaOperand(sides, token);
279
458
  return {
280
459
  type: 'Dice',
281
460
  count: left,
282
461
  sides,
462
+ start: left.start ?? token.position,
463
+ end: sides.end ?? token.end,
283
464
  };
284
465
  }
285
466
 
286
- private parsePrefixDicePercent(): DiceNode {
287
- // d% → Dice(1, 100)
467
+ private parsePrefixDicePercent(token: Token): DiceNode {
288
468
  return {
289
469
  type: 'Dice',
290
- count: { type: 'Literal', value: 1 },
291
- sides: { type: 'Literal', value: 100 },
470
+ count: Parser.syntheticLiteral(1, token),
471
+ sides: Parser.syntheticLiteral(100, token),
472
+ start: token.position,
473
+ end: token.end,
292
474
  };
293
475
  }
294
476
 
295
- private parseInfixDicePercent(left: ASTNode): DiceNode {
296
- // 2d% → Dice(2, 100)
477
+ private parseInfixDicePercent(left: ASTNode, token: Token): DiceNode {
478
+ this.rejectSuccessCountTarget(left, token);
479
+ this.rejectVersusMetaOperand(left, token);
480
+ this.rejectBareDiceChain(left, token);
297
481
  return {
298
482
  type: 'Dice',
299
483
  count: left,
300
- sides: { type: 'Literal', value: 100 },
484
+ sides: Parser.syntheticLiteral(100, token),
485
+ start: left.start ?? token.position,
486
+ end: token.end,
301
487
  };
302
488
  }
303
489
 
304
- private parsePrefixFateDice(): FateDiceNode {
305
- // dF → FateDice(1)
490
+ private parsePrefixFateDice(token: Token): FateDiceNode {
306
491
  return {
307
492
  type: 'FateDice',
308
- count: { type: 'Literal', value: 1 },
493
+ count: Parser.syntheticLiteral(1, token),
494
+ start: token.position,
495
+ end: token.end,
309
496
  };
310
497
  }
311
498
 
312
- private parseInfixFateDice(left: ASTNode): FateDiceNode {
313
- // 4dF FateDice(4). Unlike parseInfixDice, there is no sides sub-parse,
314
- // so modifiers (`kh`, `dl`, …) naturally bind at the outer Pratt loop
315
- // without BP competition against a right-operand.
499
+ private parseInfixFateDice(left: ASTNode, token: Token): FateDiceNode {
500
+ // No sides sub-parse, unlike `parseInfixDice`, so modifiers (`kh`, `dl`, …)
501
+ // bind at the outer Pratt loop with no BP competition from a right operand.
502
+ this.rejectSuccessCountTarget(left, token);
503
+ this.rejectVersusMetaOperand(left, token);
504
+ this.rejectBareDiceChain(left, token);
316
505
  return {
317
506
  type: 'FateDice',
318
507
  count: left,
508
+ start: left.start ?? token.position,
509
+ end: token.end,
319
510
  };
320
511
  }
321
512
 
322
- private parseGrouped(): ASTNode {
323
- const expr = this.parseExpression(0);
324
- this.expect(TokenType.RPAREN);
325
- return expr;
513
+ private parseGrouped(token: Token): GroupedNode {
514
+ const expression = this.parseExpression(0);
515
+ const close = this.expect(TokenType.RPAREN);
516
+ return { type: 'Grouped', expression, start: token.position, end: close.end };
517
+ }
518
+
519
+ private parseGroup(startToken: Token): GroupNode {
520
+ // `LBRACE`/`RBRACE`/`COMMA` all have `getLeftBp === -1`, so inner
521
+ // `parseExpression(0)` calls stop at the first `,` or `}` without competing
522
+ // with modifier/arithmetic BPs.
523
+ if (this.peek().type === TokenType.RBRACE) {
524
+ throw new ParseError('Empty group', 'UNEXPECTED_TOKEN', startToken.position, startToken);
525
+ }
526
+
527
+ const expressions: ASTNode[] = [this.parseExpression(0)];
528
+ while (this.peek().type === TokenType.COMMA) {
529
+ this.advance();
530
+ expressions.push(this.parseExpression(0));
531
+ }
532
+
533
+ if (this.peek().type !== TokenType.RBRACE) {
534
+ const unterminated = this.peek();
535
+ throw new ParseError(
536
+ `Unterminated group: expected '}' or ','`,
537
+ 'EXPECTED_TOKEN',
538
+ unterminated.position,
539
+ unterminated,
540
+ );
541
+ }
542
+ const close = this.advance();
543
+
544
+ return { type: 'Group', expressions, start: startToken.position, end: close.end };
545
+ }
546
+
547
+ private parseVariable(token: Token): VariableNode {
548
+ return { type: 'Variable', name: token.value, start: token.position, end: token.end };
326
549
  }
327
550
 
328
551
  private parseFunctionCall(token: Token): FunctionCallNode {
329
- // `FUNCTION` has BP = -1 so callers stop here; `COMMA` and `RPAREN` also
330
- // terminate inner `parseExpression(0)` calls, so argument boundaries are
331
- // natural.
552
+ // `FUNCTION`, `COMMA`, and `RPAREN` all sit at BP -1, so argument boundaries
553
+ // fall out of the inner `parseExpression(0)` calls terminating on their own.
332
554
  this.expect(TokenType.LPAREN);
333
555
 
334
556
  const args: ASTNode[] = [];
335
557
  if (this.peek().type !== TokenType.RPAREN) {
336
- args.push(this.parseExpression(0));
558
+ const first = this.parseExpression(0);
559
+ this.rejectSuccessCountTarget(first, token);
560
+ args.push(first);
337
561
  while (this.peek().type === TokenType.COMMA) {
338
562
  this.advance();
339
- args.push(this.parseExpression(0));
563
+ const next = this.parseExpression(0);
564
+ this.rejectSuccessCountTarget(next, token);
565
+ args.push(next);
340
566
  }
341
567
  }
342
568
 
343
- this.expect(TokenType.RPAREN);
569
+ const close = this.expect(TokenType.RPAREN);
344
570
 
345
571
  const arity = FUNCTION_ARITY[token.value];
346
- if (arity === undefined) {
347
- // ? Unreachable in practice: lexer only emits FUNCTION for registered
348
- // names. Kept defensive to keep parser/evaluator error-code contract
349
- // symmetrical.
572
+ if (arity == null) {
573
+ // Unreachable: the lexer only emits `FUNCTION` for registered names. Kept
574
+ // so the parser/evaluator error-code contract stays symmetrical.
350
575
  throw new ParseError(
351
576
  `Unknown function '${token.value}'`,
352
577
  'UNKNOWN_FUNCTION',
@@ -356,12 +581,7 @@ export class Parser {
356
581
  }
357
582
 
358
583
  if (args.length < arity.min || args.length > arity.max) {
359
- const expected =
360
- arity.max === Number.POSITIVE_INFINITY
361
- ? `at least ${arity.min}`
362
- : arity.min === arity.max
363
- ? `${arity.min}`
364
- : `${arity.min}–${arity.max}`;
584
+ const expected = formatArity(arity);
365
585
  throw new ParseError(
366
586
  `Function '${token.value}' expects ${expected} argument${arity.min === 1 && arity.max === 1 ? '' : 's'}, got ${args.length}`,
367
587
  'INVALID_FUNCTION_ARITY',
@@ -370,24 +590,69 @@ export class Parser {
370
590
  );
371
591
  }
372
592
 
373
- return { type: 'FunctionCall', name: token.value, args };
593
+ return {
594
+ type: 'FunctionCall',
595
+ name: token.value,
596
+ args,
597
+ start: token.position,
598
+ end: close.end,
599
+ };
374
600
  }
375
601
 
376
602
  private parseBinaryOp(left: ASTNode, token: Token): BinaryOpNode {
603
+ this.rejectSuccessCountTarget(left, token);
604
+
377
605
  const operator = this.getOperatorSymbol(token);
378
606
  const rightBp = this.getRightBp(token);
379
607
  const right = this.parseExpression(rightBp);
380
608
 
609
+ this.rejectSuccessCountTarget(right, token);
610
+
381
611
  return {
382
612
  type: 'BinaryOp',
383
613
  operator,
384
614
  left,
385
615
  right,
616
+ start: left.start ?? token.position,
617
+ end: right.end ?? token.end,
386
618
  };
387
619
  }
388
620
 
621
+ /**
622
+ * Rejects a dice token whose count operand is itself a bare (unparenthesized)
623
+ * dice expression. `4d6d1` would otherwise silently parse as `(4d6)d1` —
624
+ * roll 4d6, then use the result as a count of d1 dice — which is almost
625
+ * never intended: every major dice dialect reads `4d6d1` as "drop lowest 1".
626
+ * Both meanings stay reachable through explicit forms: `4d6dl1` to drop,
627
+ * `(4d6)d1` for nested dice.
628
+ */
629
+ private rejectBareDiceChain(left: ASTNode, token: Token): void {
630
+ switch (left.type) {
631
+ case 'Dice':
632
+ case 'FateDice':
633
+ case 'Explode':
634
+ case 'Reroll':
635
+ case 'DieBound':
636
+ case 'KeepDrop':
637
+ case 'Sort':
638
+ case 'CritThreshold':
639
+ throw new ParseError(
640
+ `Ambiguous dice chain: use 'dl'/'dh' to drop dice (4d6dl1) or parentheses for nested dice ((4d6)d1)`,
641
+ 'AMBIGUOUS_DICE_CHAIN',
642
+ token.position,
643
+ token,
644
+ );
645
+ default:
646
+ return;
647
+ }
648
+ }
649
+
389
650
  private rejectSuccessCountTarget(target: ASTNode, token: Token): void {
390
- if (isSuccessCount(target)) {
651
+ // Narrow unwrap (only `Grouped`): `KeepDrop`/`Sort`/`CritThreshold` each run
652
+ // this same reject before building their wrapper, so a `SuccessCount` can
653
+ // never hide inside one and widening the set would never match.
654
+ const node = unwrapGrouped(target);
655
+ if (isSuccessCount(node)) {
391
656
  throw new ParseError(
392
657
  `Cannot apply modifier after success counting`,
393
658
  'INVALID_SUCCESS_COUNT_TARGET',
@@ -397,10 +662,112 @@ export class Parser {
397
662
  }
398
663
  }
399
664
 
400
- private parseModifier(target: ASTNode, token: Token): ModifierNode {
665
+ /**
666
+ * Rejects `GroupNode` (or a wrapper-cloaked group) as the target of `token`.
667
+ * Explode, reroll, and crit-threshold wrap bare dice pools only — a group
668
+ * is a container of sub-expressions, so these modifiers have no defined
669
+ * semantics. Walks `Grouped`/`KeepDrop`/`Sort`/`CritThreshold` so wrappers
670
+ * cannot smuggle a group past the check (`{1d6}kh1cs>5`, `({1d6})!`,
671
+ * `{1d6}scs>5` all reject the same as `{1d6}!`/`{1d6}cs>5`).
672
+ *
673
+ * `singleSubRollPasses` opts the caller into the single-sub-roll passthrough:
674
+ * a `Group` with one expression is the user's explicit flat-pool escape
675
+ * hatch and is equivalent to its unwrapped form. Currently only
676
+ * `parseCritThreshold` opts in — explode/reroll keep the strict reject so
677
+ * existing notation contracts don't shift.
678
+ */
679
+ private rejectGroupTarget(
680
+ target: ASTNode,
681
+ token: Token,
682
+ action: string,
683
+ code: RollParserErrorCode,
684
+ singleSubRollPasses = false,
685
+ ): void {
686
+ const node = unwrapAllTransparent(target);
687
+ if (node.type !== 'Group') return;
688
+ if (singleSubRollPasses && node.expressions.length === 1) {
689
+ // ! Deep-walk the inner sub-expression — `unwrapAllTransparent` only peels
690
+ // ! `Grouped`/`KeepDrop`/`Sort`/`CritThreshold`, so a multi-sub Group
691
+ // ! buried under arithmetic (`{{1d6,2d8}+0}cs>5`), a function call
692
+ // ! (`{abs({1d6,2d8})}cs>5`), or a unary op would reach the evaluator and
693
+ // ! override `critical`/`fumble` on dice from dropped sub-rolls.
694
+ const inner = node.expressions[0];
695
+ if (inner != null && containsMultiSubGroup(inner)) {
696
+ throw new ParseError(`Cannot ${action} a group`, code, token.position, token);
697
+ }
698
+ return;
699
+ }
700
+ throw new ParseError(`Cannot ${action} a group`, code, token.position, token);
701
+ }
702
+
703
+ /**
704
+ * Rejects a Versus anywhere inside a meta operand — a dice count, dice sides,
705
+ * a modifier count, or a threshold/bound value.
706
+ *
707
+ * Deep, unlike {@link rejectVersusTarget}: `evalMetaOperand` reduces the
708
+ * operand to a scalar and forwards rolls but not `versusMetadata`, so
709
+ * `floor(1d20 vs 15)` or `(1d20 vs 15)+0` buried in a count loses the degree
710
+ * exactly as a bare one does.
711
+ */
712
+ private rejectVersusMetaOperand(operand: ASTNode, token: Token): void {
713
+ if (containsVersus(operand)) Parser.throwVersusMetaExpression(token);
714
+ }
715
+
716
+ /**
717
+ * The wording the meta-expression rejections share. Not every `NESTED_VERSUS`
718
+ * throw — `parseVersus` says "Cannot chain versus operators" for `a vs b vs c`,
719
+ * a different condition. Routing that one through here would silently rewrite
720
+ * its message, and `expectRollError` asserts only the code.
721
+ */
722
+ private static throwVersusMetaExpression(token: Token): never {
723
+ throw new ParseError(
724
+ `Versus cannot be used as a meta-expression`,
725
+ 'NESTED_VERSUS',
726
+ token.position,
727
+ token,
728
+ );
729
+ }
730
+
731
+ private rejectVersusTarget(target: ASTNode, token: Token): void {
732
+ // A Versus degree is a terminal scalar, never a valid modifier target —
733
+ // every one of these modifiers drops `versusMetadata` on the way through.
734
+ // Shallow on purpose: a multi-sub group like `{1d20 vs 15, 1d6}kh1` is
735
+ // legal, and `evalGroupKeepDrop` propagates the degree from kept sub-rolls.
736
+ // Narrow unwrap (only `Grouped`): `containsDicePool` does not recurse into
737
+ // `Versus`, so `KeepDrop`/`Sort`/`CritThreshold` reject the wrap upstream.
738
+ const node = unwrapGrouped(target);
739
+ if (node.type === 'Versus') Parser.throwVersusMetaExpression(token);
740
+ // ! `containsDicePool` recurses into a single-sub-roll Group via
741
+ // ! `deepContainsDicePool`, which traverses Versus's `roll`/`dc` — so that
742
+ // ! route gets a Versus past the shallow guards. Deep-walk for any descendant
743
+ // ! Versus, or `{1d20 vs 15}cs>18`, `{1+(1d20 vs 15)}cs>18`, and
744
+ // ! `4d6>={abs(1d20 vs 15)}` would parse while `(1d20 vs 15)cs>18` rejects.
745
+ if (node.type === 'Group' && node.expressions.length === 1) {
746
+ const inner = node.expressions[0];
747
+ if (inner != null && containsVersus(inner)) Parser.throwVersusMetaExpression(token);
748
+ }
749
+ }
750
+
751
+ private parseKeepDrop(target: ASTNode, token: Token): KeepDropNode {
401
752
  this.rejectSuccessCountTarget(target, token);
753
+ // ! Keep/drop on a Versus target silently drops `degree`/`natural` metadata,
754
+ // ! and `{1d20 vs 15}kh1` gets past `containsDicePool` — whose Group deep
755
+ // ! walk recurses into Versus's `roll`/`dc` — so this reject is the only
756
+ // ! thing closing that hole. Mirrors `parseSort`/`parseCritThreshold`.
757
+ this.rejectVersusTarget(target, token);
758
+
759
+ // Keep/drop needs a pool to select from; `(1d6+5)kh1` or `4d6+2kh3` would
760
+ // silently drop the user's arithmetic.
761
+ if (!containsDicePool(target)) {
762
+ throw new ParseError(
763
+ `Keep/drop modifiers require a dice pool target`,
764
+ 'INVALID_KEEP_DROP_TARGET',
765
+ token.position,
766
+ token,
767
+ );
768
+ }
402
769
 
403
- const modifier =
770
+ const kind =
404
771
  token.type === TokenType.KEEP_HIGH || token.type === TokenType.KEEP_LOW ? 'keep' : 'drop';
405
772
 
406
773
  const selector =
@@ -408,27 +775,58 @@ export class Parser {
408
775
  ? 'highest'
409
776
  : 'lowest';
410
777
 
411
- // Default to 1 when no explicit count follows the modifier (e.g., 4d6kh → 4d6kh1)
412
778
  const nextToken = this.peek().type;
413
- const count: ASTNode =
414
- nextToken === TokenType.NUMBER || nextToken === TokenType.LPAREN
415
- ? this.parseExpression(BP.DICE_LEFT)
416
- : { type: 'Literal', value: 1 };
779
+ const hasExplicitCount =
780
+ nextToken === TokenType.NUMBER ||
781
+ nextToken === TokenType.LPAREN ||
782
+ nextToken === TokenType.AT;
783
+ const count: ASTNode = hasExplicitCount
784
+ ? this.parseExpression(BP.DICE_LEFT)
785
+ : Parser.syntheticLiteral(1, token);
786
+ this.rejectSuccessCountTarget(count, token);
787
+ this.rejectVersusMetaOperand(count, token);
417
788
 
418
789
  return {
419
- type: 'Modifier',
420
- modifier,
790
+ type: 'KeepDrop',
791
+ kind,
421
792
  selector,
422
793
  count,
423
794
  target,
795
+ start: target.start ?? token.position,
796
+ end: hasExplicitCount ? (count.end ?? token.end) : token.end,
424
797
  };
425
798
  }
426
799
 
427
800
  private parseExplode(target: ASTNode, token: Token): ExplodeNode {
428
801
  this.rejectSuccessCountTarget(target, token);
429
802
 
430
- // ? Reject nested explodes (e.g., `1d6!!!`) a second explode token atop
431
- // an ExplodeNode has no meaningful semantics and is rejected per spec.
803
+ // Groups have no explode semantics. Must precede `containsDicePool`, which
804
+ // recurses into `Group` and would let `{4d6}!` slip through.
805
+ this.rejectGroupTarget(target, token, 'explode', 'INVALID_EXPLODE_TARGET');
806
+
807
+ // Arithmetic wrappers like `(1d6+5)!` or `floor(1d6/2)!` would silently drop
808
+ // the user's math.
809
+ if (!containsDicePool(target)) {
810
+ throw new ParseError(
811
+ `Explode modifier requires a dice pool target`,
812
+ 'INVALID_EXPLODE_TARGET',
813
+ token.position,
814
+ token,
815
+ );
816
+ }
817
+
818
+ // Fate's symmetric -1/0/+1 range has no max face to trigger on, so exploding
819
+ // it has no defined semantics — reject here rather than no-op in the evaluator.
820
+ if (containsFatePool(target)) {
821
+ throw new ParseError(
822
+ `Fate dice cannot explode`,
823
+ 'INVALID_EXPLODE_TARGET',
824
+ token.position,
825
+ token,
826
+ );
827
+ }
828
+
829
+ // A second explode token atop an `Explode` (`1d6!!!`) has no semantics.
432
830
  if (target.type === 'Explode') {
433
831
  throw new ParseError(
434
832
  `Cannot chain explode modifiers`,
@@ -438,24 +836,45 @@ export class Parser {
438
836
  );
439
837
  }
440
838
 
441
- const variant: ExplodeNode['variant'] =
442
- token.type === TokenType.EXPLODE
443
- ? 'standard'
444
- : token.type === TokenType.EXPLODE_COMPOUND
445
- ? 'compound'
446
- : 'penetrating';
839
+ // `parseLed` reaches here only for the three explode tokens, so the lookup
840
+ // always hits; the fallback exists to keep the type non-optional.
841
+ const variant: ExplodeNode['variant'] = EXPLODE_VARIANTS[token.type] ?? 'penetrating';
447
842
 
448
- const node: ExplodeNode = { type: 'Explode', variant, target };
449
- if (this.isComparePointAhead()) {
450
- node.threshold = this.parseComparePoint();
843
+ const start = target.start ?? token.position;
844
+ if (!this.isComparePointAhead()) {
845
+ return { type: 'Explode', variant, target, start, end: token.end };
451
846
  }
452
- return node;
847
+
848
+ const threshold = this.parseComparePoint();
849
+
850
+ return {
851
+ type: 'Explode',
852
+ variant,
853
+ target,
854
+ threshold,
855
+ start,
856
+ end: threshold.value.end ?? token.end,
857
+ };
453
858
  }
454
859
 
455
860
  private parseReroll(target: ASTNode, token: Token): RerollNode {
456
861
  this.rejectSuccessCountTarget(target, token);
457
862
 
458
- // A reroll token must be followed by a comparison — bare `r` / `ro` is invalid.
863
+ // Groups have no reroll semantics. Must precede `containsDicePool`, which
864
+ // recurses into `Group`.
865
+ this.rejectGroupTarget(target, token, 'reroll', 'INVALID_REROLL_TARGET');
866
+
867
+ // Arithmetic wrappers like `(1d6+5)r<3` or `floor(1d6/2)ro<3` would silently
868
+ // drop the user's math.
869
+ if (!containsDicePool(target)) {
870
+ throw new ParseError(
871
+ `Reroll modifier requires a dice pool target`,
872
+ 'INVALID_REROLL_TARGET',
873
+ token.position,
874
+ token,
875
+ );
876
+ }
877
+
459
878
  if (!this.isComparePointAhead()) {
460
879
  throw new ParseError(
461
880
  `Expected comparison operator after '${token.value}'`,
@@ -468,18 +887,209 @@ export class Parser {
468
887
  const once = token.type === TokenType.REROLL_ONCE;
469
888
  const condition = this.parseComparePoint();
470
889
 
471
- return { type: 'Reroll', once, condition, target };
890
+ return {
891
+ type: 'Reroll',
892
+ once,
893
+ condition,
894
+ target,
895
+ start: target.start ?? token.position,
896
+ end: condition.value.end ?? token.end,
897
+ };
898
+ }
899
+
900
+ private parseDieBound(target: ASTNode, token: Token): DieBoundNode {
901
+ this.rejectSuccessCountTarget(target, token);
902
+ this.rejectVersusTarget(target, token);
903
+
904
+ // Groups have no per-die clamp semantics — a group's "dice" are sub-roll
905
+ // subtotals. Must come before `containsDicePool`, which recurses into
906
+ // `Group`.
907
+ this.rejectGroupTarget(target, token, `apply '${token.value}' to`, 'INVALID_DIE_BOUND_TARGET');
908
+
909
+ // A clamp needs a dice pool to act on. Wrapping arithmetic (e.g.
910
+ // `(1d6+5)min3`) would silently drop user math.
911
+ if (!containsDicePool(target)) {
912
+ throw new ParseError(
913
+ `Die bound modifier requires a dice pool target`,
914
+ 'INVALID_DIE_BOUND_TARGET',
915
+ token.position,
916
+ token,
917
+ );
918
+ }
919
+
920
+ // Fate dice roll a fixed {-1, 0, +1}; clamping those faces has no
921
+ // established semantics. Mirrors the Fate-explosion rejection.
922
+ if (containsFatePool(target)) {
923
+ throw new ParseError(
924
+ `Fate dice cannot be clamped`,
925
+ 'INVALID_DIE_BOUND_TARGET',
926
+ token.position,
927
+ token,
928
+ );
929
+ }
930
+
931
+ // A bound must be explicit — bare `4d6min` has nothing to clamp to.
932
+ const nextToken = this.peek().type;
933
+ const hasExplicitValue =
934
+ nextToken === TokenType.NUMBER ||
935
+ nextToken === TokenType.LPAREN ||
936
+ nextToken === TokenType.AT;
937
+ if (!hasExplicitValue) {
938
+ throw new ParseError(
939
+ `Expected value after '${token.value}'`,
940
+ 'EXPECTED_TOKEN',
941
+ token.position,
942
+ token,
943
+ );
944
+ }
945
+
946
+ const value = this.parseExpression(BP.DICE_LEFT);
947
+ this.rejectSuccessCountTarget(value, token);
948
+ this.rejectVersusMetaOperand(value, token);
949
+
950
+ return {
951
+ type: 'DieBound',
952
+ bound: token.value === 'min' ? 'min' : 'max',
953
+ value,
954
+ target,
955
+ start: target.start ?? token.position,
956
+ end: value.end ?? token.end,
957
+ };
958
+ }
959
+
960
+ private parseSort(target: ASTNode, token: Token): SortNode {
961
+ this.rejectSuccessCountTarget(target, token);
962
+ this.rejectVersusTarget(target, token);
963
+
964
+ // Deep guard, unlike explode/reroll: arithmetic-wrapped pools like
965
+ // `(1d6+2d8)s` are sortable, while `5s`, `(1+2)s`, and `floor(5)s` have no
966
+ // dice to reorder.
967
+ if (!deepContainsDicePool(target)) {
968
+ throw new ParseError(
969
+ `Sort modifier requires a dice pool target`,
970
+ 'INVALID_SORT_TARGET',
971
+ token.position,
972
+ token,
973
+ );
974
+ }
975
+
976
+ // ! Multi-sub-roll groups (`{a, b}s`, `({a, b})s`) require hierarchical sort —
977
+ // ! dice within each sub-roll, then sub-rolls by total — but `evalSort` only
978
+ // ! flat-sorts, so accepting the syntax would silently ship wrong output.
979
+ // ! Single-sub Groups still pass; that is the flat-pool escape hatch.
980
+ // TODO: Implement hierarchical group sort, then drop this reject.
981
+ const base = unwrapAllTransparent(target);
982
+ if (base.type === 'Group' && base.expressions.length >= 2) {
983
+ throw new ParseError(
984
+ `Sort modifier does not yet support multi-sub-roll groups`,
985
+ 'INVALID_SORT_TARGET',
986
+ token.position,
987
+ token,
988
+ );
989
+ }
990
+
991
+ const order: SortNode['order'] = token.type === TokenType.SORT_ASC ? 'ascending' : 'descending';
992
+
993
+ // Chained sorts (`4d6ss`, `4d6sasd`) are deliberately allowed: repeats are
994
+ // idempotent and a later `sd` just overrides the order.
995
+ return {
996
+ type: 'Sort',
997
+ order,
998
+ target,
999
+ start: target.start ?? token.position,
1000
+ end: token.end,
1001
+ };
1002
+ }
1003
+
1004
+ private parseCritThreshold(target: ASTNode, token: Token): CritThresholdNode {
1005
+ this.rejectSuccessCountTarget(target, token);
1006
+ this.rejectVersusTarget(target, token);
1007
+
1008
+ // A multi-sub-roll group is a container of sub-roll subtotals, not a pool:
1009
+ // `cs`/`cf` there would override `critical`/`fumble` on dropped sub-roll
1010
+ // dice. Single-sub-roll groups pass through as the flat-pool form
1011
+ // (`{1d20}kh1cs>18` ≡ `(1d20)kh1cs>18`). Must run before `containsDicePool`,
1012
+ // which recurses into `Group`.
1013
+ this.rejectGroupTarget(
1014
+ target,
1015
+ token,
1016
+ 'apply crit threshold to',
1017
+ 'INVALID_CRIT_THRESHOLD_TARGET',
1018
+ true,
1019
+ );
1020
+
1021
+ // Shallow check like explode/reroll: `(1d6+2d8)cs>5`, `5cs`, `(1+2)cs`, and
1022
+ // `floor(5)cs` reject. Chained `cs`/`cf` still pass, since `containsDicePool`
1023
+ // recurses into a `CritThreshold`'s own target.
1024
+ if (!containsDicePool(target)) {
1025
+ throw new ParseError(
1026
+ `Crit threshold modifier requires a dice pool target`,
1027
+ 'INVALID_CRIT_THRESHOLD_TARGET',
1028
+ token.position,
1029
+ token,
1030
+ );
1031
+ }
1032
+
1033
+ // The bare `cs`/`cf` per-die default assumes max-side/1 semantics, which on
1034
+ // Fate's `{-1, 0, +1}` would flag the best face (`+1`) as a fumble. Explicit
1035
+ // ComparePoints stay accepted. Mirrors the Fate-explosion reject above.
1036
+ if (!this.isComparePointAhead() && containsFatePool(target)) {
1037
+ throw new ParseError(
1038
+ `Bare cs/cf cannot apply to Fate dice`,
1039
+ 'INVALID_CRIT_THRESHOLD_TARGET',
1040
+ token.position,
1041
+ token,
1042
+ );
1043
+ }
1044
+
1045
+ const threshold: CritThreshold = this.isComparePointAhead()
1046
+ ? this.parseComparePoint()
1047
+ : 'default';
1048
+ const end = threshold === 'default' ? token.end : (threshold.value.end ?? token.end);
1049
+
1050
+ // Unwrap parens so `(1d20cs>19)cs=1` collapses into the inner node instead of
1051
+ // nesting a second `CritThreshold` around the `Grouped` — every threshold in
1052
+ // a chain must collect on one node, which parenthesizing would break.
1053
+ let chainTarget: ASTNode = target;
1054
+ while (chainTarget.type === 'Grouped') {
1055
+ chainTarget = chainTarget.expression;
1056
+ }
1057
+ if (isCritThreshold(chainTarget)) {
1058
+ // Rebuilt, not mutated: `NodeSpan` is readonly, and the discarded `Grouped`
1059
+ // wrapper must not leave a stale `end`.
1060
+ const isSuccess = token.type === TokenType.CRIT_SUCCESS;
1061
+ return {
1062
+ ...chainTarget,
1063
+ successThresholds: isSuccess
1064
+ ? [...chainTarget.successThresholds, threshold]
1065
+ : chainTarget.successThresholds,
1066
+ failThresholds: isSuccess
1067
+ ? chainTarget.failThresholds
1068
+ : [...chainTarget.failThresholds, threshold],
1069
+ end,
1070
+ };
1071
+ }
1072
+
1073
+ return {
1074
+ type: 'CritThreshold',
1075
+ successThresholds: token.type === TokenType.CRIT_SUCCESS ? [threshold] : [],
1076
+ failThresholds: token.type === TokenType.CRIT_FAIL ? [threshold] : [],
1077
+ target,
1078
+ start: target.start ?? token.position,
1079
+ end,
1080
+ };
472
1081
  }
473
1082
 
474
1083
  private parseSuccessCount(target: ASTNode, token: Token): SuccessCountNode {
475
1084
  // Success counting is terminal: chaining (`>=5>=3`) has no semantics.
476
1085
  this.rejectSuccessCountTarget(target, token);
477
1086
 
478
- // Reject non-dice targets like `1>=3` or `(1+2)>=3`. Success counting
479
- // operates on a dice pool; a sum has no pool to count.
480
- if (!containsDice(target)) {
1087
+ // Success counting reads a raw pool, so arithmetic or composition wrappers
1088
+ // (`1>=3`, `(1+2)>=3`, `(1d6*2)>=10`, `(1d20 vs 15)>=1`) would be silently
1089
+ // ignored.
1090
+ if (!containsDicePool(target)) {
481
1091
  throw new ParseError(
482
- `Success counting requires a dice expression`,
1092
+ `Success counting requires a dice pool target`,
483
1093
  'INVALID_SUCCESS_COUNT_TARGET',
484
1094
  token.position,
485
1095
  token,
@@ -487,41 +1097,77 @@ export class Parser {
487
1097
  }
488
1098
 
489
1099
  const operator = this.getCompareOp(token);
1100
+ // Threshold binds at `BP.DICE_LEFT` — see `parseComparePoint` TSDoc.
490
1101
  const value = this.parseExpression(BP.DICE_LEFT);
491
- const node: SuccessCountNode = {
1102
+ this.rejectSuccessCountTarget(value, token);
1103
+ this.rejectVersusMetaOperand(value, token);
1104
+ const start = target.start ?? token.position;
1105
+ const end = value.end ?? token.end;
1106
+
1107
+ if (this.peek().type !== TokenType.FAIL) {
1108
+ return { type: 'SuccessCount', target, threshold: { operator, value }, start, end };
1109
+ }
1110
+
1111
+ this.advance();
1112
+ const failThreshold = this.parseFailThreshold(token);
1113
+
1114
+ return {
492
1115
  type: 'SuccessCount',
493
1116
  target,
494
1117
  threshold: { operator, value },
1118
+ failThreshold,
1119
+ start,
1120
+ end: failThreshold.value.end ?? end,
495
1121
  };
1122
+ }
496
1123
 
497
- if (this.peek().type === TokenType.FAIL) {
498
- this.advance();
499
- const failValue = this.parseExpression(BP.DICE_LEFT);
500
- node.failThreshold = { operator: '=', value: failValue };
501
- }
1124
+ /** Parses the `f...` suffix of a success count. Bare `fN` means `f=N`. */
1125
+ private parseFailThreshold(token: Token): ComparePoint {
1126
+ if (this.isComparePointAhead()) return this.parseComparePoint();
502
1127
 
503
- return node;
1128
+ // Same threshold binding as `parseComparePoint` (BP.DICE_LEFT).
1129
+ const failValue = this.parseExpression(BP.DICE_LEFT);
1130
+ this.rejectSuccessCountTarget(failValue, token);
1131
+ this.rejectVersusMetaOperand(failValue, token);
1132
+
1133
+ return { operator: '=', value: failValue };
504
1134
  }
505
1135
 
506
1136
  private parseVersus(left: ASTNode, token: Token): VersusNode {
507
- // ? Chained `a vs b vs c` has no semantics — a degree is a scalar, not a
508
- // comparable. Parens (`a vs (b vs c)`) slip past this check and are
509
- // caught by the evaluator via `EvalEnv.insideVersus`.
510
- if (left.type === 'Versus') {
1137
+ this.rejectSuccessCountTarget(left, token);
1138
+
1139
+ // A degree is a scalar, not a comparable, so `a vs b vs c` has no semantics.
1140
+ // Unwrapping `Grouped` makes `(a vs b) vs c` reject here like the bare form;
1141
+ // a paren-nested DC (`a vs (b vs c)`) is caught later by the evaluator's
1142
+ // `mergeContext`.
1143
+ let leftChain: ASTNode = left;
1144
+ while (leftChain.type === 'Grouped') {
1145
+ leftChain = leftChain.expression;
1146
+ }
1147
+ if (leftChain.type === 'Versus') {
511
1148
  throw new ParseError('Cannot chain versus operators', 'NESTED_VERSUS', token.position, token);
512
1149
  }
513
1150
 
514
1151
  const dc = this.parseExpression(BP.VS_RIGHT);
1152
+ this.rejectSuccessCountTarget(dc, token);
515
1153
 
516
- return { type: 'Versus', roll: left, dc };
1154
+ return {
1155
+ type: 'Versus',
1156
+ roll: left,
1157
+ dc,
1158
+ start: left.start ?? token.position,
1159
+ end: dc.end ?? token.end,
1160
+ };
517
1161
  }
518
1162
 
1163
+ //
519
1164
  // * Compare point utilities
1165
+ //
520
1166
 
521
1167
  /**
522
1168
  * Checks whether the next token is a comparison operator.
523
1169
  */
524
- isComparePointAhead(): boolean {
1170
+ private isComparePointAhead(): boolean {
525
1171
  const type = this.peek().type;
526
1172
  return (
527
1173
  type === TokenType.GREATER ||
@@ -536,16 +1182,25 @@ export class Parser {
536
1182
  * Parses a comparison operator followed by a value expression.
537
1183
  * Called by modifier parsers (explode, reroll, success counting).
538
1184
  *
1185
+ * The threshold value is parsed at `BP.DICE_LEFT`, which binds tighter than
1186
+ * arithmetic. This keeps the comparison bound to the dice pool on the left
1187
+ * rather than letting arithmetic to the right be consumed into the
1188
+ * threshold. As a consequence, `1d6>=5+2` parses as `(1d6>=5)+2` with
1189
+ * threshold `5` — not `7`. Computed thresholds require parens:
1190
+ * `1d6>=(5+2)`. Same binding applies to `parseSuccessCount` below.
1191
+ *
539
1192
  * @returns A ComparePoint with the operator and value AST node
540
1193
  * @throws {ParseError} If the next token is not a comparison operator
541
1194
  */
542
- parseComparePoint(): ComparePoint {
1195
+ private parseComparePoint(): ComparePoint {
543
1196
  const token = this.peek();
544
1197
  const operator = this.getCompareOp(token);
545
1198
 
546
1199
  this.advance();
547
1200
 
548
1201
  const value = this.parseExpression(BP.DICE_LEFT);
1202
+ this.rejectSuccessCountTarget(value, token);
1203
+ this.rejectVersusMetaOperand(value, token);
549
1204
 
550
1205
  return { operator, value };
551
1206
  }
@@ -572,7 +1227,9 @@ export class Parser {
572
1227
  }
573
1228
  }
574
1229
 
1230
+ //
575
1231
  // * Helpers
1232
+ //
576
1233
 
577
1234
  private getOperatorSymbol(token: Token): '+' | '-' | '*' | '/' | '%' | '**' {
578
1235
  switch (token.type) {
@@ -624,20 +1281,31 @@ export class Parser {
624
1281
  case TokenType.EXPLODE_PENETRATING:
625
1282
  case TokenType.REROLL:
626
1283
  case TokenType.REROLL_ONCE:
627
- // Comparison operators act as LED-dispatched success-count modifiers
628
- // at the Pratt level. Inside `parseComparePoint` (called manually by
629
- // explode/reroll) they are consumed directly and this BP is bypassed.
1284
+ case TokenType.SORT_ASC:
1285
+ case TokenType.SORT_DESC:
1286
+ case TokenType.CRIT_SUCCESS:
1287
+ case TokenType.CRIT_FAIL:
1288
+ return BP.MODIFIER;
1289
+ // Comparison operators act as LED-dispatched success-count modifiers here;
1290
+ // `parseComparePoint`, called manually by explode/reroll, bypasses this BP.
1291
+ // See `BP.COMPARE` for why it sits below ADD/MUL.
630
1292
  case TokenType.GREATER:
631
1293
  case TokenType.GREATER_EQUAL:
632
1294
  case TokenType.LESS:
633
1295
  case TokenType.LESS_EQUAL:
634
1296
  case TokenType.EQUAL:
635
- return BP.MODIFIER;
1297
+ return BP.COMPARE;
1298
+ // `min`/`max` double as postfix per-die clamps (`4d6min2`); every
1299
+ // other function name terminates the expression like punctuation.
1300
+ case TokenType.FUNCTION:
1301
+ return token.value === 'min' || token.value === 'max' ? BP.MODIFIER : -1;
636
1302
  case TokenType.RPAREN:
637
1303
  case TokenType.EOF:
638
- // Punctuation and keywords that terminate expressions
639
1304
  case TokenType.COMMA:
640
- case TokenType.FUNCTION:
1305
+ // `}` is consumed inside `parseGroup`, and a stray `{` after a complete
1306
+ // expression is an error — both must terminate the outer Pratt loop.
1307
+ case TokenType.LBRACE:
1308
+ case TokenType.RBRACE:
641
1309
  return -1;
642
1310
  default:
643
1311
  return 0;
@@ -665,7 +1333,9 @@ export class Parser {
665
1333
  }
666
1334
 
667
1335
  private peek(): Token {
668
- return this.tokens[this.pos] ?? { type: TokenType.EOF, value: '', position: this.pos };
1336
+ return (
1337
+ this.tokens[this.pos] ?? { type: TokenType.EOF, value: '', position: this.pos, end: this.pos }
1338
+ );
669
1339
  }
670
1340
 
671
1341
  private advance(): Token {
@@ -674,12 +1344,13 @@ export class Parser {
674
1344
  return token;
675
1345
  }
676
1346
 
677
- private expect(type: TokenType): Token {
1347
+ private expect(type: ExpectableToken): Token {
678
1348
  const token = this.peek();
679
1349
  if (token.type !== type) {
680
- const expected = TokenType[type];
1350
+ const expected = TOKEN_DISPLAY[type];
1351
+ const got = token.type === TokenType.EOF ? 'end of input' : `'${token.value}'`;
681
1352
  throw new ParseError(
682
- `Expected ${expected} but got '${token.value}'`,
1353
+ `Expected ${expected} but got ${got}`,
683
1354
  'EXPECTED_TOKEN',
684
1355
  token.position,
685
1356
  token,
@@ -694,20 +1365,49 @@ export class Parser {
694
1365
  }
695
1366
 
696
1367
  /**
697
- * Parse a dice notation string into an AST.
1368
+ * Parses a dice notation string into an {@link ASTNode} tree, lexing it first.
1369
+ *
1370
+ * {@link evaluate} never mutates the AST, so parse once and evaluate many times
1371
+ * when rolling the same notation repeatedly — that skips the lexer and parser
1372
+ * on every roll after the first. Use `parse` alone to validate notation
1373
+ * without consuming randomness.
698
1374
  *
699
1375
  * @param notation - The dice notation to parse
700
- * @returns The root AST node
1376
+ * @returns The root AST node, with `start`/`end` spans on every node
701
1377
  * @throws {LexerError} If the input contains invalid characters
702
1378
  * @throws {ParseError} If the input has invalid syntax
1379
+ * @throws {RollParserError} `INVALID_NOTATION_TYPE` when `notation` is not a
1380
+ * string — raised before lexing, so it carries no position
703
1381
  *
704
1382
  * @example
705
1383
  * ```typescript
706
- * const ast = parse('2d6+3');
707
- * // { type: 'BinaryOp', operator: '+',
708
- * // left: { type: 'Dice', count: 2, sides: 6 },
709
- * // right: { type: 'Literal', value: 3 } }
1384
+ * import { parse } from 'roll-parser';
1385
+ *
1386
+ * parse('2d6+3');
1387
+ * // {
1388
+ * // type: 'BinaryOp', operator: '+', start: 0, end: 5,
1389
+ * // left: {
1390
+ * // type: 'Dice', start: 0, end: 3,
1391
+ * // count: { type: 'Literal', value: 2, start: 0, end: 1 },
1392
+ * // sides: { type: 'Literal', value: 6, start: 2, end: 3 },
1393
+ * // },
1394
+ * // right: { type: 'Literal', value: 3, start: 4, end: 5 },
1395
+ * // }
710
1396
  * ```
1397
+ *
1398
+ * `count` and `sides` are full nodes, not numbers — that is what makes
1399
+ * computed dice like `(1d4)d6` expressible.
1400
+ *
1401
+ * @example Parse once, roll many
1402
+ * ```typescript
1403
+ * import { evaluate, parse, SeededRNG } from 'roll-parser';
1404
+ *
1405
+ * const ast = parse('4d6kh3');
1406
+ * const rng = new SeededRNG('demo');
1407
+ * const scores = Array.from({ length: 6 }, () => evaluate(ast, rng).total);
1408
+ * ```
1409
+ *
1410
+ * @category Core
711
1411
  */
712
1412
  export function parse(notation: string): ASTNode {
713
1413
  const tokens = lex(notation);