roll-parser 3.0.0-alpha.0 → 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 (80) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +166 -30
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/format.d.ts +1 -1
  5. package/dist/cli/format.d.ts.map +1 -1
  6. package/dist/cli.js +1110 -277
  7. package/dist/cli.js.map +28 -0
  8. package/dist/errors.d.ts +7 -4
  9. package/dist/errors.d.ts.map +1 -1
  10. package/dist/evaluator/evaluator.d.ts +63 -7
  11. package/dist/evaluator/evaluator.d.ts.map +1 -1
  12. package/dist/evaluator/index.d.ts +2 -2
  13. package/dist/evaluator/index.d.ts.map +1 -1
  14. package/dist/evaluator/modifiers/compare.d.ts +1 -1
  15. package/dist/evaluator/modifiers/compare.d.ts.map +1 -1
  16. package/dist/evaluator/modifiers/crit-threshold.d.ts +28 -0
  17. package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
  18. package/dist/evaluator/modifiers/explode.d.ts +8 -4
  19. package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
  20. package/dist/evaluator/modifiers/keep-drop.d.ts +1 -1
  21. package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
  22. package/dist/evaluator/modifiers/reroll.d.ts +4 -4
  23. package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
  24. package/dist/evaluator/modifiers/sort.d.ts +23 -0
  25. package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
  26. package/dist/evaluator/modifiers/success-count.d.ts +1 -1
  27. package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
  28. package/dist/index.d.ts +13 -13
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1112 -275
  31. package/dist/index.js.map +26 -0
  32. package/dist/lexer/lexer.d.ts +18 -3
  33. package/dist/lexer/lexer.d.ts.map +1 -1
  34. package/dist/lexer/tokens.d.ts +22 -2
  35. package/dist/lexer/tokens.d.ts.map +1 -1
  36. package/dist/parser/ast.d.ts +209 -24
  37. package/dist/parser/ast.d.ts.map +1 -1
  38. package/dist/parser/parser.d.ts +48 -5
  39. package/dist/parser/parser.d.ts.map +1 -1
  40. package/dist/rng/index.d.ts +2 -2
  41. package/dist/rng/index.d.ts.map +1 -1
  42. package/dist/rng/mock.d.ts +1 -1
  43. package/dist/rng/mock.d.ts.map +1 -1
  44. package/dist/rng/seeded.d.ts +8 -1
  45. package/dist/rng/seeded.d.ts.map +1 -1
  46. package/dist/roll.d.ts +6 -2
  47. package/dist/roll.d.ts.map +1 -1
  48. package/dist/testing.d.ts +1 -1
  49. package/dist/testing.d.ts.map +1 -1
  50. package/dist/testing.js +3 -0
  51. package/dist/testing.js.map +11 -0
  52. package/dist/types.d.ts +143 -2
  53. package/dist/types.d.ts.map +1 -1
  54. package/package.json +22 -18
  55. package/src/cli/args.ts +2 -1
  56. package/src/cli/format.ts +8 -4
  57. package/src/cli/index.ts +22 -5
  58. package/src/errors.ts +15 -3
  59. package/src/evaluator/evaluator.ts +826 -111
  60. package/src/evaluator/index.ts +2 -2
  61. package/src/evaluator/modifiers/compare.ts +1 -1
  62. package/src/evaluator/modifiers/crit-threshold.ts +59 -0
  63. package/src/evaluator/modifiers/explode.ts +29 -25
  64. package/src/evaluator/modifiers/keep-drop.ts +1 -1
  65. package/src/evaluator/modifiers/reroll.ts +18 -25
  66. package/src/evaluator/modifiers/sort.ts +30 -0
  67. package/src/evaluator/modifiers/success-count.ts +2 -2
  68. package/src/index.ts +33 -15
  69. package/src/lexer/lexer.ts +101 -8
  70. package/src/lexer/tokens.ts +42 -2
  71. package/src/parser/ast.ts +397 -30
  72. package/src/parser/parser.ts +590 -67
  73. package/src/rng/index.ts +2 -2
  74. package/src/rng/mock.ts +1 -1
  75. package/src/rng/seeded.ts +31 -1
  76. package/src/roll.ts +14 -6
  77. package/src/testing.ts +1 -1
  78. package/src/types.ts +127 -2
  79. package/dist/index.mjs +0 -1724
  80. package/dist/testing.mjs +0 -39
