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,25 +4,73 @@
4
4
  * @module lexer/lexer
5
5
  */
6
6
 
7
- import type { RollParserErrorCode } from '../errors';
8
- import { RollParserError } from '../errors';
9
- import { type Token, TokenType } from './tokens';
7
+ import type { RollParserErrorCode } from '../errors.js';
8
+ import { describeValue, RollParserError } from '../errors.js';
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,10 +87,33 @@ 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,
94
+ s: TokenType.SORT_ASC,
95
+ sa: TokenType.SORT_ASC,
96
+ sd: TokenType.SORT_DESC,
97
+ cs: TokenType.CRIT_SUCCESS,
98
+ cf: TokenType.CRIT_FAIL,
44
99
  };
45
100
 
101
+ /**
102
+ * Builds a hint for identifiers that start with a known keyword. Maximal
103
+ * munch merges adjacent modifiers when the first has no count — `4d6khs`
104
+ * lexes as one identifier `khs` instead of `kh` + `s`. Point the user at the
105
+ * explicit-count (or whitespace) split.
106
+ */
107
+ function buildIdentifierHint(identifier: string): string {
108
+ for (let length = identifier.length - 1; length >= 1; length--) {
109
+ const prefix = identifier.slice(0, length);
110
+ if (IDENTIFIER_KEYWORDS[prefix] == null) continue;
111
+ const rest = identifier.slice(length);
112
+ return ` (did you mean '${prefix}' followed by '${rest}'? separate modifiers with a count or space, e.g. '${prefix}1${rest}')`;
113
+ }
114
+ return '';
115
+ }
116
+
46
117
  /**
47
118
  * Lexer for dice notation.
48
119
  *
@@ -85,17 +156,18 @@ export class Lexer {
85
156
  const startPos = this.pos;
86
157
  const char = this.peek();
87
158
 
88
- // * Numbers
89
159
  if (this.isDigit(char)) {
90
160
  return this.scanNumber();
91
161
  }
92
162
 
93
- // * Identifiers (d, kh, kl, dh, dl, r, ro, f, vs, floor, ceil, ...)
94
163
  if (this.isAlpha(char)) {
95
164
  return this.scanIdentifier();
96
165
  }
97
166
 
98
- // * Operators and punctuation
167
+ if (char === '@') {
168
+ return this.scanAt();
169
+ }
170
+
99
171
  this.advance();
100
172
 
101
173
  switch (char) {
@@ -120,6 +192,10 @@ export class Lexer {
120
192
  return this.createTokenAt(TokenType.RPAREN, char, startPos);
121
193
  case ',':
122
194
  return this.createTokenAt(TokenType.COMMA, char, startPos);
195
+ case '{':
196
+ return this.createTokenAt(TokenType.LBRACE, char, startPos);
197
+ case '}':
198
+ return this.createTokenAt(TokenType.RBRACE, char, startPos);
123
199
  case '>':
124
200
  if (this.match('=')) {
125
201
  return this.createTokenAt(TokenType.GREATER_EQUAL, '>=', startPos);
@@ -136,17 +212,23 @@ export class Lexer {
136
212
  if (this.match('!')) {
137
213
  return this.createTokenAt(TokenType.EXPLODE_COMPOUND, '!!', startPos);
138
214
  }
139
- if (!this.isAtEnd() && this.peek().toLowerCase() === 'p') {
140
- this.advance();
215
+ if (this.match('p') || this.match('P')) {
141
216
  return this.createTokenAt(TokenType.EXPLODE_PENETRATING, '!p', startPos);
142
217
  }
143
218
  return this.createTokenAt(TokenType.EXPLODE, char, startPos);
144
- default:
145
- throw new LexerError('Unexpected character', 'UNEXPECTED_CHARACTER', startPos, char);
219
+ default: {
220
+ // Surrogate pairs (emoji, astral symbols) span two code units —
221
+ // report the full code point instead of a lone surrogate ('�').
222
+ const codePoint = this.input.codePointAt(startPos);
223
+ const display = codePoint == null ? char : String.fromCodePoint(codePoint);
224
+ throw new LexerError('Unexpected character', 'UNEXPECTED_CHARACTER', startPos, display);
225
+ }
146
226
  }
147
227
  }
148
228
 
229
+ //
149
230
  // * Private helpers
231
+ //
150
232
 
151
233
  private skipWhitespace(): void {
152
234
  while (!this.isAtEnd() && this.isWhitespace(this.peek())) {
@@ -154,24 +236,23 @@ export class Lexer {
154
236
  }
155
237
  }
156
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.
157
241
  private scanNumber(): Token {
158
242
  const startPos = this.pos;
159
- let value = '';
160
243
 
161
- // Integer part
162
244
  while (!this.isAtEnd() && this.isDigit(this.peek())) {
163
- value += this.advance();
245
+ this.pos++;
164
246
  }
165
247
 
166
- // Decimal part
167
248
  if (!this.isAtEnd() && this.peek() === '.' && this.isDigit(this.peekNext())) {
168
- value += this.advance(); // consume '.'
249
+ this.pos++;
169
250
  while (!this.isAtEnd() && this.isDigit(this.peek())) {
170
- value += this.advance();
251
+ this.pos++;
171
252
  }
172
253
  }
173
254
 
174
- return this.createTokenAt(TokenType.NUMBER, value, startPos);
255
+ return this.createTokenAt(TokenType.NUMBER, this.input.slice(startPos, this.pos), startPos);
175
256
  }
176
257
 
177
258
  /**
@@ -192,18 +273,15 @@ export class Lexer {
192
273
  const first = this.peek();
193
274
  const second = this.peekNext();
194
275
  if ((first === 'd' || first === 'D') && (second === 'f' || second === 'F')) {
195
- this.advance();
196
- this.advance();
276
+ this.pos += 2;
197
277
  return this.createTokenAt(TokenType.DICE_FATE, 'df', startPos);
198
278
  }
199
279
 
200
- let value = '';
201
-
202
280
  while (!this.isAtEnd() && this.isAlpha(this.peek())) {
203
- value += this.advance();
281
+ this.pos++;
204
282
  }
205
283
 
206
- const lower = value.toLowerCase();
284
+ const lower = this.input.slice(startPos, this.pos).toLowerCase();
207
285
 
208
286
  if (lower === 'd' && !this.isAtEnd() && this.peek() === '%') {
209
287
  this.advance();
@@ -215,7 +293,55 @@ export class Lexer {
215
293
  return this.createTokenAt(tokenType, lower, startPos);
216
294
  }
217
295
 
218
- throw new LexerError('Unexpected identifier', 'UNEXPECTED_IDENTIFIER', startPos, lower);
296
+ throw new LexerError(
297
+ `Unexpected identifier${buildIdentifierHint(lower)}`,
298
+ 'UNEXPECTED_IDENTIFIER',
299
+ startPos,
300
+ lower,
301
+ );
302
+ }
303
+
304
+ /**
305
+ * Scans a variable reference introduced by `@`.
306
+ *
307
+ * Two forms:
308
+ * - Bare: `@name` where `name` matches `[A-Za-z_][A-Za-z0-9_]*` (case preserved).
309
+ * - Braced: `@{name}` where `name` is any run of printable characters except
310
+ * `}` and newline (permits spaces, hyphens, digits).
311
+ *
312
+ * Case is preserved — distinct from `scanIdentifier`, which lowercases the
313
+ * captured value. The emitted token's `value` is the variable name without
314
+ * the leading `@` or the surrounding braces.
315
+ */
316
+ private scanAt(): Token {
317
+ const startPos = this.pos;
318
+ this.advance();
319
+
320
+ let name: string;
321
+ if (!this.isAtEnd() && this.peek() === '{') {
322
+ this.advance();
323
+ const nameStart = this.pos;
324
+ while (!this.isAtEnd() && this.peek() !== '}' && this.peek() !== '\n') {
325
+ this.advance();
326
+ }
327
+ if (this.isAtEnd() || this.peek() !== '}') {
328
+ throw new LexerError('Unterminated @{...} variable', 'UNEXPECTED_CHARACTER', startPos, '@');
329
+ }
330
+ name = this.input.slice(nameStart, this.pos);
331
+ this.advance();
332
+ } else {
333
+ const nameStart = this.pos;
334
+ if (this.isAtEnd() || !this.isIdentifierStart(this.peek())) {
335
+ throw new LexerError('Empty @ variable name', 'UNEXPECTED_CHARACTER', startPos, '@');
336
+ }
337
+ this.advance();
338
+ while (!this.isAtEnd() && this.isIdentifierPart(this.peek())) {
339
+ this.advance();
340
+ }
341
+ name = this.input.slice(nameStart, this.pos);
342
+ }
343
+
344
+ return this.createTokenAt(TokenType.AT, name, startPos);
219
345
  }
