@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,1075 @@
1
+ // The linker: one entry module in, one complete IR program out.
2
+ //
3
+ // It loads the module graph an entry file's imports name, runs the full front
4
+ // end over every module, and merges the results into a single IrProgram the
5
+ // backends already understand. Backends did not change for modules to arrive,
6
+ // and that is the point: linking happens entirely on the IR.
7
+ //
8
+ // Like the resolver, and for the same reason, this is a layer that touches the
9
+ // filesystem: the lexer, parser, checker, and lowering all stay pure, and the
10
+ // linker orchestrates them over real files.
11
+ //
12
+ // The model is per-module namespaces, the ones docs/packages.md promises: a
13
+ // module sees its own top-level declarations plus what it imports, and nothing
14
+ // else. Because the merged program is one flat C translation unit, symbols are
15
+ // renamed to keep modules apart — a symbol keeps its source name when it is
16
+ // free (the entry module loads first, so its names always survive, and `main`
17
+ // stays `main`), and takes a `_2`-style suffix when another module got there
18
+ // first. References are rewritten module by module, which is also what makes
19
+ // `import { x as y }` aliasing work.
20
+ //
21
+ // Two deliberate absences, on record:
22
+ // - No reachability pruning. Every module's globals and functions are
23
+ // emitted whether used or not. Hardware registers are #defines and cost
24
+ // nothing; on a 3583-byte VIC-20 unused *code* will eventually matter, and
25
+ // pruning earns its place when a package ships more than registers.
26
+ // - asm6502 text is never rewritten. Inline assembly that names a symbol
27
+ // sees the symbol's final, possibly-suffixed name — packages that ship
28
+ // assembly should prefer names unlikely to collide.
29
+ import { readFileSync, realpathSync } from 'node:fs';
30
+
31
+ import { tokenize } from '../lexer/index.mjs';
32
+ import { parse } from '../parser/index.mjs';
33
+ import { check } from '../checker/index.mjs';
34
+ import { foldCompileTime } from '../fold/index.mjs';
35
+ import { lower } from '../ir/index.mjs';
36
+ import { resolveSpecifier, nativeSourcesBeside } from '../resolver/index.mjs';
37
+ import { Codes, diagnostic } from '../diagnostics/index.mjs';
38
+ import { storageBytes, resolveIntegerType } from '../types/index.mjs';
39
+ import { checkHardwareHazards } from './hazards.mjs';
40
+
41
+ /** The canonical identity of a file: two pnpm symlink routes, one module. */
42
+ function canonical(path) {
43
+ try {
44
+ return realpathSync(path);
45
+ } catch {
46
+ return path;
47
+ }
48
+ }
49
+
50
+ /** Run the pure front end over one module's text. */
51
+ function loadModule(file, text, diagnostics, { frameRate, machine, facts }) {
52
+ const { tokens, diagnostics: lexical } = tokenize(text, file);
53
+ const { ast, diagnostics: syntax } = parse(tokens, text, file);
54
+ diagnostics.push(...lexical, ...syntax);
55
+ // Folding runs before check(): a #frames(...) call needs to already be a
56
+ // plain IntegerLiteral by the time the width-fit rule walks the tree, so
57
+ // e.g. #frames(100, seconds) overflowing a utinyint gets that diagnostic for free,
58
+ // with no separate rule duplicating it here.
59
+ diagnostics.push(...foldCompileTime(ast, file, { frameRate, machine, facts }));
60
+ diagnostics.push(...check(ast, file, text));
61
+ const { ir, diagnostics: lowering } = lower(ast, file, text);
62
+ // The template layout runs in both check() (so the editor sees it) and
63
+ // lower() (so a direct lower() can never drop a template silently); one
64
+ // problem is reported once.
65
+ const seen = new Set(diagnostics.map((d) => `${d.code}@${d.start}+${d.length}`));
66
+ diagnostics.push(...lowering.filter((d) => !seen.has(`${d.code}@${d.start}+${d.length}`)));
67
+ return { file, ir };
68
+ }
69
+
70
+ /**
71
+ * Discover and load every module reachable from the entry.
72
+ *
73
+ * Cycles are permitted: a module already loaded is bound to, not reloaded.
74
+ * Globals initialise to literals only, so no initialisation-order problem
75
+ * exists for a cycle to cause.
76
+ */
77
+ function loadGraph(entryText, entryFile, diagnostics, sources, options) {
78
+ const modules = [];
79
+ const byPath = new Map();
80
+ // A package's "8bitscript".native files (see the resolver), collected
81
+ // once each however many modules import the package — keyed by canonical
82
+ // path for the same pnpm-symlink reason `byPath` is.
83
+ const nativeSources = new Map();
84
+
85
+ const enqueue = (file, text) => {
86
+ const module = loadModule(file, text, diagnostics, { frameRate: options.frameRate, machine: options.machine, facts: options.facts });
87
+ modules.push(module);
88
+ byPath.set(canonical(file), module);
89
+ sources.set(file, text);
90
+ return module;
91
+ };
92
+
93
+ enqueue(entryFile, entryText);
94
+ // The entry's own package, if it sits inside one that ships native
95
+ // sources (a package's probe program under its test/ directory).
96
+ for (const source of nativeSourcesBeside(entryFile).native ?? []) {
97
+ nativeSources.set(canonical(source), source);
98
+ }
99
+
100
+ // modules grows while we walk it: a plain index loop is the worklist.
101
+ for (let i = 0; i < modules.length; i += 1) {
102
+ const module = modules[i];
103
+ for (const imp of module.ir.imports) {
104
+ const resolved = resolveSpecifier(imp.source, module.file, options);
105
+ if (!resolved) {
106
+ diagnostics.push(diagnostic(
107
+ Codes.NOT_COMPILABLE,
108
+ `import specifier '${imp.source}' is not linkable yet: only './file.8bs' paths, bare package names, and package subpaths ('@scope/name/thing') are specified`,
109
+ module.file, imp.start, imp.length,
110
+ ));
111
+ continue;
112
+ }
113
+ if (resolved.code) {
114
+ diagnostics.push(diagnostic(resolved.code, resolved.message, module.file, imp.start, imp.length));
115
+ continue;
116
+ }
117
+ if (resolved.path === null) {
118
+ // A conditional package entry, or a file that exists only in
119
+ // per-machine versions, with no machine to choose by: the caller
120
+ // linked without one, and guessing a machine would be worse.
121
+ diagnostics.push(diagnostic(
122
+ Codes.NOT_COMPILABLE,
123
+ `'${imp.source}' is target-specific; linking it needs a machine target`,
124
+ module.file, imp.start, imp.length,
125
+ ));
126
+ continue;
127
+ }
128
+ const key = canonical(resolved.path);
129
+ if (!byPath.has(key)) {
130
+ let text;
131
+ try {
132
+ text = readFileSync(resolved.path, 'utf8');
133
+ } catch {
134
+ diagnostics.push(diagnostic(
135
+ Codes.UNRESOLVED_RELATIVE_IMPORT,
136
+ `cannot read module '${imp.source}'`,
137
+ module.file, imp.start, imp.length,
138
+ ));
139
+ continue;
140
+ }
141
+ enqueue(resolved.path, text);
142
+ }
143
+ imp.module = byPath.get(key);
144
+ for (const source of resolved.native ?? []) {
145
+ nativeSources.set(canonical(source), source);
146
+ }
147
+ }
148
+ }
149
+
150
+ return { modules, nativeSources: [...nativeSources.values()] };
151
+ }
152
+
153
+ /** Every top-level name a module declares, mapped to whether it is exported. */
154
+ function declarationsOf(module) {
155
+ const decls = new Map();
156
+ for (const g of module.ir.globals) decls.set(g.name, g.exported);
157
+ for (const f of module.ir.functions) decls.set(f.name, f.exported);
158
+ for (const c of module.ir.consts ?? []) decls.set(c.name, c.exported);
159
+ return decls;
160
+ }
161
+
162
+ /** This module's own globals, by their original (pre-rename) name. */
163
+ function globalsOf(module) {
164
+ return new Map(module.ir.globals.map((g) => [g.name, g]));
165
+ }
166
+
167
+ /**
168
+ * The global a name in `module` refers to — its own, or the one an import
169
+ * binds — or null when it names something else (a function, a const, a
170
+ * parameter). Used for what only the whole program can know about an
171
+ * array: an importer's `a[i]` needs the element type, and a store into an
172
+ * imported const array is refused here.
173
+ */
174
+ function globalNamed(module, name) {
175
+ if (module.globalsByName.has(name)) return module.globalsByName.get(name);
176
+ const binding = module.bindings?.get(name);
177
+ return binding ? (binding.module.globalsByName.get(binding.name) ?? null) : null;
178
+ }
179
+
180
+ /** Does `name`, in this function's scope, refer to something another module declares? */
181
+ function isImportedName(module, scope, name) {
182
+ return !module.globalsByName.has(name) && !scope.bound?.has(name) && module.bindings?.has(name);
183
+ }
184
+
185
+ /**
186
+ * A scope for a nested block: the enclosing names, plus whatever locals
187
+ * the block declares — which go out of scope with it. `bound` is the set
188
+ * of names a parameter or local binds in the function, for the rules that
189
+ * ask "is this an import?".
190
+ */
191
+ function childScope(scope) {
192
+ const child = new Map(scope);
193
+ child.bound = new Set(scope.bound ?? []);
194
+ return child;
195
+ }
196
+
197
+ /** Bind a parameter or local: never renamed, and it shadows everything else of the name. */
198
+ function bindLocal(scope, name) {
199
+ scope.set(name, name);
200
+ if (!scope.bound) scope.bound = new Set();
201
+ scope.bound.add(name);
202
+ }
203
+
204
+ /**
205
+ * Resolve the array an `index`/`storeIndex` node names: rename its `ref`,
206
+ * fill in the element type lowering could not see, and check what only
207
+ * this layer can. `expr.array` is the ref; own-module problems were
208
+ * reported when the module was lowered, so only an imported array is
209
+ * examined here.
210
+ */
211
+ function rewriteArrayAccess(expr, scope, module, diagnostics, { store = false } = {}) {
212
+ const name = expr.array.name;
213
+ const imported = isImportedName(module, scope, name);
214
+ rewriteExpression(expr.array, scope, module, diagnostics);
215
+ rewriteExpression(expr.index, scope, module, diagnostics);
216
+ if (!imported || expr.array.kind !== 'ref') return;
217
+ const g = globalNamed(module, name);
218
+ const at = (code, message) => diagnostics.push(diagnostic(code, message, module.file, expr.array.start ?? 0, expr.array.length ?? 0));
219
+ if (!g || !g.array) {
220
+ at(Codes.NOT_COMPILABLE, `'${name}' is not an array: ${store ? 'assigning to an element' : 'indexing'} needs an array<T, N>`);
221
+ return;
222
+ }
223
+ expr.elementType = g.type;
224
+ if (store && g.constant) {
225
+ at(Codes.ASSIGN_TO_CONST, `'${name}' is a const array — data in the program, not RAM — and cannot be assigned to`);
226
+ }
227
+ if (expr.index.kind === 'const' && (expr.index.value < 0 || expr.index.value >= g.array)) {
228
+ at(Codes.INDEX_OUT_OF_RANGE, `index ${expr.index.value} is outside an array<${g.type}, ${g.array}>: elements are 0..${g.array - 1}`);
229
+ }
230
+ }
231
+
232
+ /**
233
+ * A pending initialiser — an imported const by name, or `Namespace.Member`
234
+ * — as the number it stands for, range-checked against the global's type.
235
+ * Anything else is reported and stands in as 0.
236
+ */
237
+ function resolveInitialiser(expr, g, scope, module, diagnostics) {
238
+ const at = (code, message) => diagnostics.push(diagnostic(code, message, module.file, expr.start ?? 0, expr.length ?? 0));
239
+ if (expr.kind === 'ref' && typeof module.constValues.get(expr.name) === 'object') {
240
+ at(Codes.NOT_COMPILABLE, `'${expr.name}' is a string const; it cannot initialise a ${g.type}`);
241
+ return 0;
242
+ }
243
+ if (expr.kind === 'ref' && !module.constValues.has(expr.name)) {
244
+ at(scope.has(expr.name) ? Codes.NOT_COMPILABLE : Codes.UNRESOLVED_NAME,
245
+ scope.has(expr.name)
246
+ ? `'${expr.name}' is not a const, so it cannot initialise a global: an initialiser is a literal or a const`
247
+ : `cannot find name '${expr.name}'`);
248
+ return 0;
249
+ }
250
+ const before = diagnostics.length;
251
+ rewriteExpression(expr, scope, module, diagnostics);
252
+ if (diagnostics.length > before) return 0;
253
+ if (expr.kind !== 'const') {
254
+ at(Codes.NOT_COMPILABLE, 'an initialiser is a literal or a const');
255
+ return 0;
256
+ }
257
+ const range = g.type === 'bool' ? { min: 0, max: 1 } : resolveIntegerType(g.type);
258
+ if (expr.value < range.min || expr.value > range.max) {
259
+ at(Codes.VALUE_OUT_OF_RANGE, `${expr.value} does not fit in ${g.type} (${range.min}..${range.max})`);
260
+ return 0;
261
+ }
262
+ return expr.value;
263
+ }
264
+
265
+ /**
266
+ * A call, once its callee is an output name: the argument count checked
267
+ * against the parameters, and every argument left off filled in from the
268
+ * parameter's default — a value 8bitscript resolved, so the machine sees
269
+ * a complete call. Too many, or fewer than the parameters without a
270
+ * default, is WRONG_ARGUMENT_COUNT.
271
+ */
272
+ function completeCall(call, module, diagnostics) {
273
+ const fn = module.program?.functionsByOutput?.get(call.name);
274
+ if (!fn) return;
275
+ const min = fn.params.filter((p) => p.default === undefined).length;
276
+ const max = fn.params.length;
277
+ if (call.args.length > max || call.args.length < min) {
278
+ const takes = min === max ? `${max}` : `${min} to ${max}`;
279
+ diagnostics.push(diagnostic(
280
+ Codes.WRONG_ARGUMENT_COUNT,
281
+ `'${call.original ?? call.name}' takes ${takes} argument${max === 1 ? '' : 's'}, not ${call.args.length}`,
282
+ module.file, call.start ?? 0, call.length ?? 0,
283
+ ));
284
+ return;
285
+ }
286
+ for (let i = call.args.length; i < max; i += 1) call.args.push(structuredClone(fn.params[i].default));
287
+ }
288
+
289
+ /**
290
+ * This module's own top-level consts, name to value — a number, or for a
291
+ * string const `{ string: slot }` in the module's own string table (moved
292
+ * to the program's table in link(), once that exists).
293
+ */
294
+ function constsOf(module) {
295
+ return new Map((module.ir.consts ?? []).map((c) => [
296
+ c.name,
297
+ c.pending ? { pending: c.pending, type: c.type } : c.type === 'string' ? { string: c.string } : c.value,
298
+ ]));
299
+ }
300
+
301
+ /**
302
+ * A const whose initialiser only the linker can see — `const HIGHLIGHT:
303
+ * utinyint = TextColor.YELLOW`, or `= Imported` — gets its value here,
304
+ * before any module inlines it. A pending const may name another pending
305
+ * const (in any module), so this repeats until nothing changes; what is
306
+ * still pending then is a cycle, and is reported.
307
+ */
308
+ function resolvePendingConsts(modules, diagnostics) {
309
+ // Every const slot the linker may still owe a value: the module's own
310
+ // top-level consts, and the const members of each of its namespaces
311
+ // (`namespace text { const COLUMNS: utinyint = Video.COLUMNS; }`), which
312
+ // take the same initialisers and resolve by the same rule.
313
+ const pendingOf = (module) => [
314
+ ...[...module.ownConsts].filter(([, v]) => v?.pending)
315
+ .map(([name, v]) => ({ name, ...v, table: module.ownConsts })),
316
+ ...[...module.namespaces.values()].flatMap((ns) => [...ns.consts]
317
+ .filter(([, v]) => v?.pending)
318
+ .map(([member, v]) => ({ name: `${ns.name}.${member}`, ...v, table: ns.consts, key: member }))),
319
+ ];
320
+ const settle = (slot, value) => slot.table.set(slot.key ?? slot.name, value);
321
+ const scopes = new Map(modules.map((m) => [m, new Map(m.rename)]));
322
+ for (let progress = true; progress;) {
323
+ progress = false;
324
+ for (const module of modules) {
325
+ for (const slot of pendingOf(module)) {
326
+ const { pending, type } = slot;
327
+ const expr = structuredClone(pending);
328
+ if (expr.kind === 'ref') {
329
+ // Own const first (a chain inside one module), then an import.
330
+ const binding = module.ownConsts.has(expr.name)
331
+ ? { module, name: expr.name } : module.bindings.get(expr.name);
332
+ const other = binding && binding.module.ownConsts.get(binding.name);
333
+ if (other?.pending) continue; // not yet; another pass
334
+ if (other === undefined) {
335
+ diagnostics.push(diagnostic(
336
+ binding ? Codes.NOT_COMPILABLE : Codes.UNRESOLVED_NAME,
337
+ binding ? `'${expr.name}' is not a const, so it cannot initialise a const` : `cannot find name '${expr.name}'`,
338
+ module.file, expr.start ?? 0, expr.length ?? 0,
339
+ ));
340
+ settle(slot, 0);
341
+ progress = true;
342
+ continue;
343
+ }
344
+ } else if (expr.kind === 'namespaceConst') {
345
+ // `Other.MEMBER`: a member still pending waits for another pass;
346
+ // a namespace or member that does not exist is left to
347
+ // rewriteExpression below, which reports it.
348
+ const result = resolveNamespaceMember(module, expr.namespace, expr.member, 'consts');
349
+ if (result.namespaceFound && result.memberFound && result.value?.pending) continue;
350
+ }
351
+ // constValues is what rewriteExpression inlines from; for this pass
352
+ // it is the module's own resolved consts plus its imports' values.
353
+ module.constValues = new Map([...module.ownConsts].filter(([, v]) => !v?.pending));
354
+ for (const [local, binding] of module.bindings) {
355
+ const v = binding.module.ownConsts.get(binding.name);
356
+ if (v !== undefined && !v?.pending) module.constValues.set(local, v);
357
+ }
358
+ settle(slot, resolveInitialiser(expr, { type }, scopes.get(module), module, diagnostics));
359
+ progress = true;
360
+ }
361
+ }
362
+ }
363
+ for (const module of modules) {
364
+ for (const slot of pendingOf(module)) {
365
+ diagnostics.push(diagnostic(
366
+ Codes.NOT_COMPILABLE,
367
+ `'${slot.name}' is a const whose value depends on itself, through the consts it names`,
368
+ module.file, slot.pending.start ?? 0, slot.pending.length ?? 0,
369
+ ));
370
+ settle(slot, 0);
371
+ }
372
+ }
373
+ }
374
+
375
+ /** A const's inlined value as an IR expression: a number, or a string slot. */
376
+ function constExpression(value) {
377
+ return typeof value === 'object' ? { kind: 'string', index: value.string } : { kind: 'const', value };
378
+ }
379
+
380
+ /** Is `name` a string in this scope — a parameter or local, a string const, or a string<N> variable? */
381
+ function isStringName(module, scope, name) {
382
+ if (scope.bound?.has(name)) return true; // a parameter's type is the callee's business; lowering checked its own
383
+ if (typeof module.constValues.get(name) === 'object') return true;
384
+ return Boolean(globalNamed(module, name)?.stringCapacity);
385
+ }
386
+
387
+ /** A string source checked against a `string<N>` target's capacity, for a literal. */
388
+ function checkStringFits(source, capacity, ir, module, diagnostics) {
389
+ if (source.kind !== 'string') return;
390
+ const s = ir.strings[source.index];
391
+ if (s.bytes.length > capacity) {
392
+ diagnostics.push(diagnostic(
393
+ Codes.STRING_TOO_LONG,
394
+ `"${s.text}" is ${s.bytes.length} characters and does not fit in string<${capacity}>`,
395
+ module.file, source.start ?? 0, source.length ?? 0,
396
+ ));
397
+ }
398
+ }
399
+
400
+ /** This module's own namespace declarations, keyed by namespace name. */
401
+ function namespacesOf(module) {
402
+ return new Map((module.ir.namespaces ?? []).map((ns) => [ns.name, ns]));
403
+ }
404
+
405
+ /**
406
+ * Check import bindings: every imported name must be exported by the module
407
+ * its specifier resolved to, and must not collide with a declaration or
408
+ * another import in the importing module. A namespace import binds
409
+ * separately from a value/function import — `screen` names a namespace, not
410
+ * something a bare `ref` could ever resolve to.
411
+ */
412
+ function bindImports(modules, diagnostics) {
413
+ for (const module of modules) {
414
+ module.decls = declarationsOf(module);
415
+ module.globalsByName = globalsOf(module);
416
+ module.namespaces = namespacesOf(module);
417
+ module.ownConsts = constsOf(module);
418
+ module.bindings = new Map();
419
+ module.namespaceBindings = new Map();
420
+ }
421
+ for (const module of modules) {
422
+ for (const imp of module.ir.imports) {
423
+ if (!imp.module) continue; // resolution already failed and reported
424
+ for (const spec of imp.specifiers) {
425
+ if (
426
+ module.decls.has(spec.local)
427
+ || module.bindings.has(spec.local)
428
+ || module.namespaceBindings.has(spec.local)
429
+ ) {
430
+ diagnostics.push(diagnostic(
431
+ Codes.DUPLICATE_BINDING,
432
+ `'${spec.local}' is already bound in this module`,
433
+ module.file, spec.start, spec.length,
434
+ ));
435
+ continue;
436
+ }
437
+ const importedNamespace = imp.module.namespaces.get(spec.imported);
438
+ if (importedNamespace?.exported) {
439
+ module.namespaceBindings.set(spec.local, { module: imp.module, name: spec.imported });
440
+ continue;
441
+ }
442
+ if (imp.module.decls.get(spec.imported) !== true) {
443
+ diagnostics.push(diagnostic(
444
+ Codes.NO_SUCH_EXPORT,
445
+ `'${spec.imported}' is not exported by '${imp.source}'`,
446
+ module.file, spec.start, spec.length,
447
+ ));
448
+ continue;
449
+ }
450
+ module.bindings.set(spec.local, { module: imp.module, name: spec.imported });
451
+ }
452
+ }
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Resolve `namespace.member` against a module's own namespace declarations
458
+ * or, failing that, its namespace imports — the same two-step lookup a plain
459
+ * `ref` gets from `scope`, just kept separate because a namespace member
460
+ * resolves to a *mangled function name* or a *literal value*, never to an
461
+ * output-renamed binding by itself.
462
+ *
463
+ * @param {'functions'|'consts'} table
464
+ */
465
+ function resolveNamespaceMember(module, namespaceName, memberName, table) {
466
+ const own = module.namespaces.get(namespaceName);
467
+ if (own) {
468
+ const value = own[table].get(memberName);
469
+ return { namespaceFound: true, memberFound: value !== undefined, value, targetModule: module };
470
+ }
471
+ const binding = module.namespaceBindings.get(namespaceName);
472
+ if (!binding) return { namespaceFound: false };
473
+ const target = binding.module.namespaces.get(binding.name);
474
+ if (!target) return { namespaceFound: false };
475
+ const value = target[table].get(memberName);
476
+ return { namespaceFound: true, memberFound: value !== undefined, value, targetModule: binding.module };
477
+ }
478
+
479
+ /**
480
+ * Give every declaration its output name. First come keeps the source name;
481
+ * the entry module comes first, so user-facing names — `main` above all —
482
+ * never change. Later modules take `name_2`, `name_3`, … on collision.
483
+ */
484
+ function assignOutputNames(modules) {
485
+ const taken = new Set();
486
+ for (const module of modules) {
487
+ module.rename = new Map();
488
+ for (const name of module.decls.keys()) {
489
+ // A const has no output at all: it is inlined wherever it is read.
490
+ if (module.ownConsts.has(name)) continue;
491
+ let out = name;
492
+ for (let n = 2; taken.has(out); n += 1) out = `${name}_${n}`;
493
+ taken.add(out);
494
+ module.rename.set(name, out);
495
+ }
496
+ }
497
+ }
498
+
499
+ function rewriteExpression(expr, scope, module, diagnostics) {
500
+ switch (expr.kind) {
501
+ case 'ref': {
502
+ const out = scope.get(expr.name);
503
+ if (out === undefined && module.constValues.has(expr.name)) {
504
+ // A const, this module's own or imported: the value, inlined. A
505
+ // parameter of the same name is in `scope` and so shadowed it above.
506
+ const value = constExpression(module.constValues.get(expr.name));
507
+ delete expr.name;
508
+ if (value.kind === 'const') { delete expr.start; delete expr.length; }
509
+ Object.assign(expr, value);
510
+ return;
511
+ }
512
+ if (out === undefined) {
513
+ // A parameter is already in `scope` (mapped to itself — see `link()`),
514
+ // so anything still unresolved here is a typo or a missing import,
515
+ // and letting it through would risk it silently capturing another
516
+ // module's renamed symbol.
517
+ diagnostics.push(diagnostic(
518
+ Codes.UNRESOLVED_NAME,
519
+ `cannot find name '${expr.name}'`,
520
+ module.file, expr.start ?? 0, expr.length ?? 0,
521
+ ));
522
+ return;
523
+ }
524
+ expr.name = out;
525
+ return;
526
+ }
527
+ case 'binop':
528
+ rewriteExpression(expr.left, scope, module, diagnostics);
529
+ rewriteExpression(expr.right, scope, module, diagnostics);
530
+ return;
531
+ case 'unop':
532
+ rewriteExpression(expr.argument, scope, module, diagnostics);
533
+ return;
534
+ case 'string':
535
+ // The module's own string table was merged into the program's (see
536
+ // link()); the slot number moves with it.
537
+ expr.index = module.stringMap[expr.index];
538
+ return;
539
+ case 'stringLength':
540
+ rewriteExpression(expr.string, scope, module, diagnostics);
541
+ return;
542
+ case 'stringByte':
543
+ rewriteExpression(expr.string, scope, module, diagnostics);
544
+ rewriteExpression(expr.index, scope, module, diagnostics);
545
+ return;
546
+ case 'index': {
547
+ const imported = isImportedName(module, scope, expr.array.name);
548
+ const buffer = imported ? globalNamed(module, expr.array.name) : null;
549
+ const stringConst = imported && typeof module.constValues.get(expr.array.name) === 'object';
550
+ if (buffer?.stringCapacity || stringConst) {
551
+ // `s[i]` on an imported string<N>: lowering could not tell it from
552
+ // an array; it is the i-th character, as it is for a string parameter.
553
+ rewriteExpression(expr.array, scope, module, diagnostics);
554
+ rewriteExpression(expr.index, scope, module, diagnostics);
555
+ expr.kind = 'stringByte';
556
+ expr.string = expr.array;
557
+ delete expr.array;
558
+ delete expr.elementType;
559
+ return;
560
+ }
561
+ rewriteArrayAccess(expr, scope, module, diagnostics);
562
+ return;
563
+ }
564
+ case 'call': {
565
+ const out = scope.get(expr.name);
566
+ if (out === undefined) {
567
+ diagnostics.push(diagnostic(
568
+ Codes.UNRESOLVED_NAME,
569
+ `cannot find name '${expr.name}'`,
570
+ module.file, expr.start ?? 0, expr.length ?? 0,
571
+ ));
572
+ } else {
573
+ expr.original = expr.name;
574
+ expr.name = out;
575
+ }
576
+ for (const argument of expr.args) rewriteExpression(argument, scope, module, diagnostics);
577
+ // After the caller's own arguments: a filled-in default is already
578
+ // in the program's terms (its string slot rebased by its own module).
579
+ if (out !== undefined) { completeCall(expr, module, diagnostics); delete expr.original; }
580
+ return;
581
+ }
582
+ case 'memoryRead':
583
+ // `memory` names nothing to resolve — it is a compiler intrinsic, not
584
+ // an import — but its address argument can still reference a global.
585
+ rewriteExpression(expr.address, scope, module, diagnostics);
586
+ return;
587
+ case 'namespaceCall': {
588
+ const result = resolveNamespaceMember(module, expr.namespace, expr.member, 'functions');
589
+ if (!result.namespaceFound) {
590
+ diagnostics.push(diagnostic(
591
+ Codes.UNRESOLVED_NAME,
592
+ `cannot find namespace '${expr.namespace}'`,
593
+ module.file, expr.start ?? 0, expr.length ?? 0,
594
+ ));
595
+ } else if (!result.memberFound) {
596
+ diagnostics.push(diagnostic(
597
+ Codes.NO_SUCH_EXPORT,
598
+ `'${expr.member}' is not a function in namespace '${expr.namespace}'`,
599
+ module.file, expr.start ?? 0, expr.length ?? 0,
600
+ ));
601
+ } else {
602
+ // Once resolved, a namespace call IS a plain call — same shape the
603
+ // rest of the pipeline (and both backends) already understand.
604
+ expr.kind = 'call';
605
+ expr.original = `${expr.namespace}.${expr.member}`;
606
+ expr.name = result.targetModule.rename.get(result.value);
607
+ delete expr.namespace;
608
+ delete expr.member;
609
+ }
610
+ for (const argument of expr.args) rewriteExpression(argument, scope, module, diagnostics);
611
+ if (expr.kind === 'call') { completeCall(expr, module, diagnostics); delete expr.original; }
612
+ return;
613
+ }
614
+ case 'namespaceConst': {
615
+ const result = resolveNamespaceMember(module, expr.namespace, expr.member, 'consts');
616
+ const array = !result.namespaceFound && !module.globalsByName.has(expr.namespace)
617
+ ? globalNamed(module, expr.namespace) : null;
618
+ const stringConst = !result.namespaceFound && isImportedName(module, scope, expr.namespace)
619
+ && typeof module.constValues.get(expr.namespace) === 'object';
620
+ if (stringConst && expr.member === 'length') {
621
+ // `Label.length` on an imported string const: the literal's length byte.
622
+ expr.kind = 'stringLength';
623
+ expr.string = constExpression(module.constValues.get(expr.namespace));
624
+ delete expr.namespace;
625
+ delete expr.member;
626
+ return;
627
+ }
628
+ if (array?.stringCapacity && expr.member === 'length') {
629
+ // `name.length` on an imported string<N>: the length byte, at runtime.
630
+ const ref = { kind: 'ref', name: expr.namespace, start: expr.start, length: expr.length };
631
+ rewriteExpression(ref, scope, module, diagnostics);
632
+ expr.kind = 'stringLength';
633
+ expr.string = ref;
634
+ delete expr.namespace;
635
+ delete expr.member;
636
+ return;
637
+ }
638
+ if (array?.array && expr.member === 'length') {
639
+ // `buffer.length` on an imported array: lowering could not tell it
640
+ // from a namespace const, so it arrives as one. A number, like an
641
+ // own array's length is.
642
+ expr.kind = 'const';
643
+ expr.value = array.array;
644
+ delete expr.namespace;
645
+ delete expr.member;
646
+ delete expr.start;
647
+ delete expr.length;
648
+ return;
649
+ }
650
+ if (!result.namespaceFound) {
651
+ diagnostics.push(diagnostic(
652
+ Codes.UNRESOLVED_NAME,
653
+ `cannot find namespace '${expr.namespace}'`,
654
+ module.file, expr.start ?? 0, expr.length ?? 0,
655
+ ));
656
+ } else if (!result.memberFound) {
657
+ diagnostics.push(diagnostic(
658
+ Codes.NO_SUCH_EXPORT,
659
+ `'${expr.member}' is not a const in namespace '${expr.namespace}'`,
660
+ module.file, expr.start ?? 0, expr.length ?? 0,
661
+ ));
662
+ } else {
663
+ expr.kind = 'const';
664
+ expr.value = result.value;
665
+ delete expr.namespace;
666
+ delete expr.member;
667
+ delete expr.start;
668
+ delete expr.length;
669
+ }
670
+ return;
671
+ }
672
+ default: // 'const' names nothing
673
+ }
674
+ }
675
+
676
+ function rewriteStatement(statement, scope, module, diagnostics) {
677
+ switch (statement.kind) {
678
+ case 'assign': {
679
+ const out = scope.get(statement.target);
680
+ if (out === undefined && module.constValues.has(statement.target)) {
681
+ // The checker already reports a module's own consts; this is the
682
+ // imported one it could not see.
683
+ diagnostics.push(diagnostic(
684
+ Codes.ASSIGN_TO_CONST,
685
+ `'${statement.target}' is a const — a compile-time value with no storage — and cannot be assigned`,
686
+ module.file, statement.start ?? 0, statement.length ?? 0,
687
+ ));
688
+ } else if (out === undefined) {
689
+ diagnostics.push(diagnostic(
690
+ Codes.UNRESOLVED_NAME,
691
+ `cannot find name '${statement.target}'`,
692
+ module.file, statement.start ?? 0, statement.length ?? 0,
693
+ ));
694
+ } else if (isImportedName(module, scope, statement.target) && globalNamed(module, statement.target)?.stringCapacity) {
695
+ // `name = ...` on an imported string<N>: a copy, as for an own one.
696
+ const g = globalNamed(module, statement.target);
697
+ statement.kind = 'stringCopy';
698
+ statement.target = { kind: 'ref', name: statement.target, start: statement.start, length: statement.length };
699
+ statement.source = statement.value;
700
+ statement.capacity = g.stringCapacity;
701
+ delete statement.value;
702
+ rewriteStatement(statement, scope, module, diagnostics);
703
+ return;
704
+ } else if (isImportedName(module, scope, statement.target) && globalNamed(module, statement.target)?.array) {
705
+ // An imported array; an own one was refused when the module lowered.
706
+ diagnostics.push(diagnostic(
707
+ Codes.NOT_COMPILABLE,
708
+ `'${statement.target}' is an array: it is written one element at a time, ${statement.target}[i] = ...`,
709
+ module.file, statement.start ?? 0, statement.length ?? 0,
710
+ ));
711
+ } else {
712
+ statement.target = out;
713
+ }
714
+ rewriteExpression(statement.value, scope, module, diagnostics);
715
+ if (statement.value.kind === 'string' && out !== undefined && !scope.bound?.has(statement.target)) {
716
+ // A string const (own or imported, now inlined) into a number global.
717
+ diagnostics.push(diagnostic(
718
+ Codes.NOT_COMPILABLE,
719
+ `'${statement.target}' is not a string: a string is assigned to a string<N>`,
720
+ module.file, statement.value.start ?? 0, statement.value.length ?? 0,
721
+ ));
722
+ }
723
+ return;
724
+ }
725
+ case 'storeIndex':
726
+ if (isImportedName(module, scope, statement.array.name) && globalNamed(module, statement.array.name)?.stringCapacity) {
727
+ diagnostics.push(diagnostic(
728
+ Codes.NOT_COMPILABLE,
729
+ `a string is assigned whole (${statement.array.name} = "..."), not one character at a time`,
730
+ module.file, statement.start ?? 0, statement.length ?? 0,
731
+ ));
732
+ return;
733
+ }
734
+ rewriteArrayAccess(statement, scope, module, diagnostics, { store: true });
735
+ rewriteExpression(statement.value, scope, module, diagnostics);
736
+ return;
737
+ case 'stringCopy': {
738
+ // The source must be a string: a literal (checked against the
739
+ // capacity here, where the program's string table is), a string
740
+ // const, a parameter, or a string<N> — own or imported.
741
+ const sourceName = statement.source.kind === 'ref' ? statement.source.name : null;
742
+ if (sourceName !== null && !isStringName(module, scope, sourceName)) {
743
+ diagnostics.push(diagnostic(
744
+ Codes.NOT_COMPILABLE,
745
+ `'${statement.target.name}' is a string<${statement.capacity}>: it is assigned a string — a literal, a const, a parameter, or another string variable`,
746
+ module.file, statement.source.start ?? 0, statement.source.length ?? 0,
747
+ ));
748
+ }
749
+ rewriteExpression(statement.target, scope, module, diagnostics);
750
+ rewriteExpression(statement.source, scope, module, diagnostics);
751
+ if (statement.source.kind === 'const') {
752
+ diagnostics.push(diagnostic(
753
+ Codes.NOT_COMPILABLE,
754
+ `'${statement.target.name}' is a string<${statement.capacity}>: it is assigned a string, not a number`,
755
+ module.file, statement.start ?? 0, statement.length ?? 0,
756
+ ));
757
+ }
758
+ checkStringFits(statement.source, statement.capacity, module.program, module, diagnostics);
759
+ return;
760
+ }
761
+ case 'call': {
762
+ const out = scope.get(statement.name);
763
+ if (out === undefined) {
764
+ diagnostics.push(diagnostic(
765
+ Codes.UNRESOLVED_NAME,
766
+ `cannot find name '${statement.name}'`,
767
+ module.file, statement.start ?? 0, statement.length ?? 0,
768
+ ));
769
+ } else {
770
+ statement.original = statement.name;
771
+ statement.name = out;
772
+ }
773
+ for (const argument of statement.args) rewriteExpression(argument, scope, module, diagnostics);
774
+ if (out !== undefined) { completeCall(statement, module, diagnostics); delete statement.original; }
775
+ return;
776
+ }
777
+ case 'namespaceCall':
778
+ // Same shape whether reached as a statement or a subexpression —
779
+ // `rewriteExpression`'s handling already mutates it in place.
780
+ rewriteExpression(statement, scope, module, diagnostics);
781
+ return;
782
+ case 'memoryWrite':
783
+ rewriteExpression(statement.address, scope, module, diagnostics);
784
+ rewriteExpression(statement.value, scope, module, diagnostics);
785
+ return;
786
+ case 'memoryRead':
787
+ // Only reachable as a bare statement (the read result discarded).
788
+ rewriteExpression(statement.address, scope, module, diagnostics);
789
+ return;
790
+ case 'return':
791
+ if (statement.value) rewriteExpression(statement.value, scope, module, diagnostics);
792
+ return;
793
+ case 'local':
794
+ // The initialiser is evaluated before the name exists (`let x = x`
795
+ // reads the outer x, or nothing); then the local shadows.
796
+ rewriteExpression(statement.init, scope, module, diagnostics);
797
+ if (statement.init.kind === 'string') {
798
+ diagnostics.push(diagnostic(
799
+ Codes.NOT_COMPILABLE,
800
+ `a string cannot initialise a ${statement.type}: a string lives in a string<N> or a const`,
801
+ module.file, statement.init.start ?? 0, statement.init.length ?? 0,
802
+ ));
803
+ }
804
+ bindLocal(scope, statement.name);
805
+ return;
806
+ case 'if': {
807
+ rewriteExpression(statement.test, scope, module, diagnostics);
808
+ const thenScope = childScope(scope);
809
+ for (const s of statement.then) rewriteStatement(s, thenScope, module, diagnostics);
810
+ const elseScope = childScope(scope);
811
+ for (const s of statement.else ?? []) rewriteStatement(s, elseScope, module, diagnostics);
812
+ return;
813
+ }
814
+ case 'while': {
815
+ rewriteExpression(statement.test, scope, module, diagnostics);
816
+ const inner = childScope(scope);
817
+ for (const s of statement.body) rewriteStatement(s, inner, module, diagnostics);
818
+ return;
819
+ }
820
+ case 'for': {
821
+ // The initialiser's local is in scope for the test, the update, and
822
+ // the body, and gone after the loop.
823
+ const inner = childScope(scope);
824
+ if (statement.init) rewriteStatement(statement.init, inner, module, diagnostics);
825
+ if (statement.test) rewriteExpression(statement.test, inner, module, diagnostics);
826
+ if (statement.update) rewriteStatement(statement.update, inner, module, diagnostics);
827
+ const bodyScope = childScope(inner);
828
+ for (const s of statement.body) rewriteStatement(s, bodyScope, module, diagnostics);
829
+ return;
830
+ }
831
+ case 'block': {
832
+ const inner = childScope(scope);
833
+ for (const s of statement.body) rewriteStatement(s, inner, module, diagnostics);
834
+ return;
835
+ }
836
+ default: // 'break', 'continue' name nothing; 'asm' is opaque
837
+ }
838
+ }
839
+
840
+ /**
841
+ * The entry module's one export is the program.
842
+ *
843
+ * Exactly one thing may be exported from the entry module, it must be a
844
+ * function, and it must take no parameters: that function is what the 6502
845
+ * backend's synthesised C `main` calls and what the web host's worker calls,
846
+ * bare. Any name is fine (`main` is the convention, not a rule). Other
847
+ * modules — packages, libraries — export whatever they like; this rule is
848
+ * about the file a build starts from.
849
+ *
850
+ * @returns {{ name: string|null, diagnostics: object[] }} the entry
851
+ * function's source name (null when the rule failed).
852
+ */
853
+ function checkEntryExports(module) {
854
+ const diagnostics = [];
855
+ const at = (item, message) => diagnostics.push(diagnostic(
856
+ Codes.ENTRY_EXPORTS, message, module.file, item.start ?? 0, item.length ?? 0,
857
+ ));
858
+
859
+ const functions = module.ir.functions.filter((fn) => fn.exported);
860
+ const globals = [
861
+ ...module.ir.globals.filter((g) => g.exported),
862
+ ...(module.ir.consts ?? []).filter((c) => c.exported),
863
+ ];
864
+ const namespaces = (module.ir.namespaces ?? []).filter((ns) => ns.exported);
865
+
866
+ for (const g of globals) {
867
+ at(g, `the entry module may export only its entry function; '${g.name}' is a global`);
868
+ }
869
+ for (const ns of namespaces) {
870
+ at(ns, `the entry module may export only its entry function; '${ns.name}' is a namespace`);
871
+ }
872
+ if (functions.length === 0) {
873
+ if (globals.length === 0 && namespaces.length === 0) {
874
+ at({}, 'the entry module exports nothing; export exactly one function to be the program');
875
+ } else {
876
+ at({}, 'the entry module exports no function; export exactly one to be the program');
877
+ }
878
+ return { name: null, diagnostics };
879
+ }
880
+ if (functions.length > 1) {
881
+ for (const fn of functions) {
882
+ at(fn, `the entry module must export exactly one function, its entry point; '${fn.name}' is one of ${functions.length}`);
883
+ }
884
+ return { name: null, diagnostics };
885
+ }
886
+ const [entry] = functions;
887
+ if (entry.params.length > 0) {
888
+ at(entry, `the entry point '${entry.name}' must take no parameters`);
889
+ return { name: null, diagnostics };
890
+ }
891
+ return { name: diagnostics.length === 0 ? entry.name : null, diagnostics };
892
+ }
893
+
894
+ /**
895
+ * Link a program from its entry module.
896
+ *
897
+ * The full front end runs over every module in the graph, so the diagnostics
898
+ * returned cover all of them — the `sources` map carries each file's text for
899
+ * rendering positions. `ir` is null whenever there are diagnostics: a program
900
+ * with any error in any module is not linked. The linked IR carries `entry`,
901
+ * the output name of the entry module's one exported function (see
902
+ * checkEntryExports).
903
+ *
904
+ * @param {string} entryText The entry module's source.
905
+ * @param {string} entryFile Its absolute path, the root imports resolve from.
906
+ * @param {{ machine?: string, tags?: string[], profile?: string, frameRate?: number, facts?: object }} [options]
907
+ * `machine` is the target being built for; packages with target-
908
+ * conditional entries resolve to that machine's implementation, and any
909
+ * `.8bs` file with a `.<machine>.8bs` twin beside it resolves to the
910
+ * twin. `tags` are the hardware tags the build carries (an 8032 PET, an
911
+ * expanded VIC-20): a `.<machine>.<tag>.8bs` twin is taken before the
912
+ * machine's own, and two tags each with a twin is `8BS3004`. The older
913
+ * `profile` is accepted as one tag. `frameRate` (default 60) is the
914
+ * project's logical frame rate — see 8bs.config.ts — that every
915
+ * `#frames(...)` call in the graph folds against; `machine` is also what
916
+ * every `#system()` call folds to. `facts` is the build's hardware fact
917
+ * sheet (the merged `facts` of packages/cli/src/hardware.mjs's
918
+ * resolveHardware), what every `#fact(...)` folds from; a build that
919
+ * names a machine and reads a fact without one is `8BS1038`.
920
+ * @returns {{ ir: object|null, diagnostics: object[], sources: Map<string,string> }}
921
+ */
922
+ export function link(entryText, entryFile, options = {}) {
923
+ const diagnostics = [];
924
+ const sources = new Map();
925
+
926
+ const { modules, nativeSources } = loadGraph(entryText, entryFile, diagnostics, sources, options);
927
+ // modules[0] is the entry: loadGraph enqueues it before walking imports.
928
+ const entry = checkEntryExports(modules[0]);
929
+ diagnostics.push(...entry.diagnostics);
930
+ bindImports(modules, diagnostics);
931
+ if (diagnostics.length > 0) return { ir: null, diagnostics, sources };
932
+
933
+ assignOutputNames(modules);
934
+ resolvePendingConsts(modules, diagnostics);
935
+ if (diagnostics.length > 0) return { ir: null, diagnostics, sources };
936
+
937
+ // `nativeSources` is not IR the backends translate — it is the list of
938
+ // files a backend passes through to its toolchain untouched (the 6502
939
+ // backend hands them to LLVM-MOS beside the generated C; the web backend
940
+ // has no use for 6502 assembly or CHR data and ignores it).
941
+ const ir = {
942
+ imports: [], globals: [], functions: [], strings: [], nativeSources,
943
+ entry: modules[0].rename.get(entry.name),
944
+ };
945
+ for (const module of modules) {
946
+ // One string table for the program, deduplicated across modules by
947
+ // content — "TICK" in two modules is one constant. `stringMap` takes a
948
+ // module's slot number to the program's. Every module's table first: a
949
+ // string const is imported by its slot, and the importer may come
950
+ // before the module that declares it.
951
+ module.stringMap = (module.ir.strings ?? []).map((s) => {
952
+ let index = ir.strings.findIndex((t) => t.text === s.text);
953
+ if (index === -1) { index = ir.strings.length; ir.strings.push(s); }
954
+ return index;
955
+ });
956
+ module.program = ir;
957
+ for (const [name, value] of module.ownConsts) {
958
+ if (typeof value === 'object') module.ownConsts.set(name, { string: module.stringMap[value.string] });
959
+ }
960
+ }
961
+ for (const module of modules) {
962
+ const scope = new Map(module.rename);
963
+ // Consts are inlined, not renamed: this module's own plus every import
964
+ // that names another module's const.
965
+ module.constValues = new Map(module.ownConsts);
966
+ for (const [local, binding] of module.bindings) {
967
+ if (binding.module.ownConsts.has(binding.name)) {
968
+ module.constValues.set(local, binding.module.ownConsts.get(binding.name));
969
+ continue;
970
+ }
971
+ scope.set(local, binding.module.rename.get(binding.name));
972
+ }
973
+ module.scope = scope;
974
+ }
975
+ // Every function's parameter defaults, resolved, and every function by
976
+ // its output name — before any body is rewritten, since a call in one
977
+ // module is completed from the parameters of a function in another.
978
+ ir.functionsByOutput = new Map();
979
+ const functionFiles = new Map(); // each linked function to its module's file, for checkHardwareHazards
980
+ for (const module of modules) {
981
+ for (const fn of module.ir.functions) {
982
+ for (const param of fn.params) {
983
+ if (param.default === undefined) continue;
984
+ if (param.default.kind === 'string') {
985
+ param.default = { kind: 'string', index: module.stringMap[param.default.index] };
986
+ } else if (param.default.kind !== 'const') {
987
+ param.default = { kind: 'const', value: resolveInitialiser(param.default, { type: param.type }, module.scope, module, diagnostics) };
988
+ }
989
+ }
990
+ ir.functionsByOutput.set(module.rename.get(fn.name), fn);
991
+ }
992
+ }
993
+ for (const module of modules) {
994
+ const { scope } = module;
995
+ for (const g of module.ir.globals) {
996
+ // An initialiser lowering left pending is a bare name or a
997
+ // namespace const: an imported const or `BorderColor.BLUE` is its
998
+ // value, and anything else cannot initialise a global — there is no
999
+ // code to run before the program starts. An array's pending
1000
+ // elements are resolved the same way, one at a time.
1001
+ if (Array.isArray(g.init)) {
1002
+ g.init = g.init.map((element) => (typeof element === 'object'
1003
+ ? resolveInitialiser(element, g, scope, module, diagnostics) : element));
1004
+ } else if (g.init !== null && typeof g.init === 'object' && g.init.kind === 'namespaceConst') {
1005
+ g.init = resolveInitialiser(g.init, g, scope, module, diagnostics);
1006
+ } else if (g.init !== null && typeof g.init === 'object') {
1007
+ const { name, start, length } = g.init;
1008
+ if (typeof module.constValues.get(name) === 'object') {
1009
+ diagnostics.push(diagnostic(
1010
+ Codes.NOT_COMPILABLE, `'${name}' is a string const; it cannot initialise a ${g.type}`,
1011
+ module.file, start ?? 0, length ?? 0,
1012
+ ));
1013
+ g.init = 0;
1014
+ } else if (module.constValues.has(name)) {
1015
+ g.init = module.constValues.get(name);
1016
+ } else {
1017
+ diagnostics.push(diagnostic(
1018
+ scope.has(name) ? Codes.NOT_COMPILABLE : Codes.UNRESOLVED_NAME,
1019
+ scope.has(name)
1020
+ ? `'${name}' is not a const, so it cannot initialise a global: an initialiser is a literal or a const`
1021
+ : `cannot find name '${name}'`,
1022
+ module.file, start ?? 0, length ?? 0,
1023
+ ));
1024
+ g.init = 0;
1025
+ }
1026
+ }
1027
+ g.name = module.rename.get(g.name);
1028
+ ir.globals.push(g);
1029
+ }
1030
+ for (const fn of module.ir.functions) {
1031
+ fn.name = module.rename.get(fn.name);
1032
+ // A parameter is never renamed and always shadows a same-named global
1033
+ // or import within its own function — ordinary lexical scoping, not a
1034
+ // collision the way two modules' globals can collide.
1035
+ const fnScope = childScope(scope);
1036
+ for (const param of fn.params) bindLocal(fnScope, param.name);
1037
+ for (const statement of fn.body) rewriteStatement(statement, fnScope, module, diagnostics);
1038
+ ir.functions.push(fn);
1039
+ functionFiles.set(fn, module.file);
1040
+ }
1041
+ }
1042
+
1043
+ if (diagnostics.length > 0) return { ir: null, diagnostics, sources };
1044
+ // After every body is rewritten — consts inlined, globals under their
1045
+ // output names — the writes the target refuses are visible as what they
1046
+ // are, whichever module spelled them and however it named the address.
1047
+ checkHardwareHazards(ir, options.machine, functionFiles, diagnostics);
1048
+ if (diagnostics.length > 0) return { ir: null, diagnostics, sources };
1049
+ ir.memory = memoryOf(ir);
1050
+ return { ir, diagnostics, sources };
1051
+ }
1052
+
1053
+ /**
1054
+ * What the program declares, in bytes: `variables` is RAM (every `let`,
1055
+ * arrays and `string<N>` included, at the size of its type; not an
1056
+ * `@address`, which names hardware, and not a const array, which is
1057
+ * data), `data` is constant program data (string literals with their
1058
+ * length byte, const arrays). Declared, not measured: a target's
1059
+ * toolchain may still drop a variable nothing reads, so the 6502 backend
1060
+ * reports what the linked program actually holds when it can.
1061
+ *
1062
+ * @returns {{ variables: number, data: number }}
1063
+ */
1064
+ export function memoryOf(ir) {
1065
+ let variables = 0;
1066
+ let data = 0;
1067
+ for (const g of ir.globals) {
1068
+ if (g.address !== null) continue;
1069
+ const bytes = storageBytes(g.type) * (g.array ?? 1);
1070
+ if (g.constant) data += bytes;
1071
+ else variables += bytes;
1072
+ }
1073
+ for (const s of ir.strings ?? []) data += 1 + s.bytes.length;
1074
+ return { variables, data };
1075
+ }