@@ -4,11 +4,11 @@
4
4
  * @module evaluator
5
5
  */
6
6
 
7
- export { DEFAULT_MAX_DICE, evaluate, EvaluatorError } from './evaluator';
7
+ export { DEFAULT_MAX_DICE, evaluate, EvaluatorError } from './evaluator.js';
8
8
  export {
9
9
  applyDropHighest,
10
10
  applyDropLowest,
11
11
  applyKeepHighest,
12
12
  applyKeepLowest,
13
13
  sumKeptDice,
14
- } from './modifiers/keep-drop';
14
+ } from './modifiers/keep-drop.js';
@@ -8,7 +8,7 @@
8
8
  * @module evaluator/modifiers/compare
9
9
  */
10
10
 
11
- import type { CompareOp } from '../../types';
11
+ import type { CompareOp } from '../../types.js';
12
12
 
13
13
  /**
14
14
  * Compares a die result against a fixed comparison threshold.
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Critical / fumble threshold modifier.
3
+ *
4
+ * Overrides the default `critical`/`fumble` flag logic for a dice pool.
5
+ * `cs` and `cf` are independent (Roll20 semantics): the evaluator passes
6
+ * `['default']` for a side with no explicit thresholds, so overriding one
7
+ * side never wipes the other. An empty threshold array (not produced by the
8
+ * evaluator) would force the corresponding flag to `false` on every die.
9
+ * Meta dice (rolled to compute counts/sides/modifier args) are skipped so
10
+ * their bookkeeping stays untouched.
11
+ *
12
+ * Display-only: does not alter `total`, explosion triggers, success
13
+ * counting, or any other modifier flag. Dropped dice still participate —
14
+ * their `critical`/`fumble` metadata reflects what they rolled, not
15
+ * whether they contributed to the total.
16
+ *
17
+ * @module evaluator/modifiers/crit-threshold
18
+ */
19
+
20
+ import type { DieResult, ResolvedCritThreshold } from '../../types.js';
21
+ import { matchesCondition } from './compare.js';
22
+
23
+ // ? Re-exported for existing importers — the definition moved to types.ts so
24
+ // the public RollPart union can reference it without a circular import.
25
+ export type { ResolvedCritThreshold } from '../../types.js';
26
+
27
+ /**
28
+ * Applies success/fail threshold arrays to a dice pool, overriding each
29
+ * die's `critical` and `fumble` flags in place. A die matches `'default'`
30
+ * on the success side when `result === sides && sides > 1`, and on the
31
+ * fail side when `result === 1`. Meta dice are skipped.
32
+ */
33
+ export function applyCritThresholds(
34
+ dice: DieResult[],
35
+ successThresholds: ResolvedCritThreshold[],
36
+ failThresholds: ResolvedCritThreshold[],
37
+ ): void {
38
+ for (const die of dice) {
39
+ if (die.modifiers.includes('meta')) continue;
40
+
41
+ die.critical = successThresholds.some((t) => matchesCrit(t, die));
42
+ die.fumble = failThresholds.some((t) => matchesFumble(t, die));
43
+ }
44
+ }
45
+
46
+ function matchesCrit(threshold: ResolvedCritThreshold, die: DieResult): boolean {
47
+ if (threshold === 'default') {
48
+ return die.result === die.sides && die.sides > 1;
49
+ }
50
+ return matchesCondition(die.result, threshold.operator, threshold.value);
51
+ }
52
+
53
+ function matchesFumble(threshold: ResolvedCritThreshold, die: DieResult): boolean {
54
+ if (threshold === 'default') {
55
+ // ? Mirrors `createDieResult` — a d1 always rolls 1, never a fumble.
56
+ return die.result === 1 && die.sides > 1;
57
+ }
58
+ return matchesCondition(die.result, threshold.operator, threshold.value);
59
+ }
@@ -8,10 +8,10 @@
8
8
  * @module evaluator/modifiers/explode
9
9
  */
10
10
 
11
- import type { RNG } from '../../rng/types';
12
- import type { CompareOp } from '../../types';
13
- import type { DieResult } from '../../types';
14
- import { EvaluatorError, type EvalEnv } from '../evaluator';
11
+ import type { RNG } from '../../rng/types.js';
12
+ import type { CompareOp } from '../../types.js';
13
+ import type { DieResult } from '../../types.js';
14
+ import { EvaluatorError, type EvalEnv } from '../evaluator.js';
15
15
 