220
346
 
221
347
  private peek(): string {
@@ -241,41 +367,81 @@ export class Lexer {
241
367
  return this.pos >= this.input.length;
242
368
  }
243
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.
244
372
  private isDigit(char: string): boolean {
245
- return char >= '0' && char <= '9';
373
+ const code = char.charCodeAt(0);
374
+ return code >= CHAR_DIGIT_0 && code <= CHAR_DIGIT_9;
246
375
  }
247
376
 
248
377
  private isAlpha(char: string): boolean {
249
- const c = char.toLowerCase();
250
- 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
+ );
383
+ }
384
+
385
+ private isIdentifierStart(char: string): boolean {
386
+ return this.isAlpha(char) || char === '_';
387
+ }
388
+
389
+ private isIdentifierPart(char: string): boolean {
390
+ return this.isAlpha(char) || this.isDigit(char) || char === '_';
251
391
  }
252
392
 
253
393
  private isWhitespace(char: string): boolean {
254
394
  return char === ' ' || char === '\t' || char === '\n' || char === '\r';
255
395
  }
256
396
 
397
+ // Both factories run after the token's characters were consumed, so
398
+ // `this.pos` is the exclusive end offset.
257
399
  private createToken(type: TokenType, value: string): Token {
258
- return { type, value, position: this.pos };
400
+ return { type, value, position: this.pos, end: this.pos };
259
401
  }
