roll-parser 3.0.0-beta.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/CHANGELOG.md +107 -9
  2. package/MIGRATION.md +147 -0
  3. package/README.md +960 -154
  4. package/dist/cli/args.d.ts +1 -0
  5. package/dist/cli/args.d.ts.map +1 -1
  6. package/dist/cli/args.js +81 -0
  7. package/dist/cli/args.js.map +1 -0
  8. package/dist/cli/format.d.ts +15 -3
  9. package/dist/cli/format.d.ts.map +1 -1
  10. package/dist/cli/format.js +17 -0
  11. package/dist/cli/format.js.map +1 -0
  12. package/dist/cli/index.d.ts +3 -0
  13. package/dist/cli/index.d.ts.map +1 -1
  14. package/dist/cli/index.js +14 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/cli/main.d.ts +36 -0
  17. package/dist/cli/main.d.ts.map +1 -0
  18. package/dist/cli/main.js +83 -0
  19. package/dist/cli/main.js.map +1 -0
  20. package/dist/errors.d.ts +332 -17
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +141 -0
  23. package/dist/errors.js.map +1 -0
  24. package/dist/evaluator/die.d.ts +26 -0
  25. package/dist/evaluator/die.d.ts.map +1 -0
  26. package/dist/evaluator/die.js +19 -0
  27. package/dist/evaluator/die.js.map +1 -0
  28. package/dist/evaluator/env.d.ts +58 -0
  29. package/dist/evaluator/env.d.ts.map +1 -0
  30. package/dist/evaluator/env.js +11 -0
  31. package/dist/evaluator/env.js.map +1 -0
  32. package/dist/evaluator/evaluator.d.ts +41 -75
  33. package/dist/evaluator/evaluator.d.ts.map +1 -1
  34. package/dist/evaluator/evaluator.js +906 -0
  35. package/dist/evaluator/evaluator.js.map +1 -0
  36. package/dist/evaluator/modifiers/compare.js +15 -0
  37. package/dist/evaluator/modifiers/compare.js.map +1 -0
  38. package/dist/evaluator/modifiers/crit-threshold.d.ts +0 -1
  39. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -1
  40. package/dist/evaluator/modifiers/crit-threshold.js +23 -0
  41. package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
  42. package/dist/evaluator/modifiers/die-bound.d.ts +26 -0
  43. package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
  44. package/dist/evaluator/modifiers/die-bound.js +14 -0
  45. package/dist/evaluator/modifiers/die-bound.js.map +1 -0
  46. package/dist/evaluator/modifiers/explode.d.ts +13 -5
  47. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  48. package/dist/evaluator/modifiers/explode.js +103 -0
  49. package/dist/evaluator/modifiers/explode.js.map +1 -0
  50. package/dist/evaluator/modifiers/flags.d.ts +37 -0
  51. package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
  52. package/dist/evaluator/modifiers/flags.js +18 -0
  53. package/dist/evaluator/modifiers/flags.js.map +1 -0
  54. package/dist/evaluator/modifiers/keep-drop.d.ts +12 -28
  55. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  56. package/dist/evaluator/modifiers/keep-drop.js +82 -0
  57. package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
  58. package/dist/evaluator/modifiers/reroll.d.ts +12 -4
  59. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  60. package/dist/evaluator/modifiers/reroll.js +62 -0
  61. package/dist/evaluator/modifiers/reroll.js.map +1 -0
  62. package/dist/evaluator/modifiers/sort.d.ts +4 -0
  63. package/dist/evaluator/modifiers/sort.d.ts.map +1 -1
  64. package/dist/evaluator/modifiers/sort.js +13 -0
  65. package/dist/evaluator/modifiers/sort.js.map +1 -0
  66. package/dist/evaluator/modifiers/success-count.d.ts +2 -6
  67. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  68. package/dist/evaluator/modifiers/success-count.js +24 -0
  69. package/dist/evaluator/modifiers/success-count.js.map +1 -0
  70. package/dist/index.d.ts +33 -11
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +12 -2560
  73. package/dist/index.js.map +1 -26
  74. package/dist/lexer/lexer.d.ts +50 -5
  75. package/dist/lexer/lexer.d.ts.map +1 -1
  76. package/dist/lexer/lexer.js +260 -0
  77. package/dist/lexer/lexer.js.map +1 -0
  78. package/dist/lexer/tokens.d.ts +31 -6
  79. package/dist/lexer/tokens.d.ts.map +1 -1
  80. package/dist/lexer/tokens.js +42 -0
  81. package/dist/lexer/tokens.js.map +1 -0
  82. package/dist/parser/ast.d.ts +314 -160
  83. package/dist/parser/ast.d.ts.map +1 -1
  84. package/dist/parser/ast.js +52 -0
  85. package/dist/parser/ast.js.map +1 -0
  86. package/dist/parser/guards.d.ts +106 -0
  87. package/dist/parser/guards.d.ts.map +1 -0
  88. package/dist/parser/guards.js +121 -0
  89. package/dist/parser/guards.js.map +1 -0
  90. package/dist/parser/parser.d.ts +118 -14
  91. package/dist/parser/parser.d.ts.map +1 -1
  92. package/dist/parser/parser.js +751 -0
  93. package/dist/parser/parser.js.map +1 -0
  94. package/dist/rng/mock.d.ts +73 -12
  95. package/dist/rng/mock.d.ts.map +1 -1
  96. package/dist/rng/mock.js +30 -0
  97. package/dist/rng/mock.js.map +1 -0
  98. package/dist/rng/seeded.d.ts +141 -9
  99. package/dist/rng/seeded.d.ts.map +1 -1
  100. package/dist/rng/seeded.js +138 -0
  101. package/dist/rng/seeded.js.map +1 -0
  102. package/dist/rng/types.d.ts +57 -0
  103. package/dist/rng/types.d.ts.map +1 -1
  104. package/dist/rng/types.js +2 -0
  105. package/dist/rng/types.js.map +1 -0
  106. package/dist/roll.d.ts +58 -28
  107. package/dist/roll.d.ts.map +1 -1
  108. package/dist/roll.js +8 -0
  109. package/dist/roll.js.map +1 -0
  110. package/dist/testing.d.ts +5 -4
  111. package/dist/testing.d.ts.map +1 -1
  112. package/dist/testing.js +2 -41
  113. package/dist/testing.js.map +1 -11
  114. package/dist/types.d.ts +306 -44
  115. package/dist/types.d.ts.map +1 -1
  116. package/dist/types.js +8 -0
  117. package/dist/types.js.map +1 -0
  118. package/dist/version.d.ts +2 -0
  119. package/dist/version.d.ts.map +1 -0
  120. package/dist/version.js +2 -0
  121. package/dist/version.js.map +1 -0
  122. package/package.json +83 -34
  123. package/src/cli/args.ts +66 -10
  124. package/src/cli/format.ts +22 -3
  125. package/src/cli/index.ts +27 -84
  126. package/src/cli/main.ts +129 -0
  127. package/src/errors.ts +480 -27
  128. package/src/evaluator/die.ts +50 -0
  129. package/src/evaluator/env.ts +73 -0
  130. package/src/evaluator/evaluator.ts +653 -421
  131. package/src/evaluator/modifiers/crit-threshold.ts +3 -6
  132. package/src/evaluator/modifiers/die-bound.ts +39 -0
  133. package/src/evaluator/modifiers/explode.ts +60 -58
  134. package/src/evaluator/modifiers/flags.ts +61 -0
  135. package/src/evaluator/modifiers/keep-drop.ts +124 -126
  136. package/src/evaluator/modifiers/reroll.ts +28 -49
  137. package/src/evaluator/modifiers/sort.ts +14 -1
  138. package/src/evaluator/modifiers/success-count.ts +5 -8
  139. package/src/index.ts +56 -35
  140. package/src/lexer/lexer.ts +107 -34
  141. package/src/lexer/tokens.ts +31 -6
  142. package/src/parser/ast.ts +323 -341
  143. package/src/parser/guards.ts +248 -0
  144. package/src/parser/parser.ts +419 -242
  145. package/src/rng/mock.ts +74 -13
  146. package/src/rng/seeded.ts +299 -64
  147. package/src/rng/types.ts +57 -0
  148. package/src/roll.ts +64 -47
  149. package/src/testing.ts +5 -9
  150. package/src/types.ts +310 -43
  151. package/src/version.ts +2 -0
  152. package/dist/cli.js +0 -2608
  153. package/dist/cli.js.map +0 -28
  154. package/dist/evaluator/index.d.ts +0 -8
  155. package/dist/evaluator/index.d.ts.map +0 -1
  156. package/dist/rng/index.d.ts +0 -8
  157. package/dist/rng/index.d.ts.map +0 -1
  158. package/src/evaluator/index.ts +0 -14
  159. package/src/rng/index.ts +0 -8
