@8bitscript/compiler 0.1.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.
@@ -0,0 +1,412 @@
1
+ // The compile-time folds: `#frames(...)`, the duration builtin, `#system()`,
2
+ // the machine a build is for, and `#fact(...)`, one fact about it (see
3
+ // facts.mjs for the keys and where the values come from).
4
+ //
5
+ // `#frames(x, unit)` — x an integer or decimal literal, `unit` the word
6
+ // saying what x is measured in — folds to a plain IntegerLiteral holding
7
+ // however many frames — waitFrame() calls — that much time is at the
8
+ // project's configured `frameRate` (8bs.config.ts, default 60; see
9
+ // packages/backend-6502's FRAME_SYNC and packages/cli/src/web-runtime.mjs,
10
+ // which both pace waitFrame() at that same rate). `#frames(0.5, seconds)`
11
+ // is 30 at the default rate, 25 at a configured 50.
12
+ //
13
+ // The `#` is the language's one spelling for "8bitscript evaluates this;
14
+ // the target never sees it" (see the lexer's TokenKind.CompileTime): a
15
+ // plain `name(...)` always runs on the machine. So nothing is reserved —
16
+ // `#frames` is a different token from any `frames` a program declares —
17
+ // and a `#name` the compiler doesn't know, or a `#frames` that isn't
18
+ // called, is a diagnostic here.
19
+ //
20
+ // The builtin is named for what comes *out* — a program stores the result
21
+ // in a frame counter, so the call reads as the frame count it is — and the
22
+ // unit word names what went *in*. The unit is required: `#frames(30)` would
23
+ // either mean thirty frames (pointless) or silently guess a unit, and
24
+ // either way the reader is left doing the conversion in their head, which
25
+ // is the one thing this builtin exists to prevent.
26
+ //
27
+ // Two tables, two directions of growth. DURATION_UNITS is what x can be
28
+ // measured in (`seconds`, so far); DURATION_CLOCKS is what a duration can
29
+ // be counted out in — one entry per builtin function, `frames` so far. A
30
+ // future input unit is one more unit entry; a future output clock is one
31
+ // more clock entry, which is also one more builtin name.
32
+ //
33
+ // The unit word is *contextual*, not reserved either: it is only ever
34
+ // looked up by spelling in the second-argument slot of a clock call, a slot
35
+ // that cannot hold a variable (that is the INVALID_DURATION_ARGUMENT shape
36
+ // rule), so `let seconds: uint` elsewhere in the program is an ordinary
37
+ // declaration and never collides.
38
+ //
39
+ // Runs between parse() and check(), so the existing
40
+ // literal-fits-the-declared-width rule (VALUE_OUT_OF_RANGE) fires on the
41
+ // *folded* value for free — `#frames(100, seconds)` in a `utinyint` gets
42
+ // that diagnostic with no separate rule needed here. Deliberately narrow,
43
+ // the same way the checker's literal-width rule is: this is not general
44
+ // constant folding (`let x: u8 = 200 + 100` still isn't folded anywhere in
45
+ // the compiler).
46
+ //
47
+ // Every arithmetic step below is exact BigInt division — never a `Number`
48
+ // or `parseFloat` intermediate — so a duration's real-world length is never
49
+ // silently perturbed by floating-point rounding.
50
+ import { Codes, diagnostic } from '../diagnostics/index.mjs';
51
+ import { NodeType, walk } from '../ast/index.mjs';
52
+ import { FACTS, factPlaceholder } from './facts.mjs';
53
+
54
+ /**
55
+ * The units a duration literal can be written in, keyed by the bare word a
56
+ * program writes as the second argument: `#frames(0.5, seconds)`. Each entry
57
+ * turns the literal (an exact rational `numerator/denominator`, both
58
+ * BigInt) into an exact rational number of seconds, the common currency
59
+ * every clock below is defined against.
60
+ *
61
+ * Adding a unit means adding an entry here and writing it a hover in
62
+ * intellisense/index.mjs. Nothing is reserved: the word is only recognised
63
+ * in that one argument slot.
64
+ */
65
+ export const DURATION_UNITS = new Map([
66
+ ['seconds', {
67
+ toSeconds: (numerator, denominator) => ({ numerator, denominator }),
68
+ }],
69
+ ]);
70
+
71
+ /**
72
+ * The clocks a duration can be counted out in, keyed by the compile-time
73
+ * function name a program calls: `#frames(...)` (without the `#`). Each entry turns an exact
74
+ * rational number of seconds into an exact rational number of that clock's
75
+ * ticks — still a fraction, so the one rounding step (and its
76
+ * ZERO_DURATION / INEXACT_DURATION diagnostics) happens in foldClockCall()
77
+ * identically for every clock.
78
+ *
79
+ * `tick` names one tick for diagnostics; `describe(options)` names the rate
80
+ * the fold happened at.
81
+ *
82
+ * Adding a clock means adding an entry here and writing it a hover in
83
+ * intellisense/index.mjs. Nothing is reserved: `#name` is its own token.
84
+ */
85
+ export const DURATION_CLOCKS = new Map([
86
+ ['frames', {
87
+ // Logical frames: waitFrame() calls, `frameRate` of them a second.
88
+ ticks: (numerator, denominator, { frameRate }) => ({
89
+ numerator: numerator * BigInt(frameRate),
90
+ denominator,
91
+ }),
92
+ tick: 'frame',
93
+ describe: ({ frameRate }) => `this project's frameRate (${frameRate})`,
94
+ }],
95
+ ]);
96
+
97
+ /**
98
+ * The machines `#system()` can name, keyed by the target name `8bs build
99
+ * --target` accepts, each with the number `#system()` folds to on that
100
+ * machine. `@8bitscript/system`'s `System` namespace lists the same names
101
+ * with the same numbers (its test checks them against this map), so
102
+ * `#system() == System.C64` compares two compile-time numbers. The numbers
103
+ * are arbitrary and stable: a machine keeps its number when others are
104
+ * added, so a file that records one stays readable.
105
+ */
106
+ export const SYSTEMS = new Map([
107
+ ['web', 0],
108
+ ['vic20', 1],
109
+ ['c64', 2],
110
+ ['pet', 3],
111
+ ['c128', 4],
112
+ ['atari8', 5],
113
+ ['nes', 6],
114
+ ['cx16', 7],
115
+ ['mega65', 8],
116
+ ]);
117
+
118
+ /** The `#name` a compile-time call names, or null for anything else. */
119
+ function compileTimeCallName(n) {
120
+ if (n.type !== NodeType.CallExpression || n.callee?.type !== NodeType.Identifier) return null;
121
+ return n.callee.compileTime ? n.callee.name : null;
122
+ }
123
+
124
+ const KNOWN_COMPILE_TIME = () => [...[...DURATION_CLOCKS.keys()].map((name) => `#${name}(...)`), '#system()', '#fact(...)'].join(', ');
125
+ const BUILTIN = (name) => DURATION_CLOCKS.has(name) || name === 'system' || name === 'fact';
126
+
127
+ /**
128
+ * Round `numerator/denominator` (both BigInt, denominator > 0) to the
129
+ * nearest integer, ties rounding up.
130
+ *
131
+ * @returns {{ value: bigint, exact: boolean }}
132
+ */
133
+ function roundFraction(numerator, denominator) {
134
+ const quotient = numerator / denominator;
135
+ const remainder = numerator % denominator;
136
+ if (remainder === 0n) return { value: quotient, exact: true };
137
+ return { value: remainder * 2n >= denominator ? quotient + 1n : quotient, exact: false };
138
+ }
139
+
140
+ // Mutates a folded-away clock CallExpression node into a plain
141
+ // IntegerLiteral in place, preserving its original start/length so a
142
+ // downstream diagnostic (e.g. VALUE_OUT_OF_RANGE) underlines the whole
143
+ // `#frames(...)` call rather than a synthetic span. Deleting `callee`/`args`
144
+ // also means walk()'s own descent — which reads Object.values(root) *after*
145
+ // this visit callback returns — never revisits the argument subtree, so a
146
+ // DecimalLiteral consumed by a valid (or invalidly-shaped) clock call is
147
+ // never separately flagged as misplaced, and a unit word is never handed to
148
+ // the linker to fail to resolve. Every exit from foldClockCall() goes
149
+ // through here for exactly that reason.
150
+ function replaceWithTickCount(n, name, value) {
151
+ delete n.callee;
152
+ delete n.args;
153
+ n.type = NodeType.IntegerLiteral;
154
+ n.value = value;
155
+ n.raw = name === 'system' ? '#system()' : `#${name}(...)`;
156
+ n.radix = 10;
157
+ }
158
+
159
+ // The same, for a fact: a count becomes an IntegerLiteral, a flag a
160
+ // BooleanLiteral, so a `bool` const on the sheet takes a flag and the
161
+ // checker's literal-fits-the-type rule sees the right kind of literal.
162
+ function replaceWithFact(n, key, value) {
163
+ delete n.callee;
164
+ delete n.args;
165
+ if (typeof value === 'boolean') {
166
+ n.type = NodeType.BooleanLiteral;
167
+ n.value = value;
168
+ delete n.radix;
169
+ } else {
170
+ n.type = NodeType.IntegerLiteral;
171
+ n.value = value;
172
+ n.radix = 10;
173
+ }
174
+ n.raw = `#fact(${key})`;
175
+ }
176
+
177
+ const exampleCalls = (name) => {
178
+ if (name === 'system') return '#system()';
179
+ if (name === 'fact') return '#fact(video.columns) or #fact(memory.ram)';
180
+ return `#${name}(1, seconds) or #${name}(0.5, seconds)`;
181
+ };
182
+
183
+ /**
184
+ * The dotted key a `#fact(...)` argument spells — `video.columns` is a
185
+ * member expression of plain identifiers — or null for any other shape.
186
+ * The words are contextual, not reserved: they are only read here, in this
187
+ * one slot, so `let video` elsewhere is an ordinary declaration.
188
+ */
189
+ function factKeyOf(argument) {
190
+ if (!argument) return null;
191
+ if (argument.type === NodeType.Identifier) return argument.compileTime ? null : argument.name;
192
+ if (argument.type === NodeType.MemberExpression && argument.property?.type === NodeType.Identifier) {
193
+ const head = factKeyOf(argument.object);
194
+ return head === null ? null : `${head}.${argument.property.name}`;
195
+ }
196
+ return null;
197
+ }
198
+
199
+ /**
200
+ * `#fact(key)`: one fact about the machine this build is for, from the
201
+ * sheet the build was handed (FACTS has the keys; the machine packages'
202
+ * catalogs and the CLI's resolveHardware have the values). With no machine
203
+ * in hand — `8bs check`, the editor — it folds to the key's placeholder
204
+ * (0 or false) and is "valid and target-dependent", as `#system()` is. With
205
+ * a machine but no facts it is a diagnostic: a real build has a real sheet,
206
+ * and the fold will not invent one for it.
207
+ */
208
+ function foldFactCall(n, file, machine, facts, diagnostics) {
209
+ const args = n.args ?? [];
210
+ const key = args.length === 1 ? factKeyOf(args[0]) : null;
211
+ if (key === null || !FACTS.has(key)) {
212
+ diagnostics.push(diagnostic(
213
+ Codes.UNKNOWN_FACT,
214
+ key === null
215
+ ? `#fact(...) takes one fact key, written as words — ${exampleCalls('fact')}`
216
+ : `'${key}' is not a fact — the keys are ${[...FACTS.keys()].join(', ')}`,
217
+ file, n.start, n.length,
218
+ ));
219
+ replaceWithFact(n, key ?? '?', 0);
220
+ return;
221
+ }
222
+ if (machine === undefined) {
223
+ replaceWithFact(n, key, factPlaceholder(key));
224
+ return;
225
+ }
226
+ if (facts === undefined) {
227
+ diagnostics.push(diagnostic(
228
+ Codes.NO_HARDWARE_FACTS,
229
+ `#fact(${key}) needs this build's hardware facts, and the ${machine} build was given none — a build resolves its hardware first (8bs build does; link() takes 'facts')`,
230
+ file, n.start, n.length,
231
+ ));
232
+ replaceWithFact(n, key, factPlaceholder(key));
233
+ return;
234
+ }
235
+ const value = Object.hasOwn(facts, key) ? facts[key] : factPlaceholder(key);
236
+ replaceWithFact(n, key, value);
237
+ }
238
+
239
+ /**
240
+ * `#system()`: the machine this build is for, as its number in SYSTEMS.
241
+ * Takes no arguments. With no machine in hand — `8bs check` and the editor
242
+ * analyse files, not builds — it folds to 0 and is simply "valid, and
243
+ * target-dependent", the same answer the resolver gives a `.<machine>.8bs`
244
+ * file then: only a build can say which machine, and only a build needs to.
245
+ */
246
+ function foldSystemCall(n, file, machine, diagnostics) {
247
+ if ((n.args ?? []).length > 0) {
248
+ diagnostics.push(diagnostic(
249
+ Codes.SYSTEM_TAKES_NO_ARGUMENTS,
250
+ '#system() takes no arguments: it is the machine this build is for, and the build already knows which',
251
+ file, n.start, n.length,
252
+ ));
253
+ replaceWithTickCount(n, 'system', 0);
254
+ return;
255
+ }
256
+ if (machine === undefined) {
257
+ replaceWithTickCount(n, 'system', 0);
258
+ return;
259
+ }
260
+ const value = SYSTEMS.get(machine);
261
+ if (value === undefined) {
262
+ diagnostics.push(diagnostic(
263
+ Codes.NOT_ON_THIS_TARGET,
264
+ `#system() has no number for '${machine}' — the machines are ${[...SYSTEMS.keys()].join(', ')}`,
265
+ file, n.start, n.length,
266
+ ));
267
+ replaceWithTickCount(n, 'system', 0);
268
+ return;
269
+ }
270
+ replaceWithTickCount(n, 'system', value);
271
+ }
272
+
273
+ function foldClockCall(n, clockName, file, frameRate, diagnostics) {
274
+ const clock = DURATION_CLOCKS.get(clockName);
275
+ const args = n.args ?? [];
276
+ const [argument, unitArgument] = args;
277
+ const isLiteralArgument = argument?.type === NodeType.IntegerLiteral
278
+ || argument?.type === NodeType.DecimalLiteral;
279
+ const isUnitShape = args.length === 2 && unitArgument?.type === NodeType.Identifier;
280
+
281
+ if (!isLiteralArgument || !isUnitShape) {
282
+ diagnostics.push(diagnostic(
283
+ Codes.INVALID_DURATION_ARGUMENT,
284
+ `#${clockName}(...) takes one integer or decimal literal and the unit it is measured in, `
285
+ + `e.g. ${exampleCalls(clockName)}`,
286
+ file, n.start, n.length,
287
+ ));
288
+ replaceWithTickCount(n, clockName, 0);
289
+ return;
290
+ }
291
+
292
+ const unitName = unitArgument.name;
293
+ const unit = DURATION_UNITS.get(unitName);
294
+ if (!unit) {
295
+ diagnostics.push(diagnostic(
296
+ Codes.UNKNOWN_DURATION_UNIT,
297
+ `'${unitName}' is not a unit #${clockName}(...) can measure — `
298
+ + `the units are ${[...DURATION_UNITS.keys()].join(', ')}`,
299
+ file, unitArgument.start, unitArgument.length,
300
+ ));
301
+ replaceWithTickCount(n, clockName, 0);
302
+ return;
303
+ }
304
+
305
+ const options = { frameRate };
306
+ const seconds = unit.toSeconds(
307
+ BigInt(argument.type === NodeType.IntegerLiteral ? argument.value : argument.numerator),
308
+ BigInt(argument.type === NodeType.IntegerLiteral ? 1 : argument.denominator),
309
+ );
310
+ const ticks = clock.ticks(seconds.numerator, seconds.denominator, options);
311
+ const { value, exact } = roundFraction(ticks.numerator, ticks.denominator);
312
+ const written = `#${clockName}(${argument.raw}, ${unitName})`;
313
+
314
+ if (value === 0n) {
315
+ diagnostics.push(diagnostic(
316
+ Codes.ZERO_DURATION,
317
+ `${written} rounds to 0 ${clock.tick}s at ${clock.describe(options)} — `
318
+ + `every #${clockName}(...) call must round to at least one ${clock.tick}`,
319
+ file, n.start, n.length,
320
+ ));
321
+ } else if (!exact) {
322
+ diagnostics.push(diagnostic(
323
+ Codes.INEXACT_DURATION,
324
+ `${written} is not exact at ${clock.describe(options)} — `
325
+ + `rounded to ${value} ${clock.tick}${value === 1n ? '' : 's'}`,
326
+ file, n.start, n.length, 'warning',
327
+ ));
328
+ }
329
+
330
+ replaceWithTickCount(n, clockName, Number(value));
331
+ }
332
+
333
+ /**
334
+ * Fold every compile-time call — `#frames(...)` (see DURATION_CLOCKS) and
335
+ * `#system()` (see SYSTEMS) — in `ast` into a plain IntegerLiteral,
336
+ * mutating the tree in place, and flag any decimal literal found outside a
337
+ * valid clock-call argument (the language has no other float syntax), any
338
+ * `#name` the compiler doesn't evaluate, and any `#frames` or `#system`
339
+ * that isn't called.
340
+ *
341
+ * @param {object} ast Program node from the parser.
342
+ * @param {string} file
343
+ * @param {{ frameRate?: number, machine?: string, facts?: object }} [options]
344
+ * `frameRate` is the project's logical frame rate (default 60; already
345
+ * validated positive-integer by the caller — see
346
+ * packages/cli/src/config.mjs's resolveFrameRate). `machine` is the
347
+ * target being built for, or undefined when a file is being checked
348
+ * rather than built (see foldSystemCall). `facts` is the build's merged
349
+ * hardware facts, keyed as facts.mjs's FACTS is, that every `#fact(...)`
350
+ * folds from; required whenever `machine` is given and a fact is read
351
+ * (see foldFactCall).
352
+ * @returns {object[]} diagnostics
353
+ */
354
+ export function foldCompileTime(ast, file = '<unknown>', { frameRate = 60, machine, facts } = {}) {
355
+ const diagnostics = [];
356
+ if (!ast) return diagnostics;
357
+
358
+ walk(ast, (n) => {
359
+ const name = compileTimeCallName(n);
360
+ if (name) {
361
+ if (name === 'system') {
362
+ foldSystemCall(n, file, machine, diagnostics);
363
+ return;
364
+ }
365
+ if (name === 'fact') {
366
+ foldFactCall(n, file, machine, facts, diagnostics);
367
+ return;
368
+ }
369
+ if (!DURATION_CLOCKS.has(name)) {
370
+ diagnostics.push(diagnostic(
371
+ Codes.UNKNOWN_COMPILE_TIME_FUNCTION,
372
+ `'#${name}' is not a function the compiler evaluates — the compile-time functions are ${KNOWN_COMPILE_TIME()}`,
373
+ file, n.callee.start, n.callee.length,
374
+ ));
375
+ replaceWithTickCount(n, name, 0);
376
+ return;
377
+ }
378
+ foldClockCall(n, name, file, frameRate, diagnostics);
379
+ return;
380
+ }
381
+ if (n.type === NodeType.Identifier && n.compileTime) {
382
+ // Every called one was consumed above (its callee deleted before walk()
383
+ // descends), so this one is bare: `#frames` with no argument list.
384
+ diagnostics.push(diagnostic(
385
+ Codes.UNKNOWN_COMPILE_TIME_FUNCTION,
386
+ BUILTIN(n.name)
387
+ ? `'#${n.name}' is a compile-time function and must be called: ${exampleCalls(n.name)}`
388
+ : `'#${n.name}' is not a function the compiler evaluates — the compile-time functions are ${KNOWN_COMPILE_TIME()}`,
389
+ file, n.start, n.length,
390
+ ));
391
+ return;
392
+ }
393
+ if (n.type === NodeType.DecimalLiteral) {
394
+ const raw = n.raw;
395
+ diagnostics.push(diagnostic(
396
+ Codes.MISPLACED_DECIMAL_LITERAL,
397
+ `a decimal literal ('${raw}') is only valid as the first argument to ${KNOWN_COMPILE_TIME()}`,
398
+ file, n.start, n.length,
399
+ ));
400
+ // Replaced, like a folded call is, so nothing downstream reports the
401
+ // same mistake a second time in its own words — the language has no
402
+ // float type for lowering to fail on, and it has already been told.
403
+ n.type = NodeType.IntegerLiteral;
404
+ n.value = 0;
405
+ n.radix = 10;
406
+ delete n.numerator;
407
+ delete n.denominator;
408
+ }
409
+ });
410
+
411
+ return diagnostics;
412
+ }