roll-parser 3.0.0-beta.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/CHANGELOG.md +142 -9
  2. package/MIGRATION.md +219 -0
  3. package/README.md +1026 -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 +18 -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 +27 -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 +89 -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 +914 -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 +57 -5
  39. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  40. package/dist/evaluator/modifiers/crit-threshold.js +42 -0
  41. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
  42. package/dist/evaluator/modifiers/die-bound.d.ts +29 -0
  43. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
  44. package/dist/evaluator/modifiers/die-bound.js +17 -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 +105 -0
  49. package/dist/evaluator/modifiers/explode.js.map +1 -0
  50. package/dist/evaluator/modifiers/flags.d.ts +47 -0
  51. package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
  52. package/dist/evaluator/modifiers/flags.js +25 -0
  53. package/dist/evaluator/modifiers/flags.js.map +1 -0
  54. package/dist/evaluator/modifiers/keep-drop.d.ts +15 -29
  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 +68 -0
  61. package/dist/evaluator/modifiers/reroll.js.map +1 -0
  62. package/dist/evaluator/modifiers/sort.d.ts +5 -1
  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 +6 -7
  67. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  68. package/dist/evaluator/modifiers/success-count.js +25 -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 +324 -165
  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/render.d.ts +95 -0
  95. package/dist/render.d.ts.map +1 -0
  96. package/dist/render.js +227 -0
  97. package/dist/render.js.map +1 -0
  98. package/dist/rng/mock.d.ts +73 -12
  99. package/dist/rng/mock.d.ts.map +1 -1
  100. package/dist/rng/mock.js +30 -0
  101. package/dist/rng/mock.js.map +1 -0
  102. package/dist/rng/seeded.d.ts +141 -9
  103. package/dist/rng/seeded.d.ts.map +1 -1
  104. package/dist/rng/seeded.js +138 -0
  105. package/dist/rng/seeded.js.map +1 -0
  106. package/dist/rng/types.d.ts +57 -0
  107. package/dist/rng/types.d.ts.map +1 -1
  108. package/dist/rng/types.js +2 -0
  109. package/dist/rng/types.js.map +1 -0
  110. package/dist/roll.d.ts +58 -28
  111. package/dist/roll.d.ts.map +1 -1
  112. package/dist/roll.js +8 -0
  113. package/dist/roll.js.map +1 -0
  114. package/dist/testing.d.ts +5 -4
  115. package/dist/testing.d.ts.map +1 -1
  116. package/dist/testing.js +2 -41
  117. package/dist/testing.js.map +1 -11
  118. package/dist/types.d.ts +349 -47
  119. package/dist/types.d.ts.map +1 -1
  120. package/dist/types.js +8 -0
  121. package/dist/types.js.map +1 -0
  122. package/dist/version.d.ts +2 -0
  123. package/dist/version.d.ts.map +1 -0
  124. package/dist/version.js +2 -0
  125. package/dist/version.js.map +1 -0
  126. package/package.json +93 -34
  127. package/src/cli/args.ts +66 -10
  128. package/src/cli/format.ts +37 -26
  129. package/src/cli/index.ts +27 -84
  130. package/src/cli/main.ts +129 -0
  131. package/src/errors.ts +480 -27
  132. package/src/evaluator/die.ts +51 -0
  133. package/src/evaluator/env.ts +105 -0
  134. package/src/evaluator/evaluator.ts +693 -434
  135. package/src/evaluator/modifiers/crit-threshold.ts +96 -14
  136. package/src/evaluator/modifiers/die-bound.ts +48 -0
  137. package/src/evaluator/modifiers/explode.ts +70 -62
  138. package/src/evaluator/modifiers/flags.ts +78 -0
  139. package/src/evaluator/modifiers/keep-drop.ts +129 -127
  140. package/src/evaluator/modifiers/reroll.ts +44 -56
  141. package/src/evaluator/modifiers/sort.ts +21 -2
  142. package/src/evaluator/modifiers/success-count.ts +24 -12
  143. package/src/index.ts +56 -35
  144. package/src/lexer/lexer.ts +107 -34
  145. package/src/lexer/tokens.ts +31 -6
  146. package/src/parser/ast.ts +333 -346
  147. package/src/parser/guards.ts +248 -0
  148. package/src/parser/parser.ts +419 -242
  149. package/src/render.ts +392 -0
  150. package/src/rng/mock.ts +74 -13
  151. package/src/rng/seeded.ts +299 -64
  152. package/src/rng/types.ts +57 -0
  153. package/src/roll.ts +64 -47
  154. package/src/testing.ts +5 -9
  155. package/src/types.ts +353 -46
  156. package/src/version.ts +2 -0
  157. package/dist/cli.js +0 -2608
  158. package/dist/cli.js.map +0 -28
  159. package/dist/evaluator/index.d.ts +0 -8
  160. package/dist/evaluator/index.d.ts.map +0 -1
  161. package/dist/rng/index.d.ts +0 -8
  162. package/dist/rng/index.d.ts.map +0 -1
  163. package/src/evaluator/index.ts +0 -14
  164. package/src/rng/index.ts +0 -8