@@ -9,49 +9,37 @@
9
9
  * @module evaluator/modifiers/reroll
10
10
  */
11
11
 
12
+ import { EvaluatorError } from '../../errors.js';
12
13
  import type { RNG } from '../../rng/types.js';
13
14
  import type { CompareOp, DieResult } from '../../types.js';
14
- import { EvaluatorError, type EvalEnv } from '../evaluator.js';
15
+ import { createDieResult, createFateDieResult } from '../die.js';
16
+ import { chargeDie, type EvalEnv } from '../env.js';
15
17
  import { matchesCondition } from './compare.js';
18
+ import { isVersusDc, REROLL_SLOT_FLAGS, rewriteFlags } from './flags.js';
16
19
 
17
- /** Default maximum reroll iterations per die. */
20
+ /**
21
+ * Default value of `EvaluationOptions.maxRerollIterations`: how many times a
22
+ * single die may be re-rolled by `r` before `REROLL_LIMIT_EXCEEDED` is
23
+ * thrown.
24
+ *
25
+ * Per die, not per pool — it bounds notations like `1d1r<2`, whose reroll
26
+ * condition can never stop matching. `ro` re-rolls exactly once and is
27
+ * unaffected.
28
+ *
29
+ * @category Limits
30
+ */
18
31
  export const DEFAULT_MAX_REROLL_ITERATIONS = 1_000;
