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.
- package/CHANGELOG.md +189 -0
- package/MIGRATION.md +147 -0
- package/README.md +985 -43
- package/dist/cli/args.d.ts +1 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +81 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/format.d.ts +16 -4
- package/dist/cli/format.d.ts.map +1 -1
- package/dist/cli/format.js +17 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +14 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/main.d.ts +36 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +83 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/errors.d.ts +332 -14
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +141 -0
- package/dist/errors.js.map +1 -0
- package/dist/evaluator/die.d.ts +26 -0
- package/dist/evaluator/die.d.ts.map +1 -0
- package/dist/evaluator/die.js +19 -0
- package/dist/evaluator/die.js.map +1 -0
- package/dist/evaluator/env.d.ts +58 -0
- package/dist/evaluator/env.d.ts.map +1 -0
- package/dist/evaluator/env.js +11 -0
- package/dist/evaluator/env.js.map +1 -0
- package/dist/evaluator/evaluator.d.ts +62 -40
- package/dist/evaluator/evaluator.d.ts.map +1 -1
- package/dist/evaluator/evaluator.js +906 -0
- package/dist/evaluator/evaluator.js.map +1 -0
- package/dist/evaluator/modifiers/compare.d.ts +1 -1
- package/dist/evaluator/modifiers/compare.d.ts.map +1 -1
- package/dist/evaluator/modifiers/compare.js +15 -0
- package/dist/evaluator/modifiers/compare.js.map +1 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts +27 -0
- package/dist/evaluator/modifiers/crit-threshold.d.ts.map +1 -0
- package/dist/evaluator/modifiers/crit-threshold.js +23 -0
- package/dist/evaluator/modifiers/crit-threshold.js.map +1 -0
- package/dist/evaluator/modifiers/die-bound.d.ts +26 -0
- package/dist/evaluator/modifiers/die-bound.d.ts.map +1 -0
- package/dist/evaluator/modifiers/die-bound.js +14 -0
- package/dist/evaluator/modifiers/die-bound.js.map +1 -0
- package/dist/evaluator/modifiers/explode.d.ts +18 -6
- package/dist/evaluator/modifiers/explode.d.ts.map +1 -1
- package/dist/evaluator/modifiers/explode.js +103 -0
- package/dist/evaluator/modifiers/explode.js.map +1 -0
- package/dist/evaluator/modifiers/flags.d.ts +37 -0
- package/dist/evaluator/modifiers/flags.d.ts.map +1 -0
- package/dist/evaluator/modifiers/flags.js +18 -0
- package/dist/evaluator/modifiers/flags.js.map +1 -0
- package/dist/evaluator/modifiers/keep-drop.d.ts +12 -28
- package/dist/evaluator/modifiers/keep-drop.d.ts.map +1 -1
- package/dist/evaluator/modifiers/keep-drop.js +82 -0
- package/dist/evaluator/modifiers/keep-drop.js.map +1 -0
- package/dist/evaluator/modifiers/reroll.d.ts +14 -6
- package/dist/evaluator/modifiers/reroll.d.ts.map +1 -1
- package/dist/evaluator/modifiers/reroll.js +62 -0
- package/dist/evaluator/modifiers/reroll.js.map +1 -0
- package/dist/evaluator/modifiers/sort.d.ts +27 -0
- package/dist/evaluator/modifiers/sort.d.ts.map +1 -0
- package/dist/evaluator/modifiers/sort.js +13 -0
- package/dist/evaluator/modifiers/sort.js.map +1 -0
- package/dist/evaluator/modifiers/success-count.d.ts +2 -6
- package/dist/evaluator/modifiers/success-count.d.ts.map +1 -1
- package/dist/evaluator/modifiers/success-count.js +24 -0
- package/dist/evaluator/modifiers/success-count.js.map +1 -0
- package/dist/index.d.ts +35 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -1723
- package/dist/index.js.map +1 -0
- package/dist/lexer/lexer.d.ts +68 -8
- package/dist/lexer/lexer.d.ts.map +1 -1
- package/dist/lexer/lexer.js +260 -0
- package/dist/lexer/lexer.js.map +1 -0
- package/dist/lexer/tokens.d.ts +52 -7
- package/dist/lexer/tokens.d.ts.map +1 -1
- package/dist/lexer/tokens.js +42 -0
- package/dist/lexer/tokens.js.map +1 -0
- package/dist/parser/ast.d.ts +419 -80
- package/dist/parser/ast.d.ts.map +1 -1
- package/dist/parser/ast.js +52 -0
- package/dist/parser/ast.js.map +1 -0
- package/dist/parser/guards.d.ts +106 -0
- package/dist/parser/guards.d.ts.map +1 -0
- package/dist/parser/guards.js +121 -0
- package/dist/parser/guards.js.map +1 -0
- package/dist/parser/parser.d.ts +162 -15
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/parser/parser.js +751 -0
- package/dist/parser/parser.js.map +1 -0
- package/dist/rng/mock.d.ts +74 -13
- package/dist/rng/mock.d.ts.map +1 -1
- package/dist/rng/mock.js +30 -0
- package/dist/rng/mock.js.map +1 -0
- package/dist/rng/seeded.d.ts +149 -10
- package/dist/rng/seeded.d.ts.map +1 -1
- package/dist/rng/seeded.js +138 -0
- package/dist/rng/seeded.js.map +1 -0
- package/dist/rng/types.d.ts +57 -0
- package/dist/rng/types.d.ts.map +1 -1
- package/dist/rng/types.js +2 -0
- package/dist/rng/types.js.map +1 -0
- package/dist/roll.d.ts +59 -25
- package/dist/roll.d.ts.map +1 -1
- package/dist/roll.js +8 -0
- package/dist/roll.js.map +1 -0
- package/dist/testing.d.ts +5 -4
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +2 -38
- package/dist/testing.js.map +1 -0
- package/dist/types.d.ts +427 -24
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/package.json +93 -40
- package/src/cli/args.ts +66 -9
- package/src/cli/format.ts +30 -7
- package/src/cli/index.ts +27 -67
- package/src/cli/main.ts +129 -0
- package/src/errors.ts +488 -23
- package/src/evaluator/die.ts +50 -0
- package/src/evaluator/env.ts +73 -0
- package/src/evaluator/evaluator.ts +1296 -349
- package/src/evaluator/modifiers/compare.ts +1 -1
- package/src/evaluator/modifiers/crit-threshold.ts +56 -0
- package/src/evaluator/modifiers/die-bound.ts +39 -0
- package/src/evaluator/modifiers/explode.ts +82 -76
- package/src/evaluator/modifiers/flags.ts +61 -0
- package/src/evaluator/modifiers/keep-drop.ts +124 -126
- package/src/evaluator/modifiers/reroll.ts +36 -64
- package/src/evaluator/modifiers/sort.ts +43 -0
- package/src/evaluator/modifiers/success-count.ts +6 -9
- package/src/index.ts +73 -34
- package/src/lexer/lexer.ts +201 -35
- package/src/lexer/tokens.ts +72 -7
- package/src/parser/ast.ts +453 -104
- package/src/parser/guards.ts +248 -0
- package/src/parser/parser.ts +835 -135
- package/src/rng/mock.ts +75 -14
- package/src/rng/seeded.ts +323 -58
- package/src/rng/types.ts +57 -0
- package/src/roll.ts +66 -41
- package/src/testing.ts +5 -9
- package/src/types.ts +416 -24
- package/src/version.ts +2 -0
- package/dist/cli.js +0 -1775
- package/dist/evaluator/index.d.ts +0 -8
- package/dist/evaluator/index.d.ts.map +0 -1
- package/dist/index.mjs +0 -1724
- package/dist/rng/index.d.ts +0 -8
- package/dist/rng/index.d.ts.map +0 -1
- package/dist/testing.mjs +0 -39
- package/src/evaluator/index.ts +0 -14
- package/src/rng/index.ts +0 -8
package/src/lexer/lexer.ts
CHANGED
|
@@ -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(
|
|
19
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
245
|
+
this.pos++;
|
|
164
246
|
}
|
|
165
247
|
|
|
166
|
-
// Decimal part
|
|
167
248
|
if (!this.isAtEnd() && this.peek() === '.' && this.isDigit(this.peekNext())) {
|
|
168
|
-
|
|
249
|
+
this.pos++;
|
|
169
250
|
while (!this.isAtEnd() && this.isDigit(this.peek())) {
|
|
170
|
-
|
|
251
|
+
this.pos++;
|
|
171
252
|
}
|
|
172
253
|
}
|
|
173
254
|
|
|
174
|
-
return this.createTokenAt(TokenType.NUMBER,
|
|
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.
|
|
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
|
-
|
|
281
|
+
this.pos++;
|
|
204
282
|
}
|
|
205
283
|
|
|
206
|
-
const lower =
|
|
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(
|
|
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
|
-
|
|
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
|
|
250
|
-
return
|
|
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
|
-
*
|
|
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
|
|
271
|
-
*
|
|
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
|
-
* //
|
|
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
|
}
|
package/src/lexer/tokens.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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 =
|
|
174
|
+
EOF = 37,
|
|
135
175
|
}
|
|
136
176
|
|
|
137
177
|
/**
|
|
138
|
-
* A token produced by the
|
|
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
|
|
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
|
};
|