@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.
- package/LICENSE +21 -0
- package/index.mjs +91 -0
- package/package.json +29 -0
- package/src/ast/index.mjs +90 -0
- package/src/checker/index.mjs +294 -0
- package/src/diagnostics/index.mjs +179 -0
- package/src/fold/facts.mjs +209 -0
- package/src/fold/index.mjs +412 -0
- package/src/intellisense/index.mjs +558 -0
- package/src/ir/index.mjs +1422 -0
- package/src/lexer/index.mjs +383 -0
- package/src/linker/hazards.mjs +121 -0
- package/src/linker/index.mjs +1075 -0
- package/src/parser/index.mjs +795 -0
- package/src/resolver/index.mjs +534 -0
- package/src/templates/index.mjs +278 -0
- package/src/types/index.mjs +116 -0
|
@@ -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
|
+
}
|