19
32
 
20
- export { matchesCondition };
21
-
22
33
  /**
23
34
  * Rolls a replacement die for the given sides, charging it against the global
24
35
  * dice limit. Fate dice (sides === 0) re-roll on the {-1, 0, +1} range.
25
36
  */
26
37
  function rollReplacement(sides: number, rng: RNG, env: EvalEnv): DieResult {
27
- if (env.totalDiceRolled + 1 > env.maxDice) {
28
- throw new EvaluatorError(
29
- `Total dice count ${env.totalDiceRolled + 1} exceeds limit of ${env.maxDice}`,
30
- 'DICE_LIMIT_EXCEEDED',
31
- 'Reroll',
32
- );
33
- }
34
- env.totalDiceRolled += 1;
35
-
36
- if (sides === 0) {
37
- const result = rng.nextInt(-1, 1);
38
- return {
39
- sides: 0,
40
- result,
41
- modifiers: [],
42
- critical: false,
43
- fumble: false,
44
- };
45
- }
38
+ chargeDie(env, 'Reroll');
39
+
40
+ if (sides === 0) return createFateDieResult(rng.nextInt(-1, 1), []);
46
41
 
47
- const result = rng.nextInt(1, sides);
48
- return {
49
- sides,
50
- result,
51
- modifiers: [],
52
- critical: result === sides && sides > 1,
53
- fumble: result === 1 && sides > 1,
54
- };
42
+ return createDieResult(sides, rng.nextInt(1, sides), []);
55
43
  }
56
44
 
