forma-dsl 0.1.1

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.
@@ -0,0 +1,359 @@
1
+ /** @import { SourceLocation, Token, TokenType } from '../index.js' */
2
+
3
+ /**
4
+ * Every error the language raises, carrying the position it was raised at when there is one.
5
+ *
6
+ * The position is appended to the message as well as kept on the error, so a caller that
7
+ * only prints `error.message` still tells the reader where to look.
8
+ */
9
+ export class FormaError extends Error {
10
+ /**
11
+ * @param {string} message What went wrong.
12
+ * @param {SourceLocation | null} [loc] Where it went wrong.
13
+ */
14
+ constructor(message, loc) {
15
+ super(loc ? `${message} (line ${loc.line}, column ${loc.column})` : message);
16
+ this.name = 'FormaError';
17
+ /** @type {SourceLocation | null} Where the error was raised, if known. */
18
+ this.loc = loc ?? null;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Punctuation, longest first.
24
+ *
25
+ * The order is load-bearing: matching is first-wins, so `==` has to be tried before `=`, or
26
+ * every comparison would lex as two assignments.
27
+ */
28
+ const PUNCTUATION = [
29
+ '==', '!=', '<=', '>=', '&&', '||', '=>', '...',
30
+ '{', '}', '[', ']', '(', ')', ',', '.', '=', '<', '>',
31
+ '+', '-', '*', '/', '%', '!', '?', ':',
32
+ ];
33
+
34
+ /** Escapes recognised inside a string. `\$` is here so a literal `${` can be written. */
35
+ const ESCAPES = { n: '\n', t: '\t', r: '\r', '"': '"', '\\': '\\', $: '$' };
36
+
37
+ /**
38
+ * @param {string} c A single character.
39
+ * @returns {boolean} Whether a name may begin with it.
40
+ */
41
+ const isIdentStart = (c) => /[A-Za-z_]/.test(c);
42
+
43
+ /**
44
+ * @param {string} c A single character.
45
+ * @returns {boolean} Whether a name may continue with it. Hyphens are allowed, so
46
+ * `min-width` is one name rather than a subtraction.
47
+ */
48
+ const isIdentPart = (c) => /[A-Za-z0-9_-]/.test(c);
49
+
50
+ /**
51
+ * @param {string} c A single character.
52
+ * @returns {boolean} Whether it is a decimal digit.
53
+ */
54
+ const isDigit = (c) => c >= '0' && c <= '9';
55
+
56
+ /**
57
+ * @param {string} c A single character.
58
+ * @returns {boolean} Whether it is whitespace that carries no meaning. A newline does carry
59
+ * meaning — it ends an attribute — so it is deliberately not here.
60
+ */
61
+ const isSpace = (c) => c === ' ' || c === '\t' || c === '\r';
62
+
63
+ /**
64
+ * Walks the source once, keeping the cursor and the accumulated tokens together.
65
+ *
66
+ * A class rather than one long function because the four scanners below — comments,
67
+ * strings, numbers, names — all need the same mutable cursor, and threading it through
68
+ * arguments made each of them harder to read than the grammar it implements.
69
+ */
70
+ class Lexer {
71
+ /**
72
+ * @param {string} source The document.
73
+ */
74
+ constructor(source) {
75
+ /** @type {string} The document being scanned. */
76
+ this.source = source;
77
+ /** @type {Token[]} Tokens produced so far. */
78
+ this.tokens = [];
79
+ /** @type {number} Offset of the next character. */
80
+ this.i = 0;
81
+ /** @type {number} Line of the next character, counting from 1. */
82
+ this.line = 1;
83
+ /** @type {number} Column of the next character, counting from 1. */
84
+ this.column = 1;
85
+ /** @type {boolean} Whether a line break has been passed since the last token. */
86
+ this.sawNewline = false;
87
+ }
88
+
89
+ /** @returns {SourceLocation} Where the cursor is. */
90
+ here() {
91
+ return { line: this.line, column: this.column, offset: this.i };
92
+ }
93
+
94
+ /**
95
+ * @param {number} [n] How many characters to consume.
96
+ * @returns {void}
97
+ */
98
+ advance(n = 1) {
99
+ for (let k = 0; k < n; k++) {
100
+ if (this.source[this.i] === '\n') { this.line++; this.column = 1; } else { this.column++; }
101
+ this.i++;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * @param {number} [offset] How far ahead to look.
107
+ * @returns {string | undefined} The character there, or undefined past the end.
108
+ */
109
+ peek(offset = 0) {
110
+ return this.source[this.i + offset];
111
+ }
112
+
113
+ /** @returns {boolean} Whether the whole document has been consumed. */
114
+ get done() {
115
+ return this.i >= this.source.length;
116
+ }
117
+
118
+ /**
119
+ * Records a token and clears the pending line break, which belongs to this token only.
120
+ *
121
+ * @param {TokenType} type What kind of token.
122
+ * @param {unknown} value Its payload.
123
+ * @param {SourceLocation} loc Where it started.
124
+ * @returns {void}
125
+ */
126
+ push(type, value, loc) {
127
+ this.tokens.push({ type, value, loc, nlBefore: this.sawNewline });
128
+ this.sawNewline = false;
129
+ }
130
+
131
+ /**
132
+ * Turns the whole document into tokens.
133
+ *
134
+ * @returns {Token[]} The tokens, ending with an `eof`.
135
+ * @throws {FormaError} On any character that cannot begin a token.
136
+ */
137
+ run() {
138
+ while (!this.done) this.step();
139
+
140
+ this.tokens.push({ type: 'eof', value: null, loc: this.here(), nlBefore: this.sawNewline });
141
+ return this.tokens;
142
+ }
143
+
144
+ /**
145
+ * Consumes whatever begins at the cursor, in the one order the grammar allows: a comment
146
+ * before the division it starts with, a number before the name a bare `e` would become.
147
+ *
148
+ * @returns {void}
149
+ * @throws {FormaError} On a character that cannot begin a token.
150
+ */
151
+ step() {
152
+ const c = this.peek();
153
+
154
+ if (c === '\n') { this.sawNewline = true; this.advance(); return; }
155
+ if (isSpace(c)) { this.advance(); return; }
156
+
157
+ if (this.startsLineComment(c)) { this.skipLineComment(); return; }
158
+ if (c === '/' && this.peek(1) === '*') { this.skipBlockComment(); return; }
159
+
160
+ if (c === '"') { this.readString(); return; }
161
+ if (this.startsNumber(c)) { this.readNumber(); return; }
162
+ if (isIdentStart(c)) { this.readIdentifier(); return; }
163
+ if (this.readPunctuation()) return;
164
+
165
+ throw new FormaError(`unexpected character ${JSON.stringify(c)}`, this.here());
166
+ }
167
+
168
+ /**
169
+ * @param {string | undefined} c The character at the cursor.
170
+ * @returns {boolean} Whether a line comment starts here, in either spelling.
171
+ */
172
+ startsLineComment(c) {
173
+ return c === '#' || (c === '/' && this.peek(1) === '/');
174
+ }
175
+
176
+ /**
177
+ * @param {string | undefined} c The character at the cursor.
178
+ * @returns {boolean} Whether a number starts here. A leading `.` counts only when a digit
179
+ * follows it, so `.5` is a number and `.x` is punctuation.
180
+ */
181
+ startsNumber(c) {
182
+ return isDigit(c) || (c === '.' && isDigit(this.peek(1)));
183
+ }
184
+
185
+ /** @returns {void} */
186
+ skipLineComment() {
187
+ while (!this.done && this.peek() !== '\n') this.advance();
188
+ }
189
+
190
+ /**
191
+ * @returns {void}
192
+ * @throws {FormaError} If the comment is never closed.
193
+ */
194
+ skipBlockComment() {
195
+ const start = this.here();
196
+ this.advance(2);
197
+ while (!this.done && !(this.peek() === '*' && this.peek(1) === '/')) this.advance();
198
+ if (this.done) throw new FormaError('unterminated block comment', start);
199
+ this.advance(2);
200
+ }
201
+
202
+ /**
203
+ * Reads a `"..."` string into its literal runs and its `${...}` splices.
204
+ *
205
+ * The spliced expressions are kept as raw source; the parser re-enters itself on them,
206
+ * rather than this having to know the expression grammar.
207
+ *
208
+ * @returns {void}
209
+ * @throws {FormaError} On an unknown escape, a newline inside the string, or no closing quote.
210
+ */
211
+ readString() {
212
+ const loc = this.here();
213
+ this.advance();
214
+
215
+ /** @type {Array<{ kind: 'text', value: string } | { kind: 'expr', source: string, loc: SourceLocation }>} */
216
+ const parts = [];
217
+ let text = '';
218
+
219
+ while (!this.done && this.peek() !== '"') {
220
+ if (this.peek() === '\\') {
221
+ text += this.readEscape();
222
+ continue;
223
+ }
224
+
225
+ if (this.peek() === '$' && this.peek(1) === '{') {
226
+ if (text) { parts.push({ kind: 'text', value: text }); text = ''; }
227
+ parts.push(this.readSplice(loc));
228
+ continue;
229
+ }
230
+
231
+ if (this.peek() === '\n') throw new FormaError('unterminated string', loc);
232
+ text += this.peek();
233
+ this.advance();
234
+ }
235
+
236
+ if (this.done) throw new FormaError('unterminated string', loc);
237
+ this.advance();
238
+
239
+ // An empty string still needs one part, or it would carry no value at all.
240
+ if (text || parts.length === 0) parts.push({ kind: 'text', value: text });
241
+ this.push('string', parts, loc);
242
+ }
243
+
244
+ /**
245
+ * Reads one backslash escape and returns what it stands for.
246
+ *
247
+ * @returns {string} The character the escape denotes.
248
+ * @throws {FormaError} On an escape that is not one of the six the language defines.
249
+ */
250
+ readEscape() {
251
+ const next = this.peek(1);
252
+ if (!(next in ESCAPES)) throw new FormaError(`unknown escape \\${next}`, this.here());
253
+ this.advance(2);
254
+ return ESCAPES[next];
255
+ }
256
+
257
+ /**
258
+ * Reads one `${...}`, counting braces so a nested object literal does not end it early.
259
+ *
260
+ * @param {SourceLocation} loc Where the enclosing string began, for the error.
261
+ * @returns {{ kind: 'expr', source: string, loc: SourceLocation }} The spliced source.
262
+ * @throws {FormaError} If the splice is never closed.
263
+ */
264
+ readSplice(loc) {
265
+ this.advance(2);
266
+ const start = this.i;
267
+ let depth = 1;
268
+ while (!this.done && depth > 0) {
269
+ if (this.peek() === '{') depth++;
270
+ else if (this.peek() === '}') depth--;
271
+ if (depth > 0) this.advance();
272
+ }
273
+ if (depth !== 0) throw new FormaError('unterminated ${ } in string', loc);
274
+ const source = this.source.slice(start, this.i);
275
+ this.advance();
276
+ return { kind: 'expr', source, loc: { ...loc } };
277
+ }
278
+
279
+ /**
280
+ * Reads a decimal number, with an optional fraction and exponent.
281
+ *
282
+ * @returns {void}
283
+ */
284
+ readNumber() {
285
+ const loc = this.here();
286
+ const start = this.i;
287
+
288
+ this.readDigits();
289
+ if (this.peek() === '.' && isDigit(this.peek(1))) {
290
+ this.advance();
291
+ this.readDigits();
292
+ }
293
+ this.readExponent();
294
+
295
+ this.push('number', Number(this.source.slice(start, this.i)), loc);
296
+ }
297
+
298
+ /**
299
+ * Consumes a run of digits, which may be empty.
300
+ *
301
+ * @returns {void}
302
+ */
303
+ readDigits() {
304
+ while (!this.done && isDigit(this.peek())) this.advance();
305
+ }
306
+
307
+ /**
308
+ * Consumes an exponent if there is a complete one.
309
+ *
310
+ * `2e` and `2emm` are not numbers with exponents, so a partial match rewinds and lets the
311
+ * `e` start a name instead.
312
+ *
313
+ * @returns {void}
314
+ */
315
+ readExponent() {
316
+ if (this.peek() !== 'e' && this.peek() !== 'E') return;
317
+
318
+ const save = this.i;
319
+ this.advance();
320
+ if (this.peek() === '+' || this.peek() === '-') this.advance();
321
+
322
+ if (isDigit(this.peek())) this.readDigits();
323
+ else this.i = save;
324
+ }
325
+
326
+ /** @returns {void} */
327
+ readIdentifier() {
328
+ const loc = this.here();
329
+ const start = this.i;
330
+ while (!this.done && isIdentPart(this.peek())) this.advance();
331
+ this.push('ident', this.source.slice(start, this.i), loc);
332
+ }
333
+
334
+ /** @returns {boolean} Whether punctuation was found and consumed. */
335
+ readPunctuation() {
336
+ const punct = PUNCTUATION.find((p) => this.source.startsWith(p, this.i));
337
+ if (!punct) return false;
338
+ const loc = this.here();
339
+ this.advance(punct.length);
340
+ this.push('punct', punct, loc);
341
+ return true;
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Turns source into tokens.
347
+ *
348
+ * Each token records whether a line break preceded it. The parser needs that to know an
349
+ * attribute has ended: `size = [1,2]` on one line and `radius = 3` on the next are two
350
+ * attributes, but `radius = 3 +\n 4` is one expression, and nothing else distinguishes the
351
+ * leading `-` of a new attribute value from a subtraction.
352
+ *
353
+ * @param {string} source The document.
354
+ * @returns {Token[]} The tokens, ending with an `eof`.
355
+ * @throws {FormaError} On anything that cannot be tokenised.
356
+ */
357
+ export function tokenize(source) {
358
+ return new Lexer(source).run();
359
+ }