16
16
  /** Default maximum explosion iterations per die. */
17
17
  export const DEFAULT_MAX_EXPLODE_ITERATIONS = 1_000;
@@ -75,8 +75,11 @@ function explodeLimitError(maxIterations: number): EvaluatorError {
75
75
  * Returns true when the die is eligible to start exploding: it must not
76
76
  * already be dropped by a prior modifier, and its `sides` must be rollable.
77
77
  *
78
- * Fate dice (sides = 0) are skipped defensively`rng.nextInt(1, 0)` is
79
- * invalid. Fate + explode is out of scope for the current feature set.
78
+ * The `sides < 1` branch is a defense-in-depth fallback Fate dice
79
+ * (sides = 0) are rejected at parse time via `INVALID_EXPLODE_TARGET`
80
+ * (see `parseExplode` in `src/parser/parser.ts`), so this guard should
81
+ * never fire during normal flow. Keeping it ensures `rng.nextInt(1, 0)`
82
+ * can never be reached if a future AST path slips past the parser gate.
80
83
  */
81
84
  function canExplode(die: DieResult): boolean {
82
85
  if (die.modifiers.includes('dropped')) return false;
@@ -114,7 +117,7 @@ export function applyStandardExplode(
114
117
  result: next,
115
118
  modifiers: ['exploded', 'kept'],
116
119
  critical: next === sides && sides > 1,
117
- fumble: next === 1,
120
+ fumble: next === 1 && sides > 1,
118
121
  });
119
122
  last = next;
120
123
  iterations += 1;
@@ -128,6 +131,10 @@ export function applyStandardExplode(
128
131
  * Compound explode: pool length stays the same. Each original die's `result`
129
132
  * accumulates every explosion roll, and gains the `'exploded'` modifier once
130
133
  * it actually exploded at least once.
134
+ *
135
+ * Mutates exploded dice in place — the same `DieResult` objects are shared
136
+ * between `RollResult.rolls` and the `RollPart` tree, and both must reflect
137
+ * post-explosion state.
131
138
  */
132
139
  export function applyCompoundExplode(
133
140
  pool: DieResult[],
@@ -135,8 +142,8 @@ export function applyCompoundExplode(
135
142
  rng: RNG,
136
143
  env: EvalEnv,
137
144
  ): DieResult[] {
138
- return pool.map((original) => {
139
- if (!canExplode(original)) return original;
145
+ for (const original of pool) {
146
+ if (!canExplode(original)) continue;
140
147
 
141
148
  const sides = original.sides;
142
149
  let accumulated = original.result;
@@ -155,21 +162,18 @@ export function applyCompoundExplode(
155
162
  iterations += 1;
156
163
  }
157
164
 
158
- if (!exploded) return original;
159
-
160
- return {
161
- sides,
162
- result: accumulated,
163
- modifiers: original.modifiers.includes('exploded')
164
- ? original.modifiers
165
- : [...original.modifiers, 'exploded'],
166
- // ? `critical` and `fumble` refer to the original triggering roll, but
167
- // after compounding the `result` is a sum. Mark critical if the
168
- // original roll was max; fumble stays as-is (can't accumulate down).
169
- critical: original.critical,
170
- fumble: original.fumble,
171
- };
172
- });
165
+ if (!exploded) continue;
166
+
167
+ // ? `critical` and `fumble` keep referring to the original triggering
168
+ // roll — after compounding the `result` is a sum.
169
+ original.initialResult = original.result;
170
+ original.result = accumulated;
171
+ if (!original.modifiers.includes('exploded')) {
172
+ original.modifiers = [...original.modifiers, 'exploded'];
173
+ }
174
+ }
175
+
176
+ return pool;
173
177
  }
174
178
 
175
179
  /**
@@ -204,7 +208,7 @@ export function applyPenetratingExplode(
204
208
  result: stored,
205
209
  modifiers: ['exploded', 'kept'],
206
210
  critical: raw === sides && sides > 1,
207
- fumble: raw === 1,
211
+ fumble: raw === 1 && sides > 1,
208
212
  });
209
213
  lastRaw = raw;
210
214
  iterations += 1;
@@ -4,7 +4,7 @@
4
4
  * @module evaluator/modifiers/keep-drop
5
5
  */
6
6
 
7
- import type { DieResult } from '../../types';
7
+ import type { DieResult } from '../../types.js';
8
8
 
9
9
  /**
10
10
  * Marks all eligible dice as `'kept'`. Dice that already carry `'dropped'`
@@ -9,10 +9,10 @@
9
9
  * @module evaluator/modifiers/reroll
10
10
  */
11
11
 
12
- import type { RNG } from '../../rng/types';
13
- import type { CompareOp, DieResult } from '../../types';
14
- import { EvaluatorError, type EvalEnv } from '../evaluator';
15
- import { matchesCondition } from './compare';
12
+ import type { RNG } from '../../rng/types.js';
13
+ import type { CompareOp, DieResult } from '../../types.js';
14
+ import { EvaluatorError, type EvalEnv } from '../evaluator.js';
15
+ import { matchesCondition } from './compare.js';
16
16
 
17
17
  /** Default maximum reroll iterations per die. */
18
18
  export const DEFAULT_MAX_REROLL_ITERATIONS = 1_000;
@@ -50,7 +50,7 @@ function rollReplacement(sides: number, rng: RNG, env: EvalEnv): DieResult {
50
50
  result,
51
51
  modifiers: [],
52
52
  critical: result === sides && sides > 1,
53
- fumble: result === 1,
53
+ fumble: result === 1 && sides > 1,
54
54
  };
55
55
  }
56
56
 
@@ -105,24 +105,22 @@ export function applyRecursiveReroll(
105
105
  let current = original;
106
106
  let iterations = 0;
107
107
 
108
+ // ? Mutate flags in place — the same DieResult objects are shared with
109
+ // the RollPart tree, and both views must reflect reroll state.
108
110
  while (matchesCondition(current.result, operator, value)) {
109
111
  if (iterations >= env.maxRerollIterations) {
110
112
  throw rerollLimitError(env.maxRerollIterations);
111
113
  }
112
114
 
113
- result.push({
114
- ...current,
115
- modifiers: [...stripSlotFlags(current.modifiers), 'rerolled', 'dropped'],
116
- });
115
+ current.modifiers = [...stripSlotFlags(current.modifiers), 'rerolled', 'dropped'];
116
+ result.push(current);
117
117
 
118
118
  current = rollReplacement(current.sides, rng, env);
119
119
  iterations += 1;
120
120
  }
121
121
 
122
- result.push({
123
- ...current,
124
- modifiers: [...stripSlotFlags(current.modifiers), 'kept'],
125
- });
122
+ current.modifiers = [...stripSlotFlags(current.modifiers), 'kept'];
123
+ result.push(current);
126
124
  }
127
125
 
128
126
  return result;
@@ -148,24 +146,19 @@ export function applyRerollOnce(
148
146
  continue;
149
147
  }
150
148
 
149
+ // ? Mutate flags in place — see `applyRecursiveReroll`.
151
150
  if (!matchesCondition(original.result, operator, value)) {
152
- result.push({
153
- ...original,
154
- modifiers: [...stripSlotFlags(original.modifiers), 'kept'],
155
- });
151
+ original.modifiers = [...stripSlotFlags(original.modifiers), 'kept'];
152
+ result.push(original);
156
153
  continue;
157
154
  }
158
155
 
159
- result.push({
160
- ...original,
161
- modifiers: [...stripSlotFlags(original.modifiers), 'rerolled', 'dropped'],
162
- });
156
+ original.modifiers = [...stripSlotFlags(original.modifiers), 'rerolled', 'dropped'];
157
+ result.push(original);
163
158
 
164
159
  const replacement = rollReplacement(original.sides, rng, env);
165
- result.push({
166
- ...replacement,
167
- modifiers: [...stripSlotFlags(replacement.modifiers), 'kept'],
168
- });
160
+ replacement.modifiers = [...stripSlotFlags(replacement.modifiers), 'kept'];
161
+ result.push(replacement);
169
162
  }
170
163
 
171
164
  return result;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Sort modifier implementation.
3
+ *
4
+ * Sort is purely cosmetic — it reorders the dice produced by its target in
5
+ * ascending or descending order of `result` without touching any flag
6
+ * (`kept`, `dropped`, `critical`, `fumble`, `rerolled`, `meta`) or the total.
7
+ * Dropped dice stay in the pool and sort alongside kept dice so readers can
8
+ * see where the dropped value landed in the ordered sequence.
9
+ *
10
+ * @module evaluator/modifiers/sort
11
+ */
12
+
13
+ import type { DieResult } from '../../types.js';
14
+
15
+ /**
16
+ * Returns a sorted copy of `dice` in the given order, preserving every die's
17
+ * original flags and metadata. Comparison is on `.result` — the raw face
18
+ * value is what the user sees in rendered output, so ordering by raw value
19
+ * matches the visual intent regardless of any `kept`/`dropped` flagging.
20
+ *
21
+ * Relies on `Array.prototype.sort` being stable — equal-valued dice retain
22
+ * their original insertion order.
23
+ */
24
+ export function sortDice(dice: DieResult[], order: 'ascending' | 'descending'): DieResult[] {
25
+ const cmp =
26
+ order === 'ascending'
27
+ ? (a: DieResult, b: DieResult) => a.result - b.result
28
+ : (a: DieResult, b: DieResult) => b.result - a.result;
29
+ return [...dice].sort(cmp);
30
+ }
@@ -15,8 +15,8 @@
15
15
  * @module evaluator/modifiers/success-count
16
16
  */
17
17
 
18
- import type { CompareOp, DieResult } from '../../types';
19
- import { matchesCondition } from './compare';
18
+ import type { CompareOp, DieResult } from '../../types.js';
19
+ import { matchesCondition } from './compare.js';
20
20
 
21
21
  export type ResolvedThreshold = {
22
22
  operator: CompareOp;
package/src/index.ts CHANGED
@@ -5,45 +5,56 @@
5
5
  */
6
6
 
7
7
  // * Error hierarchy
8
- export { RollParserError, isRollParserError } from './errors';
9
- export type { RollParserErrorCode } from './errors';
8
+ export { RollParserError, isRollParserError } from './errors.js';
9
+ export type { RollParserErrorCode } from './errors.js';
10
10
 
11
11
  // * Lexer exports
12
- export { LexerError } from './lexer/lexer';
12
+ export { LexerError } from './lexer/lexer.js';
13
13
 
14
14
  // * Parser exports
15
- export { parse, ParseError } from './parser/parser';
15
+ export { parse, ParseError } from './parser/parser.js';
16
16
  export type {
17
17
  ASTNode,
18
18
  BinaryOpNode,
19
19
  DiceNode,
20
+ NodeSpan,
20
21
  ExplodeNode,
21
22
  FateDiceNode,
22
23
  FunctionCallNode,
24
+ GroupedNode,
23
25
  LiteralNode,
24
26
  ModifierNode,
25
27
  RerollNode,
26
28
  SuccessCountNode,
29
+ SortNode,
30
+ CritThresholdNode,
31
+ GroupNode,
27
32
  UnaryOpNode,
33
+ VariableNode,
28
34
  VersusNode,
29
- } from './parser/ast';
35
+ } from './parser/ast.js';
30
36
  export {
31
37
  isBinaryOp,
38
+ isCritThreshold,
32
39
  isDice,
33
40
  isExplode,
34
41
  isFateDice,
35
42
  isFunctionCall,
43
+ isGroup,
44
+ isGrouped,
36
45
  isLiteral,
37
46
  isModifier,
38
47
  isReroll,
48
+ isSort,
39
49
  isSuccessCount,
40
50
  isUnaryOp,
51
+ isVariable,
41
52
  isVersus,
42
- } from './parser/ast';
53
+ } from './parser/ast.js';
43
54
 