package/src/index.ts CHANGED
@@ -4,31 +4,45 @@
4
4
  * @module roll-parser
5
5
  */
6
6
 
7
- // * Error hierarchy
8
- export { RollParserError, isRollParserError } from './errors.js';
9
- export type { RollParserErrorCode } from './errors.js';
7
+ import { version } from './version.js';
10
8
 
11
- // * Lexer exports
12
- export { LexerError } from './lexer/lexer.js';
13
-
14
- // * Parser exports
15
- export { parse, ParseError } from './parser/parser.js';
9
+ export type { ErrorSpan, NotationErrorCode, RollParserErrorCode } from './errors.js';
10
+ export {
11
+ getErrorSpan,
12
+ isNotationError,
13
+ isRollParserError,
14
+ NOTATION_ERROR_CODES,
15
+ ROLL_PARSER_ERROR_CODES,
16
+ RollParserError,
17
+ } from './errors.js';
18
+ export {
19
+ DEFAULT_MAX_DICE,
20
+ DEFAULT_MAX_EXPLODE_ITERATIONS,
21
+ DEFAULT_MAX_REROLL_ITERATIONS,
22
+ EvaluatorError,
23
+ evaluate,
24
+ } from './evaluator/evaluator.js';
25
+ export { LexerError, lex } from './lexer/lexer.js';
26
+ export type { Token } from './lexer/tokens.js';
27
+ export { TokenType } from './lexer/tokens.js';
16
28
  export type {
17
29
  ASTNode,
18
30
  BinaryOpNode,
31
+ CritThreshold,
32
+ CritThresholdNode,
19
33
  DiceNode,
20
- NodeSpan,
34
+ DieBoundNode,
21
35
  ExplodeNode,
22
36
  FateDiceNode,
23
37
  FunctionCallNode,
24
38
  GroupedNode,
39
+ GroupNode,
40
+ KeepDropNode,
25
41
  LiteralNode,
26
- ModifierNode,
42
+ NodeSpan,
27
43
  RerollNode,
28
- SuccessCountNode,
29
44
  SortNode,
30
- CritThresholdNode,
31
- GroupNode,
45
+ SuccessCountNode,
32
46
  UnaryOpNode,
33
47
  VariableNode,
34
48
  VersusNode,
@@ -37,13 +51,14 @@ export {
37
51
  isBinaryOp,
38
52
  isCritThreshold,
39
53
  isDice,
54
+ isDieBound,
40
55
  isExplode,
41
56
  isFateDice,
42
57
  isFunctionCall,
43
58
  isGroup,
44
59
  isGrouped,
60
+ isKeepDrop,
45
61
  isLiteral,
46
- isModifier,
47
62
  isReroll,
48
63
  isSort,
49
64
  isSuccessCount,
@@ -51,40 +66,46 @@ export {
51
66
  isVariable,
52
67
  isVersus,
53
68
  } from './parser/ast.js';
54
-
55
- // * RNG exports
56
- export type { RNG } from './rng/types.js';
69
+ export { MAX_PARSE_DEPTH, ParseError, parse } from './parser/parser.js';
70
+ export type { RngState } from './rng/seeded.js';
57
71
  export { SeededRNG } from './rng/seeded.js';
58
-
59
- // * Evaluator exports
60
- export {
61
- DEFAULT_MAX_DICE,
62
- DEFAULT_MAX_EXPLODE_ITERATIONS,
63
- DEFAULT_MAX_REROLL_ITERATIONS,
64
- evaluate,
65
- EvaluatorError,
66
- } from './evaluator/evaluator.js';
72
+ export type { RNG } from './rng/types.js';
73
+ export type { RollOptions } from './roll.js';
74
+ export { roll } from './roll.js';
67
75
  export type {
68
76
  CompareOp,
69
77
  ComparePoint,
70
78
  DieModifier,
71
79
  DieResult,
72
80
  EvaluateOptions,
73
- ModifierSpec,
81
+ EvaluationOptions,
82
+ KeepDropSpec,
74
83
  ResolvedComparePoint,
75
84
  ResolvedCritThreshold,
76
85
  RollPart,
86
+ RollPartBase,
77
87
  RollPartType,
78
88
  RollResult,
79
89
  } from './types.js';
80
90
  export { DegreeOfSuccess } from './types.js';
81
91
 
82
- // * Public API
83
- export { roll } from './roll.js';
84
- export type { RollOptions } from './roll.js';
85
-
86
- // ? Named import Bun's bundler tree-shakes the JSON module down to the
87
- // single used property, so the full manifest is not embedded in dist.
88
- import { version } from '../package.json';
89
-
92
+ /**
93
+ * Installed roll-parser version the exact string in `package.json`, so
94
+ * pre-releases keep their suffix (`'3.0.0-beta.0'`). Sourced from the
95
+ * generated `src/version.ts`, kept in sync with the manifest by
96
+ * `bun run generate:version` and gated by `check:version` and CI.
97
+ *
98
+ * Useful in bug reports and for feature-gating against a minimum version. The
99
+ * CLI prints it for `--version` and in the `--help` header.
100
+ *
101
+ * @example
102
+ * ```typescript
103
+ * import { VERSION } from 'roll-parser';
104
+ *
105
+ * VERSION; // e.g. '3.0.0', or '3.1.0-beta.0' on a pre-release
106
+ * VERSION.startsWith('3.'); // true
107
+ * ```
108
+ *
109
+ * @category Core
110
+ */
90
111
  export const VERSION: string = version;
@@ -5,24 +5,72 @@
5
5
  */
6
6
 
7
7
  import type { RollParserErrorCode } from '../errors.js';
8
- import { RollParserError } from '../errors.js';
8
+ import { describeValue, RollParserError } from '../errors.js';
9
9
  import { type Token, TokenType } from './tokens.js';
10
10
 
11
11
  /**
12
12
  * Error thrown when the lexer encounters an invalid character.
13
+ *
14
+ * `position` is a zero-based UTF-16 offset into the input. It is deliberately
15
+ * absent from `message` — read it from the field, or uniformly across all
16
+ * roll-parser errors via `getErrorSpan`.
17
+ *
18
+ * Codes: `UNEXPECTED_CHARACTER` for a character that cannot start any token,
19
+ * `UNEXPECTED_IDENTIFIER` for a word that is not a known keyword.
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * import { LexerError, roll } from 'roll-parser';
24
+ *
25
+ * try {
26
+ * roll('2d6+&');
27
+ * } catch (error) {
28
+ * const typed = error as LexerError;
29
+ * typed.code; // 'UNEXPECTED_CHARACTER'
30
+ * typed.character; // '&'
31
+ * typed.position; // 4
32
+ * }
33
+ * ```
34
+ *
35
+ * @category Errors
13
36
  */
14
37
  export class LexerError extends RollParserError {
38
+ /** Zero-based UTF-16 offset of the offending character in the input. */
15
39
  readonly position: number;
40
+ /**
41
+ * The offending text — a single character for `UNEXPECTED_CHARACTER` (the
42
+ * whole code point, so astral symbols are not split into surrogates), or
43
+ * the unrecognized word for `UNEXPECTED_IDENTIFIER`.
44
+ */
16
45
  readonly character: string;
17
46
 
18
- constructor(message: string, code: RollParserErrorCode, position: number, character: string) {
19
- super(`${message} at position ${position}: '${character}'`, code);
47
+ constructor(
48
+ message: string,
49
+ code: RollParserErrorCode,
50
+ position: number,
51
+ character: string,
52
+ options?: ErrorOptions,
53
+ ) {
54
+ super(`${message}: '${character}'`, code, options);
20
55
  this.name = 'LexerError';
21
56
  this.position = position;
22
57
  this.character = character;
23
58
  }
24
59
  }
25
60
 
61
+ //
62
+ // * Character codes
63
+ //
64
+
65
+ // Range tests compare code units: `char.toLowerCase()` per character allocated
66
+ // a string on the lexer's hottest loop.
67
+ const CHAR_DIGIT_0 = 48;
68
+ const CHAR_DIGIT_9 = 57;
69
+ const CHAR_UPPER_A = 65;
70
+ const CHAR_UPPER_Z = 90;
71
+ const CHAR_LOWER_A = 97;
72
+ const CHAR_LOWER_Z = 122;
73
+
26
74
  /** Known identifier keywords mapped to their token types. */
27
75
  const IDENTIFIER_KEYWORDS: Record<string, TokenType> = {
28
76
  kh: TokenType.KEEP_HIGH,
@@ -39,6 +87,8 @@ const IDENTIFIER_KEYWORDS: Record<string, TokenType> = {
39
87
  ceil: TokenType.FUNCTION,
40
88
  round: TokenType.FUNCTION,
41
89
  abs: TokenType.FUNCTION,
90
+ sqrt: TokenType.FUNCTION,
91
+ pow: TokenType.FUNCTION,
42
92
  max: TokenType.FUNCTION,
43
93
  min: TokenType.FUNCTION,
44
94
  s: TokenType.SORT_ASC,
@@ -106,22 +156,18 @@ export class Lexer {
106
156
  const startPos = this.pos;
107
157
  const char = this.peek();
108
158
 
109
- // * Numbers
110
159
  if (this.isDigit(char)) {
111
160
  return this.scanNumber();
112
161
  }
113
162
 
114
- // * Identifiers (d, kh, kl, dh, dl, r, ro, f, vs, floor, ceil, ...)
115
163
  if (this.isAlpha(char)) {
116
164
  return this.scanIdentifier();
117
165
  }
118
166
 
119
- // * Variable reference
120
167
  if (char === '@') {
121
168
  return this.scanAt();
122
169
  }
123
170
 
124
- // * Operators and punctuation
125
171
  this.advance();
126
172
 
127
173
  switch (char) {
@@ -166,8 +212,7 @@ export class Lexer {
166
212
  if (this.match('!')) {
167
213
  return this.createTokenAt(TokenType.EXPLODE_COMPOUND, '!!', startPos);
168
214
  }
169
- if (!this.isAtEnd() && this.peek().toLowerCase() === 'p') {
170
- this.advance();
215
+ if (this.match('p') || this.match('P')) {
171
216
  return this.createTokenAt(TokenType.EXPLODE_PENETRATING, '!p', startPos);
172
217
  }
173
218
  return this.createTokenAt(TokenType.EXPLODE, char, startPos);
@@ -181,7 +226,9 @@ export class Lexer {
181
226
  }
182
227
  }
183
228
 
229
+ //
184
230
  // * Private helpers
231
+ //
185
232
 
186
233
  private skipWhitespace(): void {
187
234
  while (!this.isAtEnd() && this.isWhitespace(this.peek())) {
@@ -189,24 +236,23 @@ export class Lexer {
189
236
  }
190
237
  }
191
238
 
239
+ // Scanners slice once from a recorded start offset rather than accumulating
240
+ // `value += this.advance()` — one string per token instead of one per character.
192
241
  private scanNumber(): Token {
193
242
  const startPos = this.pos;
194
- let value = '';
195
243
 
196
- // Integer part
197
244
  while (!this.isAtEnd() && this.isDigit(this.peek())) {
198
- value += this.advance();
245
+ this.pos++;
199
246
  }
200
247
 
201
- // Decimal part
202
248
  if (!this.isAtEnd() && this.peek() === '.' && this.isDigit(this.peekNext())) {
203
- value += this.advance(); // consume '.'
249
+ this.pos++;
204
250
  while (!this.isAtEnd() && this.isDigit(this.peek())) {
205
- value += this.advance();
251
+ this.pos++;
206
252
  }
207
253
  }
208
254
 
209
- return this.createTokenAt(TokenType.NUMBER, value, startPos);
255
+ return this.createTokenAt(TokenType.NUMBER, this.input.slice(startPos, this.pos), startPos);
210
256
  }
211
257
 
212
258
  /**
@@ -227,18 +273,15 @@ export class Lexer {
227
273
  const first = this.peek();
228
274
  const second = this.peekNext();
229
275
  if ((first === 'd' || first === 'D') && (second === 'f' || second === 'F')) {
230
- this.advance();
231
- this.advance();
276
+ this.pos += 2;
232
277
  return this.createTokenAt(TokenType.DICE_FATE, 'df', startPos);
233
278
  }
234
279
 
235
- let value = '';
236
-
237
280
  while (!this.isAtEnd() && this.isAlpha(this.peek())) {
238
- value += this.advance();
281
+ this.pos++;
239
282
  }
240
283
 
241
- const lower = value.toLowerCase();
284
+ const lower = this.input.slice(startPos, this.pos).toLowerCase();
242
285
 
243
286
  if (lower === 'd' && !this.isAtEnd() && this.peek() === '%') {
244
287
  this.advance();
@@ -272,11 +315,11 @@ export class Lexer {
272
315
  */
273
316
  private scanAt(): Token {
274
317
  const startPos = this.pos;
275
- this.advance(); // consume '@'
318
+ this.advance();
276
319
 
277
320
  let name: string;
278
321
  if (!this.isAtEnd() && this.peek() === '{') {
279
- this.advance(); // consume '{'
322
+ this.advance();
280
323
  const nameStart = this.pos;
281
324
  while (!this.isAtEnd() && this.peek() !== '}' && this.peek() !== '\n') {
282
325
  this.advance();
@@ -285,7 +328,7 @@ export class Lexer {
285
328
  throw new LexerError('Unterminated @{...} variable', 'UNEXPECTED_CHARACTER', startPos, '@');
286
329
  }
287
330
  name = this.input.slice(nameStart, this.pos);
288
- this.advance(); // consume '}'
331
+ this.advance();
289
332
  } else {
290
333
  const nameStart = this.pos;
291
334
  if (this.isAtEnd() || !this.isIdentifierStart(this.peek())) {
@@ -324,13 +367,19 @@ export class Lexer {
324
367
  return this.pos >= this.input.length;
325
368
  }
326
369
 
370
+ // `NaN` from an empty `peek()` fails every comparison, so end-of-input
371
+ // still reads as "not a digit / not alpha" without an extra guard.
327
372
  private isDigit(char: string): boolean {
328
- return char >= '0' && char <= '9';
373
+ const code = char.charCodeAt(0);
374
+ return code >= CHAR_DIGIT_0 && code <= CHAR_DIGIT_9;
329
375
  }
330
376
 
331
377
  private isAlpha(char: string): boolean {
332
- const c = char.toLowerCase();
333
- return c >= 'a' && c <= 'z';
378
+ const code = char.charCodeAt(0);
379
+ return (
380
+ (code >= CHAR_LOWER_A && code <= CHAR_LOWER_Z) ||
381
+ (code >= CHAR_UPPER_A && code <= CHAR_UPPER_Z)
382
+ );
334
383
  }
335
384
 
336
385
  private isIdentifierStart(char: string): boolean {
@@ -345,8 +394,8 @@ export class Lexer {
345
394
  return char === ' ' || char === '\t' || char === '\n' || char === '\r';
346
395
  }
347
396
 
348
- // ? Both factories run after the token's characters were consumed, so
349
- // `this.pos` is the exclusive end offset.
397
+ // Both factories run after the token's characters were consumed, so
398
+ // `this.pos` is the exclusive end offset.
350
399
  private createToken(type: TokenType, value: string): Token {
351
400
  return { type, value, position: this.pos, end: this.pos };
352
401
  }
@@ -357,18 +406,42 @@ export class Lexer {
357
406
  }
358
407
 
359
408
  /**
360
- * Tokenize a dice notation string.
409
+ * Tokenizes a dice notation string. The first stage of the pipeline —
410
+ * {@link parse} calls it for you; reach for `lex` directly only to build a
411
+ * syntax highlighter or an editor integration.
412
+ *
413
+ * Notation is case-insensitive and whitespace-tolerant: `2D20 + 5` and
414
+ * `2d20+5` produce the same tokens, and identifier tokens carry a lowercased
415
+ * `value`. The one exception is `@name`, whose case is preserved.
361
416
  *
362
417
  * @param input - The dice notation to tokenize
363
- * @returns Array of tokens including EOF
364
- * @throws {LexerError} If an invalid character is encountered
418
+ * @returns Every token in source order, always ending with one
419
+ * `TokenType.EOF` token
420
+ * @throws {LexerError} If an invalid character or unknown identifier is found
421
+ * @throws {RollParserError} `INVALID_NOTATION_TYPE` when `input` is not a
422
+ * string — raised before scanning, so it carries no position
365
423
  *
366
424
  * @example
367
425
  * ```typescript
426
+ * import { lex, TokenType } from 'roll-parser';
427
+ *
368
428
  * const tokens = lex('2d20+5');
369
- * // [NUMBER(2), DICE, NUMBER(20), PLUS, NUMBER(5), EOF]
429
+ * tokens.length; // 6 — NUMBER DICE NUMBER PLUS NUMBER EOF
430
+ * tokens[0]; // { type: TokenType.NUMBER, value: '2', position: 0, end: 1 }
431
+ * tokens[1].type === TokenType.DICE; // true
432
+ * tokens.at(-1)?.type === TokenType.EOF; // true
370
433
  * ```
434
+ *
435
+ * @category Core
371
436
  */
372
437
  export function lex(input: string): Token[] {
438
+ // ! The pipeline's only notation type guard — `parse` and `roll` both funnel through here.
439
+ if (typeof input !== 'string') {
440
+ throw new RollParserError(
441
+ `Notation must be a string, received ${describeValue(input)}`,
442
+ 'INVALID_NOTATION_TYPE',
443
+ );
444
+ }
445
+
373
446
  return new Lexer(input).tokenize();
374
447
  }
@@ -9,6 +9,8 @@
9
9
  *
10
10
  * Grouped semantically. Numeric values are stable identifiers — the specific
11
11
  * numbers don't matter, but they must be unique.
12
+ *
13
+ * @category AST
12
14
  */
13
15
  export enum TokenType {
14
16
  //
@@ -116,7 +118,11 @@ export enum TokenType {
116
118
  // * Functions
117
119
  //
118
120
 
119
- /** Math function: 'floor', 'ceil', 'round', 'abs', 'max', 'min' */
121
+ /**
122
+ * Math function: 'floor', 'ceil', 'round', 'abs', 'sqrt', 'pow', 'max',
123
+ * 'min'. In postfix position, 'min'/'max' double as per-die clamp
124
+ * modifiers (`4d6min2`) — the parser decides by position, not the lexer.
125
+ */
120
126
  FUNCTION = 28,
121
127
 
122
128
  //
@@ -169,19 +175,38 @@ export enum TokenType {
169
175
  }
170
176
 
171
177
  /**
172
- * A token produced by the lexer.
178
+ * A token produced by {@link lex}: what it is, the text it came from, and
179
+ * where in the input that text sits.
180
+ *
181
+ * `position`/`end` are a half-open range, so `input.slice(position, end)`
182
+ * recovers the original source text — useful for syntax highlighting, where
183
+ * `value` alone is lossy.
184
+ *
185
+ * @example
186
+ * ```typescript
187
+ * import { lex, TokenType } from 'roll-parser';
188
+ *
189
+ * const input = '4D6KH3';
190
+ * const tokens = lex(input);
191
+ * tokens[1]; // { type: TokenType.DICE, value: 'd', position: 1, end: 2 }
192
+ * tokens[3].type === TokenType.KEEP_HIGH; // true
193
+ * input.slice(tokens[3].position, tokens[3].end); // 'KH' — original casing
194
+ * tokens[3].value; // 'kh' — normalized
195
+ * ```
196
+ *
197
+ * @category AST
173
198
  */
174
199
  export type Token = {
175
200
  /** The type of this token */
176
- type: TokenType;
201
+ readonly type: TokenType;
177
202
  /** The raw string value from input (lowercased for identifiers) */
178
- value: string;
203
+ readonly value: string;
179
204
  /** Zero-based start offset in the input string (UTF-16 code units) */
180
- position: number;
205
+ readonly position: number;
181
206
  /**
182
207
  * Zero-based end offset (exclusive). Not always `position + value.length` —
183
208
  * braced variables (`@{name}` stores only `name`) and case-normalized
184
209
  * identifiers consume more input than their `value` retains.
185
210
  */
186
- end: number;
211
+ readonly end: number;
187
212
  };