260
402
 
261
403
  private createTokenAt(type: TokenType, value: string, position: number): Token {
262
- return { type, value, position };
404
+ return { type, value, position, end: this.pos };
263
405
  }
264
406
  }
265
407
 
266
408
  /**
267
- * 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.
268
416
  *
269
417
  * @param input - The dice notation to tokenize
270
- * @returns Array of tokens including EOF
271
- * @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
272
423
  *
273
424
  * @example
274
425
  * ```typescript
426
+ * import { lex, TokenType } from 'roll-parser';
427
+ *
275
428
  * const tokens = lex('2d20+5');
276
- * // [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
277
433
  * ```
434
+ *
435
+ * @category Core
278
436
  */
279
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
+
280
446
  return new Lexer(input).tokenize();
281
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
  //
@@ -126,22 +132,81 @@ export enum TokenType {
126
132
  /** Versus operator: 'vs' */
127
133
  VS = 29,
128
134
 
135
+ //
136
+ // * Group boundaries
137
+ //
138
+
139
+ /** Left brace: '{' */
140
+ LBRACE = 30,
141
+ /** Right brace: '}' */
142
+ RBRACE = 31,
143
+
144
+ //
145
+ // * Variables
146
+ //
147
+
148
+ /** Variable reference prefix: '@' */
149
+ AT = 32,
150
+
151
+ //
152
+ // * Sort modifiers
153
+ //
154
+
155
+ /** Ascending sort: 's' or 'sa' */
156
+ SORT_ASC = 33,
157
+ /** Descending sort: 'sd' */
158
+ SORT_DESC = 34,
159
+
160
+ //
161
+ // * Crit thresholds
162
+ //
163
+
164
+ /** Critical success threshold: 'cs' */
165
+ CRIT_SUCCESS = 35,
166
+ /** Critical failure threshold: 'cf' */
167
+ CRIT_FAIL = 36,
168
+
129
169
  //
130
170
  // * End of input
131
171
  //
132
172
 
133
173
  /** End of input marker */
134
- EOF = 30,
174
+ EOF = 37,
135
175
  }
136
176
 
137
177
  /**
138
- * 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
139
198
  */
140
199
  export type Token = {
141
200
  /** The type of this token */
142
- type: TokenType;
201
+ readonly type: TokenType;
143
202
  /** The raw string value from input (lowercased for identifiers) */
144
- value: string;
145
- /** Zero-based position in the input string */
146
- position: number;
203
+ readonly value: string;
204
+ /** Zero-based start offset in the input string (UTF-16 code units) */
205
+ readonly position: number;
206
+ /**
207
+ * Zero-based end offset (exclusive). Not always `position + value.length` —
208
+ * braced variables (`@{name}` stores only `name`) and case-normalized
209
+ * identifiers consume more input than their `value` retains.
210
+ */
211
+ readonly end: number;
147
212
  };