44
55
  // * RNG exports
45
- export type { RNG } from './rng/types';
46
- export { SeededRNG } from './rng/seeded';
56
+ export type { RNG } from './rng/types.js';
57
+ export { SeededRNG } from './rng/seeded.js';
47
58
 
48
59
  // * Evaluator exports
49
60
  export {
@@ -52,21 +63,28 @@ export {
52
63
  DEFAULT_MAX_REROLL_ITERATIONS,
53
64
  evaluate,
54
65
  EvaluatorError,
55
- } from './evaluator/evaluator';
66
+ } from './evaluator/evaluator.js';
56
67
  export type {
57
68
  CompareOp,
58
69
  ComparePoint,
59
70
  DieModifier,
60
71
  DieResult,
61
72
  EvaluateOptions,
73
+ ModifierSpec,
74
+ ResolvedComparePoint,
75
+ ResolvedCritThreshold,
76
+ RollPart,
77
+ RollPartType,
62
78
  RollResult,
63
- } from './types';
64
- export { DegreeOfSuccess } from './types';
79
+ } from './types.js';
80
+ export { DegreeOfSuccess } from './types.js';
65
81
 
66
82
  // * Public API
67
- export { roll } from './roll';
68
- export type { RollOptions } from './roll';
83
+ export { roll } from './roll.js';
84
+ export type { RollOptions } from './roll.js';
69
85
 
