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,522 @@
1
+ /** @import { SourceLocation, Token, TokenType } from '../index.js' */
2
+ /** @import { Attribute, Block, Body, Declaration, Document, Expression, ForBlock, IfBlock, LocalDeclaration, ParamDeclaration } from './ast.js' */
3
+
4
+ import { tokenize, FormaError } from './lexer.js';
5
+
6
+ /** Names that introduce a construct, and so cannot also name a block. */
7
+ const KEYWORDS = new Set(['param', 'local', 'component', 'model', 'for', 'if', 'else', 'in', 'true', 'false', 'null']);
8
+
9
+ /** Binding power per infix operator; higher binds tighter. */
10
+ const BINARY_PRECEDENCE = {
11
+ '||': 1,
12
+ '&&': 2,
13
+ '==': 3, '!=': 3,
14
+ '<': 4, '<=': 4, '>': 4, '>=': 4,
15
+ '+': 5, '-': 5,
16
+ '*': 6, '/': 6, '%': 6,
17
+ };
18
+
19
+ /**
20
+ * A recursive-descent parser over the token stream.
21
+ *
22
+ * The one unusual rule is that a line break ends an attribute, but only outside brackets.
23
+ * {@link Parser#breaksLine} is where that is decided, and `depth` is what makes it apply
24
+ * only at the top level of an expression.
25
+ */
26
+ class Parser {
27
+ /**
28
+ * @param {string} source The document, or one spliced expression from inside a string.
29
+ */
30
+ constructor(source) {
31
+ /** @type {Token[]} The token stream. */
32
+ this.tokens = tokenize(source);
33
+ /** @type {number} Index of the next token. */
34
+ this.pos = 0;
35
+ /**
36
+ * @type {number} Depth of enclosing brackets. The newline rule that ends an attribute
37
+ * applies only at depth 0, so an array or a call can still span lines.
38
+ */
39
+ this.depth = 0;
40
+ }
41
+
42
+ /** @returns {Token} The token about to be read. */
43
+ get current() { return this.tokens[this.pos]; }
44
+
45
+ /**
46
+ * @param {number} [n] How far ahead to look.
47
+ * @returns {Token} The token there.
48
+ */
49
+ peek(n = 1) { return this.tokens[this.pos + n]; }
50
+
51
+ /**
52
+ * @param {TokenType} type The kind to match.
53
+ * @param {unknown} [value] The exact value to match, when it matters.
54
+ * @returns {boolean} Whether the current token matches.
55
+ */
56
+ is(type, value) {
57
+ const t = this.current;
58
+ return t.type === type && (value === undefined || t.value === value);
59
+ }
60
+
61
+ /**
62
+ * @param {string} value The punctuation to match.
63
+ * @returns {boolean} Whether the current token is that punctuation.
64
+ */
65
+ isPunct(value) { return this.is('punct', value); }
66
+
67
+ /**
68
+ * @param {string} value The name to match.
69
+ * @returns {boolean} Whether the current token is that name.
70
+ */
71
+ isIdent(value) { return this.is('ident', value); }
72
+
73
+ /** @returns {Token} The current token, having moved past it. */
74
+ next() { return this.tokens[this.pos++]; }
75
+
76
+ /**
77
+ * @param {TokenType} type The kind required.
78
+ * @param {unknown} [value] The exact value required, when it matters.
79
+ * @returns {Token} The consumed token.
80
+ * @throws {FormaError} If the current token is not what was required.
81
+ */
82
+ expect(type, value) {
83
+ if (!this.is(type, value)) {
84
+ const got = this.current.type === 'eof' ? 'end of file' : JSON.stringify(this.current.value);
85
+ throw new FormaError(`expected ${value ?? type}, found ${got}`, this.current.loc);
86
+ }
87
+ return this.next();
88
+ }
89
+
90
+ // --- document -------------------------------------------------------------------
91
+
92
+ /**
93
+ * @returns {Document} The whole document.
94
+ * @throws {FormaError} On anything that is not a top-level block.
95
+ */
96
+ parseDocument() {
97
+ const declarations = [];
98
+ while (!this.is('eof')) declarations.push(this.parseDeclaration());
99
+ return { kind: 'document', declarations };
100
+ }
101
+
102
+ /**
103
+ * @returns {Declaration} One top-level block.
104
+ * @throws {FormaError} If the block type is not one of the four that may appear here.
105
+ */
106
+ parseDeclaration() {
107
+ const t = this.current;
108
+ if (t.type !== 'ident') {
109
+ throw new FormaError(`expected a block, found ${JSON.stringify(t.value)}`, t.loc);
110
+ }
111
+ switch (t.value) {
112
+ case 'param': return this.parseParam();
113
+ case 'local': return this.parseLocal();
114
+ case 'component':
115
+ case 'model': {
116
+ this.next();
117
+ const name = this.parseLabel();
118
+ const body = this.parseBracedBody();
119
+ return { kind: t.value, name, body, loc: t.loc };
120
+ }
121
+ default:
122
+ throw new FormaError(`unknown top-level block "${t.value}"`, t.loc);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Reads a static name, quoted or bare.
128
+ *
129
+ * @returns {string} The name.
130
+ * @throws {FormaError} If the label is interpolated, which a declaration name cannot be:
131
+ * a name that depended on a value could not be resolved before evaluating it.
132
+ */
133
+ parseLabel() {
134
+ if (this.is('string')) {
135
+ const token = this.next();
136
+ const parts = token.value;
137
+ if (parts.length !== 1 || parts[0].kind !== 'text') {
138
+ throw new FormaError('a label cannot contain ${ }', token.loc);
139
+ }
140
+ return parts[0].value;
141
+ }
142
+ return this.expect('ident').value;
143
+ }
144
+
145
+ /**
146
+ * Reads a param declaration.
147
+ *
148
+ * `param name = expr` and `param name { ... }` are the same declaration; the braced form
149
+ * carries the metadata the editor needs to render a control.
150
+ *
151
+ * @returns {ParamDeclaration} The declaration.
152
+ * @throws {FormaError} If the braced form contains nested blocks.
153
+ */
154
+ parseParam() {
155
+ const loc = this.expect('ident', 'param').loc;
156
+ const name = this.parseLabel();
157
+ if (this.isPunct('=')) {
158
+ this.next();
159
+ return { kind: 'param', name, attributes: [{ name: 'default', value: this.parseExpression() }], loc };
160
+ }
161
+ const body = this.parseBracedBody();
162
+ if (body.blocks.length) {
163
+ throw new FormaError('a param block holds attributes, not nested blocks', loc);
164
+ }
165
+ return { kind: 'param', name, attributes: body.attributes, loc };
166
+ }
167
+
168
+ /**
169
+ * @returns {LocalDeclaration} The declaration.
170
+ */
171
+ parseLocal() {
172
+ const loc = this.expect('ident', 'local').loc;
173
+ const name = this.parseLabel();
174
+ this.expect('punct', '=');
175
+ return { kind: 'local', name, value: this.parseExpression(), loc };
176
+ }
177
+
178
+ // --- bodies ---------------------------------------------------------------------
179
+
180
+ /**
181
+ * @returns {Body} The contents of a `{ ... }`.
182
+ */
183
+ parseBracedBody() {
184
+ this.expect('punct', '{');
185
+ const body = this.parseBody();
186
+ this.expect('punct', '}');
187
+ return body;
188
+ }
189
+
190
+ /**
191
+ * Reads entries until the closing brace, sorting each into attributes, nested blocks,
192
+ * params or locals.
193
+ *
194
+ * @returns {Body} The contents.
195
+ * @throws {FormaError} On an entry that does not begin with a name.
196
+ */
197
+ parseBody() {
198
+ /** @type {Body} */
199
+ const body = { attributes: [], blocks: [], params: [], locals: [] };
200
+
201
+ while (!this.isPunct('}') && !this.is('eof')) {
202
+ const t = this.current;
203
+
204
+ if (t.type !== 'ident') {
205
+ throw new FormaError(`expected an attribute or block, found ${JSON.stringify(t.value)}`, t.loc);
206
+ }
207
+
208
+ if (t.value === 'param') { body.params.push(this.parseParam()); continue; }
209
+ if (t.value === 'local') { body.locals.push(this.parseLocal()); continue; }
210
+ if (t.value === 'for') { body.blocks.push(this.parseFor()); continue; }
211
+ if (t.value === 'if') { body.blocks.push(this.parseIf()); continue; }
212
+
213
+ // `name = expr` is an attribute; anything else that starts with an identifier is a
214
+ // nested block, which is how the CSG tree is written.
215
+ if (this.peek().type === 'punct' && this.peek().value === '=') {
216
+ const name = this.next().value;
217
+ this.next();
218
+ /** @type {Attribute} */
219
+ const attribute = { name, value: this.parseExpression(), loc: t.loc };
220
+ body.attributes.push(attribute);
221
+ continue;
222
+ }
223
+
224
+ body.blocks.push(this.parseBlock());
225
+ }
226
+
227
+ return body;
228
+ }
229
+
230
+ /**
231
+ * Reads a nested block and its labels.
232
+ *
233
+ * A block label is an expression, not a fixed string, so a part built inside a loop can
234
+ * name itself from the loop variable.
235
+ *
236
+ * @returns {Block} The block.
237
+ * @throws {FormaError} If the block type is a keyword.
238
+ */
239
+ parseBlock() {
240
+ const token = this.expect('ident');
241
+ if (KEYWORDS.has(token.value)) {
242
+ throw new FormaError(`"${token.value}" cannot be used as a block type`, token.loc);
243
+ }
244
+ const labels = [];
245
+ while (this.is('string') || this.is('ident')) {
246
+ labels.push(this.parsePrimary());
247
+ }
248
+ const body = this.parseBracedBody();
249
+ return { kind: 'block', type: token.value, labels, body, loc: token.loc };
250
+ }
251
+
252
+ /**
253
+ * @returns {ForBlock} The loop.
254
+ */
255
+ parseFor() {
256
+ const loc = this.expect('ident', 'for').loc;
257
+ const names = [this.expect('ident').value];
258
+ if (this.isPunct(',')) { this.next(); names.push(this.expect('ident').value); }
259
+ this.expect('ident', 'in');
260
+ const sequence = this.parseExpression();
261
+ const body = this.parseBracedBody();
262
+ return { kind: 'for', names, sequence, body, loc };
263
+ }
264
+
265
+ /**
266
+ * Reads a conditional block. `else if` is nested rather than flattened, so the chain is
267
+ * one shape all the way down.
268
+ *
269
+ * @returns {IfBlock} The conditional.
270
+ */
271
+ parseIf() {
272
+ const loc = this.expect('ident', 'if').loc;
273
+ const condition = this.parseExpression();
274
+ const then = this.parseBracedBody();
275
+ let otherwise = null;
276
+ if (this.isIdent('else')) {
277
+ this.next();
278
+ if (this.isIdent('if')) {
279
+ otherwise = { attributes: [], params: [], locals: [], blocks: [this.parseIf()] };
280
+ } else {
281
+ otherwise = this.parseBracedBody();
282
+ }
283
+ }
284
+ return { kind: 'if', condition, then, otherwise, loc };
285
+ }
286
+
287
+ // --- expressions ----------------------------------------------------------------
288
+
289
+ /**
290
+ * @returns {Expression} One expression.
291
+ */
292
+ parseExpression() { return this.parseConditional(); }
293
+
294
+ /**
295
+ * @returns {Expression} A `? :` expression, or whatever was there instead.
296
+ */
297
+ parseConditional() {
298
+ const condition = this.parseBinary(0);
299
+ if (this.isPunct('?') && !this.breaksLine()) {
300
+ this.next();
301
+ const then = this.parseExpression();
302
+ this.expect('punct', ':');
303
+ const otherwise = this.parseExpression();
304
+ return { kind: 'conditional', condition, then, otherwise };
305
+ }
306
+ return condition;
307
+ }
308
+
309
+ /**
310
+ * @returns {boolean} Whether the current token starts a new line outside any bracket —
311
+ * the signal that the expression is finished and the next attribute has begun.
312
+ */
313
+ breaksLine() {
314
+ return this.depth === 0 && this.current.nlBefore;
315
+ }
316
+
317
+ /**
318
+ * Precedence climbing over the infix operators.
319
+ *
320
+ * @param {number} minPrecedence The lowest binding power this call will accept.
321
+ * @returns {Expression} The expression.
322
+ */
323
+ parseBinary(minPrecedence) {
324
+ let left = this.parseUnary();
325
+ for (;;) {
326
+ const t = this.current;
327
+ if (t.type !== 'punct') break;
328
+ const precedence = BINARY_PRECEDENCE[t.value];
329
+ if (precedence === undefined || precedence < minPrecedence) break;
330
+ if (this.breaksLine()) break;
331
+ this.next();
332
+ const right = this.parseBinary(precedence + 1);
333
+ left = { kind: 'binary', op: t.value, left, right, loc: t.loc };
334
+ }
335
+ return left;
336
+ }
337
+
338
+ /**
339
+ * @returns {Expression} A prefixed expression, or whatever was there instead.
340
+ */
341
+ parseUnary() {
342
+ if (this.isPunct('-') || this.isPunct('!')) {
343
+ const t = this.next();
344
+ return { kind: 'unary', op: t.value, operand: this.parseUnary(), loc: t.loc };
345
+ }
346
+ return this.parsePostfix();
347
+ }
348
+
349
+ /**
350
+ * Reads the suffixes — member access, indexing and calls — that bind tightest.
351
+ *
352
+ * @returns {Expression} The expression.
353
+ */
354
+ parsePostfix() {
355
+ let target = this.parsePrimary();
356
+ for (;;) {
357
+ if (this.isPunct('.') && !this.breaksLine()) {
358
+ this.next();
359
+ const property = this.expect('ident').value;
360
+ target = { kind: 'member', object: target, property };
361
+ } else if (this.isPunct('[') && !this.breaksLine()) {
362
+ this.next();
363
+ this.depth++;
364
+ const index = this.parseExpression();
365
+ this.depth--;
366
+ this.expect('punct', ']');
367
+ target = { kind: 'index', object: target, index };
368
+ } else if (this.isPunct('(') && !this.breaksLine()) {
369
+ target = { kind: 'call', callee: target, args: this.parseArguments(), loc: this.current.loc };
370
+ } else {
371
+ return target;
372
+ }
373
+ }
374
+ }
375
+
376
+ /**
377
+ * @returns {Array<{ name: string | null, value: Expression }>} The argument list.
378
+ */
379
+ parseArguments() {
380
+ this.expect('punct', '(');
381
+ this.depth++;
382
+ const args = [];
383
+ while (!this.isPunct(')')) {
384
+ if (this.is('ident') && this.peek().type === 'punct' && this.peek().value === '=') {
385
+ const name = this.next().value;
386
+ this.next();
387
+ args.push({ name, value: this.parseExpression() });
388
+ } else {
389
+ args.push({ name: null, value: this.parseExpression() });
390
+ }
391
+ if (this.isPunct(',')) this.next(); else break;
392
+ }
393
+ this.depth--;
394
+ this.expect('punct', ')');
395
+ return args;
396
+ }
397
+
398
+ /**
399
+ * Reads a literal, a name, or a bracketed expression.
400
+ *
401
+ * @returns {Expression} The expression.
402
+ * @throws {FormaError} If the token cannot begin one.
403
+ */
404
+ parsePrimary() {
405
+ const t = this.current;
406
+
407
+ if (t.type === 'number') { this.next(); return { kind: 'literal', value: t.value }; }
408
+ if (t.type === 'string') { this.next(); return stringExpression(t); }
409
+
410
+ if (t.type === 'ident') {
411
+ this.next();
412
+ if (t.value === 'true') return { kind: 'literal', value: true };
413
+ if (t.value === 'false') return { kind: 'literal', value: false };
414
+ if (t.value === 'null') return { kind: 'literal', value: null };
415
+ return { kind: 'identifier', name: t.value, loc: t.loc };
416
+ }
417
+
418
+ if (this.isPunct('(')) {
419
+ this.next();
420
+ this.depth++;
421
+ const inner = this.parseExpression();
422
+ this.depth--;
423
+ this.expect('punct', ')');
424
+ return inner;
425
+ }
426
+
427
+ if (this.isPunct('[')) return this.parseArrayLiteral(t.loc);
428
+ if (this.isPunct('{')) return this.parseObjectLiteral(t.loc);
429
+
430
+ throw new FormaError(`unexpected ${JSON.stringify(t.value ?? 'end of file')}`, t.loc);
431
+ }
432
+
433
+ /**
434
+ * @param {SourceLocation} loc Where the bracket was.
435
+ * @returns {Expression} The list.
436
+ */
437
+ parseArrayLiteral(loc) {
438
+ this.next();
439
+ this.depth++;
440
+ const items = [];
441
+ while (!this.isPunct(']')) {
442
+ items.push(this.parseExpression());
443
+ if (this.isPunct(',')) this.next(); else break;
444
+ }
445
+ this.depth--;
446
+ this.expect('punct', ']');
447
+ return { kind: 'array', items, loc };
448
+ }
449
+
450
+ /**
451
+ * @param {SourceLocation} loc Where the brace was.
452
+ * @returns {Expression} The object.
453
+ * @throws {FormaError} If an entry has no `=` or `:` between key and value.
454
+ */
455
+ parseObjectLiteral(loc) {
456
+ this.next();
457
+ this.depth++;
458
+ const entries = [];
459
+ while (!this.isPunct('}')) {
460
+ const key = this.is('string') ? this.parseLabel() : this.expect('ident').value;
461
+ if (this.isPunct('=') || this.isPunct(':')) this.next();
462
+ else throw new FormaError('expected = or : in an object', this.current.loc);
463
+ entries.push({ key, value: this.parseExpression() });
464
+ if (this.isPunct(',')) this.next();
465
+ }
466
+ this.depth--;
467
+ this.expect('punct', '}');
468
+ return { kind: 'object', entries, loc };
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Turns a string token into an expression, collapsing the common case of a string with no
474
+ * splices in it down to a plain literal.
475
+ *
476
+ * @param {Token} token A `string` token.
477
+ * @returns {Expression} The literal or template.
478
+ */
479
+ function stringExpression(token) {
480
+ const parts = token.value.map((part) => (part.kind === 'text'
481
+ ? { kind: 'text', value: part.value }
482
+ : { kind: 'expr', expression: parseExpressionSource(part.source, part.loc) }));
483
+
484
+ if (parts.length === 1 && parts[0].kind === 'text') {
485
+ return { kind: 'literal', value: parts[0].value };
486
+ }
487
+ return { kind: 'template', parts, loc: token.loc };
488
+ }
489
+
490
+ /**
491
+ * Parses one `${...}` splice, which the lexer kept as raw source.
492
+ *
493
+ * @param {string} source The spliced expression.
494
+ * @param {SourceLocation} loc Where the enclosing string began.
495
+ * @returns {Expression} The expression.
496
+ * @throws {FormaError} If the splice is not exactly one expression. Errors raised inside it
497
+ * are given the string's position, since the splice has no line of its own.
498
+ */
499
+ function parseExpressionSource(source, loc) {
500
+ try {
501
+ const parser = new Parser(source);
502
+ const expression = parser.parseExpression();
503
+ if (!parser.is('eof')) throw new FormaError('trailing input in ${ }', loc);
504
+ return expression;
505
+ } catch (error) {
506
+ if (error instanceof FormaError && !error.loc) error.loc = loc;
507
+ throw error;
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Parses a document into an AST.
513
+ *
514
+ * @param {string} source The document.
515
+ * @returns {Document} The parsed document.
516
+ * @throws {FormaError} On any syntax error, carrying the position.
517
+ */
518
+ export function parse(source) {
519
+ return new Parser(source).parseDocument();
520
+ }
521
+
522
+ export { FormaError };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * An angle, stored in radians and read in whichever unit the caller wants.
3
+ *
4
+ * The kernel takes degrees and the trigonometry takes radians, so an unwrapped number would
5
+ * be ambiguous at exactly the call sites where getting it wrong produces a model that is
6
+ * subtly wrong rather than an error.
7
+ *
8
+ * Note that the unit names appear twice, as static factories and as instance accessors:
9
+ * `Angle.degrees(90)` builds one, `angle.degrees` reads one back. They are different
10
+ * namespaces and do not collide.
11
+ */
12
+ export class Angle {
13
+ /**
14
+ * Prefer the named factories; this takes radians, which is rarely the unit at hand.
15
+ *
16
+ * @param {number} radians The angle in radians.
17
+ */
18
+ constructor(radians) {
19
+ /** @type {number} The angle in radians. */
20
+ this.radians = Number(radians);
21
+ Object.freeze(this);
22
+ }
23
+
24
+ /**
25
+ * @param {number} value An angle in degrees.
26
+ * @returns {Angle} The angle.
27
+ */
28
+ static degrees(value) { return new Angle((Number(value) * Math.PI) / 180); }
29
+
30
+ /**
31
+ * @param {number} value An angle in radians.
32
+ * @returns {Angle} The angle.
33
+ */
34
+ static radians(value) { return new Angle(value); }
35
+
36
+ /**
37
+ * @param {number} value A number of full turns, so `0.25` is a right angle.
38
+ * @returns {Angle} The angle.
39
+ */
40
+ static turns(value) { return new Angle(Number(value) * 2 * Math.PI); }
41
+
42
+ /**
43
+ * Accepts an Angle or a bare number, which is read as degrees — the unit a person writing
44
+ * a model thinks in.
45
+ *
46
+ * @param {Angle | number} value An angle, or a number of degrees.
47
+ * @returns {Angle} The angle.
48
+ * @throws {TypeError} If the value is neither an Angle nor a number.
49
+ */
50
+ static of(value) {
51
+ if (value instanceof Angle) return value;
52
+ if (typeof value === 'number') return Angle.degrees(value);
53
+ throw new TypeError(`cannot read an angle from ${typeof value}`);
54
+ }
55
+
56
+ /** @returns {number} The angle in degrees. */
57
+ get degrees() { return (this.radians * 180) / Math.PI; }
58
+
59
+ /**
60
+ * @param {Angle | number} other An angle, or a number of degrees.
61
+ * @returns {Angle} The sum.
62
+ */
63
+ plus(other) { return new Angle(this.radians + Angle.of(other).radians); }
64
+
65
+ /**
66
+ * @param {Angle | number} other An angle, or a number of degrees.
67
+ * @returns {Angle} The difference.
68
+ */
69
+ minus(other) { return new Angle(this.radians - Angle.of(other).radians); }
70
+
71
+ /**
72
+ * @param {number} factor A plain multiplier, not an angle.
73
+ * @returns {Angle} The scaled angle.
74
+ */
75
+ times(factor) { return new Angle(this.radians * factor); }
76
+
77
+ /** @returns {number} The sine of the angle. */
78
+ get sin() { return Math.sin(this.radians); }
79
+
80
+ /** @returns {number} The cosine of the angle. */
81
+ get cos() { return Math.cos(this.radians); }
82
+
83
+ /** @returns {number} The tangent of the angle. */
84
+ get tan() { return Math.tan(this.radians); }
85
+
86
+ /** @returns {string} The angle in degrees, for diagnostics. */
87
+ toString() { return `${this.degrees}°`; }
88
+ }
89
+
90
+ /** Shorthand for {@link Angle.degrees}. */
91
+ export const degrees = Angle.degrees;
92
+
93
+ /** Shorthand for {@link Angle.radians}. */
94
+ export const radians = Angle.radians;