roll-parser 2.3.2 → 3.0.0-beta.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 (109) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +175 -170
  3. package/dist/cli/args.d.ts +35 -0
  4. package/dist/cli/args.d.ts.map +1 -0
  5. package/dist/cli/format.d.ts +20 -0
  6. package/dist/cli/format.d.ts.map +1 -0
  7. package/dist/cli/index.d.ts +8 -0
  8. package/dist/cli/index.d.ts.map +1 -0
  9. package/dist/cli.js +2608 -0
  10. package/dist/cli.js.map +28 -0
  11. package/dist/errors.d.ts +42 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/evaluator/evaluator.d.ts +121 -0
  14. package/dist/evaluator/evaluator.d.ts.map +1 -0
  15. package/dist/evaluator/index.d.ts +8 -0
  16. package/dist/evaluator/index.d.ts.map +1 -0
  17. package/dist/evaluator/modifiers/compare.d.ts +15 -0
  18. package/dist/evaluator/modifiers/compare.d.ts.map +1 -0
  19. package/dist/evaluator/modifiers/crit-threshold.d.ts +28 -0
  20. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
  21. package/dist/evaluator/modifiers/explode.d.ts +48 -0
  22. package/dist/evaluator/modifiers/explode.d.ts.map +1 -0
  23. package/dist/evaluator/modifiers/keep-drop.d.ts +44 -0
  24. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -0
  25. package/dist/evaluator/modifiers/reroll.d.ts +33 -0
  26. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -0
  27. package/dist/evaluator/modifiers/sort.d.ts +23 -0
  28. package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
  29. package/dist/evaluator/modifiers/success-count.d.ts +28 -0
  30. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -0
  31. package/dist/index.d.ts +20 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +2560 -0
  34. package/dist/index.js.map +26 -0
  35. package/dist/lexer/lexer.d.ts +90 -0
  36. package/dist/lexer/lexer.d.ts.map +1 -0
  37. package/dist/lexer/tokens.d.ts +107 -0
  38. package/dist/lexer/tokens.d.ts.map +1 -0
  39. package/dist/parser/ast.d.ts +368 -0
  40. package/dist/parser/ast.d.ts.map +1 -0
  41. package/dist/parser/parser.d.ts +145 -0
  42. package/dist/parser/parser.d.ts.map +1 -0
  43. package/dist/rng/index.d.ts +8 -0
  44. package/dist/rng/index.d.ts.map +1 -0
  45. package/dist/rng/mock.d.ts +36 -0
  46. package/dist/rng/mock.d.ts.map +1 -0
  47. package/dist/rng/seeded.d.ts +40 -0
  48. package/dist/rng/seeded.d.ts.map +1 -0
  49. package/dist/rng/types.d.ts +24 -0
  50. package/dist/rng/types.d.ts.map +1 -0
  51. package/dist/roll.d.ts +51 -0
  52. package/dist/roll.d.ts.map +1 -0
  53. package/dist/testing.d.ts +11 -0
  54. package/dist/testing.d.ts.map +1 -0
  55. package/dist/testing.js +41 -0
  56. package/dist/testing.js.map +11 -0
  57. package/dist/types.d.ts +246 -0
  58. package/dist/types.d.ts.map +1 -0
  59. package/package.json +66 -43
  60. package/src/cli/args.ts +73 -0
  61. package/src/cli/format.ts +49 -0
  62. package/src/cli/index.ts +92 -0
  63. package/src/errors.ts +90 -0
  64. package/src/evaluator/evaluator.ts +1552 -0
  65. package/src/evaluator/index.ts +14 -0
  66. package/src/evaluator/modifiers/compare.ts +29 -0
  67. package/src/evaluator/modifiers/crit-threshold.ts +59 -0
  68. package/src/evaluator/modifiers/explode.ts +219 -0
  69. package/src/evaluator/modifiers/keep-drop.ts +151 -0
  70. package/src/evaluator/modifiers/reroll.ts +165 -0
  71. package/src/evaluator/modifiers/sort.ts +30 -0
  72. package/src/evaluator/modifiers/success-count.ts +59 -0
  73. package/src/index.ts +90 -0
  74. package/src/lexer/lexer.ts +374 -0
  75. package/src/lexer/tokens.ts +187 -0
  76. package/src/parser/ast.ts +628 -0
  77. package/src/parser/parser.ts +1239 -0
  78. package/src/rng/index.ts +8 -0
  79. package/src/rng/mock.ts +65 -0
  80. package/src/rng/seeded.ts +157 -0
  81. package/src/rng/types.ts +25 -0
  82. package/src/roll.ts +74 -0
  83. package/src/testing.ts +16 -0
  84. package/src/types.ts +237 -0
  85. package/bin/help.txt +0 -19
  86. package/bin/roll-parser.js +0 -23
  87. package/dist/roll-parser.js +0 -697
  88. package/dist/roll-parser.min.js +0 -1
  89. package/index.js +0 -24
  90. package/src/complex/parse.js +0 -37
  91. package/src/complex/parseAndRoll.js +0 -21
  92. package/src/complex/parseAndRollClassic.js +0 -20
  93. package/src/complex/parseAndRollSimple.js +0 -19
  94. package/src/complex/parseAndRollWod.js +0 -20
  95. package/src/complex/parseClassicRoll.js +0 -22
  96. package/src/complex/parseSimpleRoll.js +0 -21
  97. package/src/complex/parseWodRoll.js +0 -21
  98. package/src/converter.js +0 -45
  99. package/src/grammar.js +0 -14
  100. package/src/mapper.js +0 -50
  101. package/src/normalizer.js +0 -51
  102. package/src/object/Result.js +0 -23
  103. package/src/object/Roll.js +0 -26
  104. package/src/object/Type.js +0 -5
  105. package/src/object/WodRoll.js +0 -29
  106. package/src/parser.js +0 -43
  107. package/src/random.js +0 -22
  108. package/src/roller.js +0 -106
  109. package/src/stringifier.js +0 -33
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Success counting modifier.
3
+ *
4
+ * Transforms a dice pool into a signed success count: each die matching the
5
+ * success `threshold` contributes +1, each die matching the optional
6
+ * `failThreshold` contributes -1. When a single die matches both thresholds,
7
+ * success wins (checked first).
8
+ *
9
+ * Dropped dice (from a preceding keep/drop or intermediate reroll) are
10
+ * excluded from counting and are never tagged.
11
+ *
12
+ * Mutates the input pool in place to add `'success'` / `'failure'` modifier
13
+ * flags — mirrors the mutation pattern of explode and reroll modifiers.
14
+ *
15
+ * @module evaluator/modifiers/success-count
16
+ */
17
+
18
+ import type { CompareOp, DieResult } from '../../types.js';
19
+ import { matchesCondition } from './compare.js';
20
+
21
+ export type ResolvedThreshold = {
22
+ operator: CompareOp;
23
+ value: number;
24
+ };
25
+
26
+ export type SuccessCountResult = {
27
+ total: number;
28
+ successes: number;
29
+ failures: number;
30
+ };
31
+
32
+ export function countSuccesses(
33
+ dice: DieResult[],
34
+ threshold: ResolvedThreshold,
35
+ failThreshold?: ResolvedThreshold,
36
+ ): SuccessCountResult {
37
+ let successes = 0;
38
+ let failures = 0;
39
+
40
+ for (const die of dice) {
41
+ if (die.modifiers.includes('dropped')) continue;
42
+
43
+ if (matchesCondition(die.result, threshold.operator, threshold.value)) {
44
+ die.modifiers.push('success');
45
+ successes += 1;
46
+ continue;
47
+ }
48
+
49
+ if (
50
+ failThreshold != null &&
51
+ matchesCondition(die.result, failThreshold.operator, failThreshold.value)
52
+ ) {
53
+ die.modifiers.push('failure');
54
+ failures += 1;
55
+ }
56
+ }
57
+
58
+ return { total: successes - failures, successes, failures };
59
+ }
package/src/index.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Roll Parser - Dice notation parser for tabletop RPGs.
3
+ *
4
+ * @module roll-parser
5
+ */
6
+
7
+ // * Error hierarchy
8
+ export { RollParserError, isRollParserError } from './errors.js';
9
+ export type { RollParserErrorCode } from './errors.js';
10
+
11
+ // * Lexer exports
12
+ export { LexerError } from './lexer/lexer.js';
13
+
14
+ // * Parser exports
15
+ export { parse, ParseError } from './parser/parser.js';
16
+ export type {
17
+ ASTNode,
18
+ BinaryOpNode,
19
+ DiceNode,
20
+ NodeSpan,
21
+ ExplodeNode,
22
+ FateDiceNode,
23
+ FunctionCallNode,
24
+ GroupedNode,
25
+ LiteralNode,
26
+ ModifierNode,
27
+ RerollNode,
28
+ SuccessCountNode,
29
+ SortNode,
30
+ CritThresholdNode,
31
+ GroupNode,
32
+ UnaryOpNode,
33
+ VariableNode,
34
+ VersusNode,
35
+ } from './parser/ast.js';
36
+ export {
37
+ isBinaryOp,
38
+ isCritThreshold,
39
+ isDice,
40
+ isExplode,
41
+ isFateDice,
42
+ isFunctionCall,
43
+ isGroup,
44
+ isGrouped,
45
+ isLiteral,
46
+ isModifier,
47
+ isReroll,
48
+ isSort,
49
+ isSuccessCount,
50
+ isUnaryOp,
51
+ isVariable,
52
+ isVersus,
53
+ } from './parser/ast.js';
54
+
55
+ // * RNG exports
56
+ export type { RNG } from './rng/types.js';
57
+ 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';
67
+ export type {
68
+ CompareOp,
69
+ ComparePoint,
70
+ DieModifier,
71
+ DieResult,
72
+ EvaluateOptions,
73
+ ModifierSpec,
74
+ ResolvedComparePoint,
75
+ ResolvedCritThreshold,
76
+ RollPart,
77
+ RollPartType,
78
+ RollResult,
79
+ } from './types.js';
80
+ export { DegreeOfSuccess } from './types.js';
81
+
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
+
90
+ export const VERSION: string = version;
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Dice notation lexer with character-by-character scanning.
3
+ *
4
+ * @module lexer/lexer
5
+ */
6
+
7
+ import type { RollParserErrorCode } from '../errors.js';
8
+ import { RollParserError } from '../errors.js';
9
+ import { type Token, TokenType } from './tokens.js';
10
+
11
+ /**
12
+ * Error thrown when the lexer encounters an invalid character.
13
+ */
14
+ export class LexerError extends RollParserError {
15
+ readonly position: number;
16
+ readonly character: string;
17
+
18
+ constructor(message: string, code: RollParserErrorCode, position: number, character: string) {
19
+ super(`${message} at position ${position}: '${character}'`, code);
20
+ this.name = 'LexerError';
21
+ this.position = position;
22
+ this.character = character;
23
+ }
24
+ }
25
+
26
+ /** Known identifier keywords mapped to their token types. */
27
+ const IDENTIFIER_KEYWORDS: Record<string, TokenType> = {
28
+ kh: TokenType.KEEP_HIGH,
29
+ kl: TokenType.KEEP_LOW,
30
+ k: TokenType.KEEP_HIGH,
31
+ dh: TokenType.DROP_HIGH,
32
+ dl: TokenType.DROP_LOW,
33
+ d: TokenType.DICE,
34
+ r: TokenType.REROLL,
35
+ ro: TokenType.REROLL_ONCE,
36
+ f: TokenType.FAIL,
37
+ vs: TokenType.VS,
38
+ floor: TokenType.FUNCTION,
39
+ ceil: TokenType.FUNCTION,
40
+ round: TokenType.FUNCTION,
41
+ abs: TokenType.FUNCTION,
42
+ max: TokenType.FUNCTION,
43
+ min: TokenType.FUNCTION,
44
+ s: TokenType.SORT_ASC,
45
+ sa: TokenType.SORT_ASC,
46
+ sd: TokenType.SORT_DESC,
47
+ cs: TokenType.CRIT_SUCCESS,
48
+ cf: TokenType.CRIT_FAIL,
49
+ };
50
+
51
+ /**
52
+ * Builds a hint for identifiers that start with a known keyword. Maximal
53
+ * munch merges adjacent modifiers when the first has no count — `4d6khs`
54
+ * lexes as one identifier `khs` instead of `kh` + `s`. Point the user at the
55
+ * explicit-count (or whitespace) split.
56
+ */
57
+ function buildIdentifierHint(identifier: string): string {
58
+ for (let length = identifier.length - 1; length >= 1; length--) {
59
+ const prefix = identifier.slice(0, length);
60
+ if (IDENTIFIER_KEYWORDS[prefix] == null) continue;
61
+ const rest = identifier.slice(length);
62
+ return ` (did you mean '${prefix}' followed by '${rest}'? separate modifiers with a count or space, e.g. '${prefix}1${rest}')`;
63
+ }
64
+ return '';
65
+ }
66
+
67
+ /**
68
+ * Lexer for dice notation.
69
+ *
70
+ * Produces a stream of tokens from an input string using character-by-character
71
+ * scanning with maximal munch for multi-character tokens.
72
+ */
73
+ export class Lexer {
74
+ private pos = 0;
75
+ private readonly input: string;
76
+
77
+ constructor(input: string) {
78
+ this.input = input;
79
+ }
80
+
81
+ /**
82
+ * Tokenize the entire input and return all tokens.
83
+ */
84
+ tokenize(): Token[] {
85
+ const tokens: Token[] = [];
86
+
87
+ while (true) {
88
+ const token = this.nextToken();
89
+ tokens.push(token);
90
+ if (token.type === TokenType.EOF) break;
91
+ }
92
+
93
+ return tokens;
94
+ }
95
+
96
+ /**
97
+ * Get the next token from the input.
98
+ */
99
+ nextToken(): Token {
100
+ this.skipWhitespace();
101
+
102
+ if (this.isAtEnd()) {
103
+ return this.createToken(TokenType.EOF, '');
104
+ }
105
+
106
+ const startPos = this.pos;
107
+ const char = this.peek();
108
+
109
+ // * Numbers
110
+ if (this.isDigit(char)) {
111
+ return this.scanNumber();
112
+ }
113
+
114
+ // * Identifiers (d, kh, kl, dh, dl, r, ro, f, vs, floor, ceil, ...)
115
+ if (this.isAlpha(char)) {
116
+ return this.scanIdentifier();
117
+ }
118
+
119
+ // * Variable reference
120
+ if (char === '@') {
121
+ return this.scanAt();
122
+ }
123
+
124
+ // * Operators and punctuation
125
+ this.advance();
126
+
127
+ switch (char) {
128
+ case '+':
129
+ return this.createTokenAt(TokenType.PLUS, char, startPos);
130
+ case '-':
131
+ return this.createTokenAt(TokenType.MINUS, char, startPos);
132
+ case '*':
133
+ if (this.match('*')) {
134
+ return this.createTokenAt(TokenType.POWER, '**', startPos);
135
+ }
136
+ return this.createTokenAt(TokenType.MULTIPLY, char, startPos);
137
+ case '/':
138
+ return this.createTokenAt(TokenType.DIVIDE, char, startPos);
139
+ case '%':
140
+ return this.createTokenAt(TokenType.MODULO, char, startPos);
141
+ case '^':
142
+ return this.createTokenAt(TokenType.POWER, char, startPos);
143
+ case '(':
144
+ return this.createTokenAt(TokenType.LPAREN, char, startPos);
145
+ case ')':
146
+ return this.createTokenAt(TokenType.RPAREN, char, startPos);
147
+ case ',':
148
+ return this.createTokenAt(TokenType.COMMA, char, startPos);
149
+ case '{':
150
+ return this.createTokenAt(TokenType.LBRACE, char, startPos);
151
+ case '}':
152
+ return this.createTokenAt(TokenType.RBRACE, char, startPos);
153
+ case '>':
154
+ if (this.match('=')) {
155
+ return this.createTokenAt(TokenType.GREATER_EQUAL, '>=', startPos);
156
+ }
157
+ return this.createTokenAt(TokenType.GREATER, char, startPos);
158
+ case '<':
159
+ if (this.match('=')) {
160
+ return this.createTokenAt(TokenType.LESS_EQUAL, '<=', startPos);
161
+ }
162
+ return this.createTokenAt(TokenType.LESS, char, startPos);
163
+ case '=':
164
+ return this.createTokenAt(TokenType.EQUAL, char, startPos);
165
+ case '!':
166
+ if (this.match('!')) {
167
+ return this.createTokenAt(TokenType.EXPLODE_COMPOUND, '!!', startPos);
168
+ }
169
+ if (!this.isAtEnd() && this.peek().toLowerCase() === 'p') {
170
+ this.advance();
171
+ return this.createTokenAt(TokenType.EXPLODE_PENETRATING, '!p', startPos);
172
+ }
173
+ return this.createTokenAt(TokenType.EXPLODE, char, startPos);
174
+ default: {
175
+ // Surrogate pairs (emoji, astral symbols) span two code units —
176
+ // report the full code point instead of a lone surrogate ('�').
177
+ const codePoint = this.input.codePointAt(startPos);
178
+ const display = codePoint == null ? char : String.fromCodePoint(codePoint);
179
+ throw new LexerError('Unexpected character', 'UNEXPECTED_CHARACTER', startPos, display);
180
+ }
181
+ }
182
+ }
183
+
184
+ // * Private helpers
185
+
186
+ private skipWhitespace(): void {
187
+ while (!this.isAtEnd() && this.isWhitespace(this.peek())) {
188
+ this.advance();
189
+ }
190
+ }
191
+
192
+ private scanNumber(): Token {
193
+ const startPos = this.pos;
194
+ let value = '';
195
+
196
+ // Integer part
197
+ while (!this.isAtEnd() && this.isDigit(this.peek())) {
198
+ value += this.advance();
199
+ }
200
+
201
+ // Decimal part
202
+ if (!this.isAtEnd() && this.peek() === '.' && this.isDigit(this.peekNext())) {
203
+ value += this.advance(); // consume '.'
204
+ while (!this.isAtEnd() && this.isDigit(this.peek())) {
205
+ value += this.advance();
206
+ }
207
+ }
208
+
209
+ return this.createTokenAt(TokenType.NUMBER, value, startPos);
210
+ }
211
+
212
+ /**
213
+ * Scans an identifier using full-accumulation: collects all consecutive
214
+ * alpha characters, then classifies the result against known keywords.
215
+ *
216
+ * Special cases run before/after the accumulation loop:
217
+ * - `dF` / `Df` / `dF` / `DF` produces DICE_FATE. Must be handled BEFORE
218
+ * the loop because `F` is alpha and would otherwise be greedily merged
219
+ * into identifiers like `dfkh` (from `4dFkh2`) or `dfdf` (from `dFdF`).
220
+ * Reserves the `d[fF]` prefix namespace for Fate dice.
221
+ * - Bare `d` followed by `%` produces DICE_PERCENT. `%` is not alpha so the
222
+ * accumulation loop stops naturally and the post-loop check handles it.
223
+ */
224
+ private scanIdentifier(): Token {
225
+ const startPos = this.pos;
226
+
227
+ const first = this.peek();
228
+ const second = this.peekNext();
229
+ if ((first === 'd' || first === 'D') && (second === 'f' || second === 'F')) {
230
+ this.advance();
231
+ this.advance();
232
+ return this.createTokenAt(TokenType.DICE_FATE, 'df', startPos);
233
+ }
234
+
235
+ let value = '';
236
+
237
+ while (!this.isAtEnd() && this.isAlpha(this.peek())) {
238
+ value += this.advance();
239
+ }
240
+
241
+ const lower = value.toLowerCase();
242
+
243
+ if (lower === 'd' && !this.isAtEnd() && this.peek() === '%') {
244
+ this.advance();
245
+ return this.createTokenAt(TokenType.DICE_PERCENT, 'd%', startPos);
246
+ }
247
+
248
+ const tokenType = IDENTIFIER_KEYWORDS[lower];
249
+ if (tokenType != null) {
250
+ return this.createTokenAt(tokenType, lower, startPos);
251
+ }
252
+
253
+ throw new LexerError(
254
+ `Unexpected identifier${buildIdentifierHint(lower)}`,
255
+ 'UNEXPECTED_IDENTIFIER',
256
+ startPos,
257
+ lower,
258
+ );
259
+ }
260
+
261
+ /**
262
+ * Scans a variable reference introduced by `@`.
263
+ *
264
+ * Two forms:
265
+ * - Bare: `@name` where `name` matches `[A-Za-z_][A-Za-z0-9_]*` (case preserved).
266
+ * - Braced: `@{name}` where `name` is any run of printable characters except
267
+ * `}` and newline (permits spaces, hyphens, digits).
268
+ *
269
+ * Case is preserved — distinct from `scanIdentifier`, which lowercases the
270
+ * captured value. The emitted token's `value` is the variable name without
271
+ * the leading `@` or the surrounding braces.
272
+ */
273
+ private scanAt(): Token {
274
+ const startPos = this.pos;
275
+ this.advance(); // consume '@'
276
+
277
+ let name: string;
278
+ if (!this.isAtEnd() && this.peek() === '{') {
279
+ this.advance(); // consume '{'
280
+ const nameStart = this.pos;
281
+ while (!this.isAtEnd() && this.peek() !== '}' && this.peek() !== '\n') {
282
+ this.advance();
283
+ }
284
+ if (this.isAtEnd() || this.peek() !== '}') {
285
+ throw new LexerError('Unterminated @{...} variable', 'UNEXPECTED_CHARACTER', startPos, '@');
286
+ }
287
+ name = this.input.slice(nameStart, this.pos);
288
+ this.advance(); // consume '}'
289
+ } else {
290
+ const nameStart = this.pos;
291
+ if (this.isAtEnd() || !this.isIdentifierStart(this.peek())) {
292
+ throw new LexerError('Empty @ variable name', 'UNEXPECTED_CHARACTER', startPos, '@');
293
+ }
294
+ this.advance();
295
+ while (!this.isAtEnd() && this.isIdentifierPart(this.peek())) {
296
+ this.advance();
297
+ }
298
+ name = this.input.slice(nameStart, this.pos);
299
+ }
300
+
301
+ return this.createTokenAt(TokenType.AT, name, startPos);
302
+ }
303
+
304
+ private peek(): string {
305
+ return this.input[this.pos] ?? '';
306
+ }
307
+
308
+ private peekNext(): string {
309
+ return this.input[this.pos + 1] ?? '';
310
+ }
311
+
312
+ private advance(): string {
313
+ return this.input[this.pos++] ?? '';
314
+ }
315
+
316
+ private match(expected: string): boolean {
317
+ if (this.isAtEnd()) return false;
318
+ if (this.input[this.pos] !== expected) return false;
319
+ this.pos++;
320
+ return true;
321
+ }
322
+
323
+ private isAtEnd(): boolean {
324
+ return this.pos >= this.input.length;
325
+ }
326
+
327
+ private isDigit(char: string): boolean {
328
+ return char >= '0' && char <= '9';
329
+ }
330
+
331
+ private isAlpha(char: string): boolean {
332
+ const c = char.toLowerCase();
333
+ return c >= 'a' && c <= 'z';
334
+ }
335
+
336
+ private isIdentifierStart(char: string): boolean {
337
+ return this.isAlpha(char) || char === '_';
338
+ }
339
+
340
+ private isIdentifierPart(char: string): boolean {
341
+ return this.isAlpha(char) || this.isDigit(char) || char === '_';
342
+ }
343
+
344
+ private isWhitespace(char: string): boolean {
345
+ return char === ' ' || char === '\t' || char === '\n' || char === '\r';
346
+ }
347
+
348
+ // ? Both factories run after the token's characters were consumed, so
349
+ // `this.pos` is the exclusive end offset.
350
+ private createToken(type: TokenType, value: string): Token {
351
+ return { type, value, position: this.pos, end: this.pos };
352
+ }
353
+
354
+ private createTokenAt(type: TokenType, value: string, position: number): Token {
355
+ return { type, value, position, end: this.pos };
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Tokenize a dice notation string.
361
+ *
362
+ * @param input - The dice notation to tokenize
363
+ * @returns Array of tokens including EOF
364
+ * @throws {LexerError} If an invalid character is encountered
365
+ *
366
+ * @example
367
+ * ```typescript
368
+ * const tokens = lex('2d20+5');
369
+ * // [NUMBER(2), DICE, NUMBER(20), PLUS, NUMBER(5), EOF]
370
+ * ```
371
+ */
372
+ export function lex(input: string): Token[] {
373
+ return new Lexer(input).tokenize();
374
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Token type definitions for the dice notation lexer.
3
+ *
4
+ * @module lexer/tokens
5
+ */
6
+
7
+ /**
8
+ * Token types for dice notation.
9
+ *
10
+ * Grouped semantically. Numeric values are stable identifiers — the specific
11
+ * numbers don't matter, but they must be unique.
12
+ */
13
+ export enum TokenType {
14
+ //
15
+ // * Literals
16
+ //
17
+
18
+ /** Numeric literal: integer or decimal */
19
+ NUMBER = 0,
20
+
21
+ //
22
+ // * Dice operators
23
+ //
24
+
25
+ /** Dice operator: 'd' or 'D' */
26
+ DICE = 1,
27
+ /** Percentile dice operator: 'd%' (alias for d100) */
28
+ DICE_PERCENT = 2,
29
+ /** Fate/Fudge dice operator: 'dF' */
30
+ DICE_FATE = 3,
31
+
32
+ //
33
+ // * Arithmetic operators
34
+ //
35
+
36
+ /** Addition operator: '+' */
37
+ PLUS = 4,
38
+ /** Subtraction operator: '-' */
39
+ MINUS = 5,
40
+ /** Multiplication operator: '*' */
41
+ MULTIPLY = 6,
42
+ /** Division operator: '/' */
43
+ DIVIDE = 7,
44
+ /** Modulo operator: '%' */
45
+ MODULO = 8,
46
+ /** Power operator: '**' or '^' */
47
+ POWER = 9,
48
+
49
+ //
50
+ // * Comparison operators
51
+ //
52
+
53
+ /** Greater than: '>' */
54
+ GREATER = 10,
55
+ /** Greater than or equal: '>=' */
56
+ GREATER_EQUAL = 11,
57
+ /** Less than: '<' */
58
+ LESS = 12,
59
+ /** Less than or equal: '<=' */
60
+ LESS_EQUAL = 13,
61
+ /** Equal: '=' */
62
+ EQUAL = 14,
63
+
64
+ //
65
+ // * Grouping and punctuation
66
+ //
67
+
68
+ /** Left parenthesis: '(' */
69
+ LPAREN = 15,
70
+ /** Right parenthesis: ')' */
71
+ RPAREN = 16,
72
+ /** Argument separator: ',' */
73
+ COMMA = 17,
74
+
75
+ //
76
+ // * Keep/drop modifiers
77
+ //
78
+
79
+ /** Keep highest modifier: 'kh' or 'k' */
80
+ KEEP_HIGH = 18,
81
+ /** Keep lowest modifier: 'kl' */
82
+ KEEP_LOW = 19,
83
+ /** Drop highest modifier: 'dh' */
84
+ DROP_HIGH = 20,
85
+ /** Drop lowest modifier: 'dl' */
86
+ DROP_LOW = 21,
87
+
88
+ //
89
+ // * Explode modifiers
90
+ //
91
+
92
+ /** Standard explode: '!' */
93
+ EXPLODE = 22,
94
+ /** Compounding explode: '!!' */
95
+ EXPLODE_COMPOUND = 23,
96
+ /** Penetrating explode: '!p' */
97
+ EXPLODE_PENETRATING = 24,
98
+
99
+ //
100
+ // * Reroll modifiers
101
+ //
102
+
103
+ /** Recursive reroll: 'r' */
104
+ REROLL = 25,
105
+ /** Reroll once: 'ro' */
106
+ REROLL_ONCE = 26,
107
+
108
+ //
109
+ // * Success counting
110
+ //
111
+
112
+ /** Fail marker: 'f' */
113
+ FAIL = 27,
114
+
115
+ //
116
+ // * Functions
117
+ //
118
+
119
+ /** Math function: 'floor', 'ceil', 'round', 'abs', 'max', 'min' */
120
+ FUNCTION = 28,
121
+
122
+ //
123
+ // * Keywords
124
+ //
125
+
126
+ /** Versus operator: 'vs' */
127
+ VS = 29,
128
+
129
+ //
130
+ // * Group boundaries
131
+ //
132
+
133
+ /** Left brace: '{' */
134
+ LBRACE = 30,
135
+ /** Right brace: '}' */
136
+ RBRACE = 31,
137
+
138
+ //
139
+ // * Variables
140
+ //
141
+
142
+ /** Variable reference prefix: '@' */
143
+ AT = 32,
144
+
145
+ //
146
+ // * Sort modifiers
147
+ //
148
+
149
+ /** Ascending sort: 's' or 'sa' */
150
+ SORT_ASC = 33,
151
+ /** Descending sort: 'sd' */
152
+ SORT_DESC = 34,
153
+
154
+ //
155
+ // * Crit thresholds
156
+ //
157
+
158
+ /** Critical success threshold: 'cs' */
159
+ CRIT_SUCCESS = 35,
160
+ /** Critical failure threshold: 'cf' */
161
+ CRIT_FAIL = 36,
162
+
163
+ //
164
+ // * End of input
165
+ //
166
+
167
+ /** End of input marker */
168
+ EOF = 37,
169
+ }
170
+
171
+ /**
172
+ * A token produced by the lexer.
173
+ */
174
+ export type Token = {
175
+ /** The type of this token */
176
+ type: TokenType;
177
+ /** The raw string value from input (lowercased for identifiers) */
178
+ value: string;
179
+ /** Zero-based start offset in the input string (UTF-16 code units) */
180
+ position: number;
181
+ /**
182
+ * Zero-based end offset (exclusive). Not always `position + value.length` —
183
+ * braced variables (`@{name}` stores only `name`) and case-normalized
184
+ * identifiers consume more input than their `value` retains.
185
+ */
186
+ end: number;
187
+ };