70
- import pkg from '../package.json';
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';
71
89
 
72
- export const VERSION: string = pkg.version;
90
+ export const VERSION: string = version;
@@ -4,9 +4,9 @@
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 { 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.
@@ -41,8 +41,29 @@ const IDENTIFIER_KEYWORDS: Record<string, TokenType> = {
41
41
  abs: TokenType.FUNCTION,
42
42
  max: TokenType.FUNCTION,
43
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,
44
49
  };
45
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
+
46
67
  /**
47
68
  * Lexer for dice notation.
48
69
  *
@@ -95,6 +116,11 @@ export class Lexer {
95
116
  return this.scanIdentifier();
96
117
  }
97
118
 
119
+ // * Variable reference
120
+ if (char === '@') {
121
+ return this.scanAt();
122
+ }
123
+
98
124
  // * Operators and punctuation
99
125
  this.advance();
100
126
 
@@ -120,6 +146,10 @@ export class Lexer {
120
146
  return this.createTokenAt(TokenType.RPAREN, char, startPos);
121
147
  case ',':
122
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);
123
153
  case '>':
124
154
  if (this.match('=')) {
125
155
  return this.createTokenAt(TokenType.GREATER_EQUAL, '>=', startPos);
@@ -141,8 +171,13 @@ export class Lexer {
141
171
  return this.createTokenAt(TokenType.EXPLODE_PENETRATING, '!p', startPos);
142
172
  }
143
173
  return this.createTokenAt(TokenType.EXPLODE, char, startPos);
144
- default:
145
- throw new LexerError('Unexpected character', 'UNEXPECTED_CHARACTER', startPos, char);
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
+ }
146
181
  }
147
182
  }
148
183
 
@@ -215,7 +250,55 @@ export class Lexer {
215
250
  return this.createTokenAt(tokenType, lower, startPos);
216
251
  }
217
252
 
218
- throw new LexerError('Unexpected identifier', 'UNEXPECTED_IDENTIFIER', startPos, lower);
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);
219
302
  }
220
303
 
221
304
  private peek(): string {
@@ -250,16 +333,26 @@ export class Lexer {
250
333
  return c >= 'a' && c <= 'z';
251
334
  }
252
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
+
253
344
  private isWhitespace(char: string): boolean {
254
345
  return char === ' ' || char === '\t' || char === '\n' || char === '\r';
255
346
  }
256
347
 
348
+ // ? Both factories run after the token's characters were consumed, so
349
+ // `this.pos` is the exclusive end offset.
257
350
  private createToken(type: TokenType, value: string): Token {
258
- return { type, value, position: this.pos };
351
+ return { type, value, position: this.pos, end: this.pos };
259
352
  }
260
353
 
261
354
  private createTokenAt(type: TokenType, value: string, position: number): Token {
262
- return { type, value, position };
355
+ return { type, value, position, end: this.pos };
263
356
  }
264
357
  }
265
358
 
@@ -126,12 +126,46 @@ export enum TokenType {
126
126
  /** Versus operator: 'vs' */
127
127
  VS = 29,
128
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
+
129
163
  //
130
164
  // * End of input
131
165
  //
132
166
 
133
167
  /** End of input marker */
134
- EOF = 30,
168
+ EOF = 37,
135
169
  }
136
170
 
137
171
  /**
@@ -142,6 +176,12 @@ export type Token = {
142
176
  type: TokenType;
143
177
  /** The raw string value from input (lowercased for identifiers) */
144
178
  value: string;
145
- /** Zero-based position in the input string */
179
+ /** Zero-based start offset in the input string (UTF-16 code units) */
146
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;
147
187
  };