57
45
  function rerollLimitError(maxIterations: number): EvaluatorError {
@@ -67,16 +55,7 @@ function rerollLimitError(maxIterations: number): EvaluatorError {
67
55
  * keep/drop modifier) are left alone.
68
56
  */
69
57
  function canReroll(die: DieResult): boolean {
70
- return !die.modifiers.includes('dropped');
71
- }
72
-
73
- /**
74
- * Returns the die's modifiers with any "slot" flags removed. Slot flags
75
- * (`kept`, `dropped`, `rerolled`) are controlled by the surrounding logic
76
- * and should be reassigned each pass.
77
- */
78
- function stripSlotFlags(modifiers: DieResult['modifiers']): DieResult['modifiers'] {
79
- return modifiers.filter((m) => m !== 'kept' && m !== 'dropped' && m !== 'rerolled');
58
+ return !die.modifiers.includes('dropped') && !isVersusDc(die);
80
59
  }
81
60
 
82
61
  /**
@@ -105,21 +84,21 @@ export function applyRecursiveReroll(
105
84
  let current = original;
106
85
  let iterations = 0;
107
86
 
108
- // ? Mutate flags in place — the same DieResult objects are shared with
109
- // the RollPart tree, and both views must reflect reroll state.
87
+ // Mutate flags in place — the same DieResult objects are shared with
88
+ // the RollPart tree, and both views must reflect reroll state.
110
89
  while (matchesCondition(current.result, operator, value)) {
111
90
  if (iterations >= env.maxRerollIterations) {
112
91
  throw rerollLimitError(env.maxRerollIterations);
113
92
  }
114
93
 
115
- current.modifiers = [...stripSlotFlags(current.modifiers), 'rerolled', 'dropped'];
94
+ current.modifiers = rewriteFlags(current.modifiers, REROLL_SLOT_FLAGS, 'rerolled', 'dropped');
116
95
  result.push(current);
117
96
 
118
97
  current = rollReplacement(current.sides, rng, env);
119
98
  iterations += 1;
120
99
  }
121
100
 
122
- current.modifiers = [...stripSlotFlags(current.modifiers), 'kept'];
101
+ current.modifiers = rewriteFlags(current.modifiers, REROLL_SLOT_FLAGS, 'kept');
123
102
  result.push(current);
124
103
  }
125
104
 
@@ -146,18 +125,18 @@ export function applyRerollOnce(
146
125
  continue;
147
126
  }
148
127
 
149
- // ? Mutate flags in place — see `applyRecursiveReroll`.
128
+ // Mutate flags in place — see `applyRecursiveReroll`.
150
129
  if (!matchesCondition(original.result, operator, value)) {
151
- original.modifiers = [...stripSlotFlags(original.modifiers), 'kept'];
130
+ original.modifiers = rewriteFlags(original.modifiers, REROLL_SLOT_FLAGS, 'kept');
152
131
  result.push(original);
153
132
  continue;
154
133
  }
155
134
 
156
- original.modifiers = [...stripSlotFlags(original.modifiers), 'rerolled', 'dropped'];
135
+ original.modifiers = rewriteFlags(original.modifiers, REROLL_SLOT_FLAGS, 'rerolled', 'dropped');
157
136
  result.push(original);
158
137
 
159
138
  const replacement = rollReplacement(original.sides, rng, env);
160
- replacement.modifiers = [...stripSlotFlags(replacement.modifiers), 'kept'];
139
+ replacement.modifiers = rewriteFlags(replacement.modifiers, REROLL_SLOT_FLAGS, 'kept');
161
140
  result.push(replacement);
162
141
  }
163
142
 
@@ -7,10 +7,15 @@
7
7
  * Dropped dice stay in the pool and sort alongside kept dice so readers can
8
8
  * see where the dropped value landed in the ordered sequence.
9
9
  *
10
+ * DC dice are the exception: they hold their positions rather than sorting,
11
+ * so a `vs` comparison's DC faces never appear shuffled into the roll-side
12
+ * pool they are not a member of.
13
+ *
10
14
  * @module evaluator/modifiers/sort
11
15
  */
12
16
 
13
17
  import type { DieResult } from '../../types.js';
18
+ import { isVersusDc } from './flags.js';
14
19
 
15
20
  /**
16
21
  * Returns a sorted copy of `dice` in the given order, preserving every die's
@@ -26,5 +31,13 @@ export function sortDice(dice: DieResult[], order: 'ascending' | 'descending'):
26
31
  order === 'ascending'
27
32
  ? (a: DieResult, b: DieResult) => a.result - b.result
28
33
  : (a: DieResult, b: DieResult) => b.result - a.result;
29
- return [...dice].sort(cmp);
34
+
35
+ const sortable = dice.filter((die) => !isVersusDc(die));
36
+ if (sortable.length === dice.length) return [...dice].sort(cmp);
37
+
38
+ // Sort only the pool members, then lay them back into the slots they came
39
+ // from, leaving every DC die exactly where it was.
40
+ sortable.sort(cmp);
41
+ let next = 0;
42
+ return dice.map((die) => (isVersusDc(die) ? die : (sortable[next++] as DieResult)));
30
43
  }
@@ -15,13 +15,9 @@
15
15
  * @module evaluator/modifiers/success-count
16
16
  */
17
17
 
18
- import type { CompareOp, DieResult } from '../../types.js';
18
+ import type { DieResult, ResolvedComparePoint } from '../../types.js';
19
19
  import { matchesCondition } from './compare.js';
20
-
21
- export type ResolvedThreshold = {
22
- operator: CompareOp;
23
- value: number;
24
- };
20
+ import { isVersusDc } from './flags.js';
25
21
 
26
22
  export type SuccessCountResult = {
27
23
  total: number;
@@ -31,13 +27,14 @@ export type SuccessCountResult = {
31
27
 
32
28
  export function countSuccesses(
33
29
  dice: DieResult[],
34
- threshold: ResolvedThreshold,
35
- failThreshold?: ResolvedThreshold,
30
+ threshold: ResolvedComparePoint,
31
+ failThreshold?: ResolvedComparePoint,
36
32
  ): SuccessCountResult {
37
33
  let successes = 0;
38
34
  let failures = 0;
39
35
 
40
36
  for (const die of dice) {
37
+ if (isVersusDc(die)) continue;
41
38
  if (die.modifiers.includes('dropped')) continue;
42
39
 
43
40
  if (matchesCondition(die.result, threshold.operator, threshold.value)) {
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
  };