@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,383 @@
1
+ // The lexer: raw text in, tokens out.
2
+ //
3
+ // First layer of the pipeline described in docs/compiler.md. It is deliberately
4
+ // the only layer that exists today, because it is the one that pays off before
5
+ // a parser does: it finds unterminated strings, stray characters, and unbalanced
6
+ // brackets, which is already enough to put real errors under the cursor.
7
+ //
8
+ // Every token carries its offset and length. Diagnostics are built from those,
9
+ // so a position is never recomputed by guesswork later.
10
+ import { Codes, diagnostic } from '../diagnostics/index.mjs';
11
+ import { INTEGER_TYPE_NAMES } from '../types/index.mjs';
12
+
13
+ export const TokenKind = {
14
+ Comment: 'comment',
15
+ String: 'string',
16
+ // A backtick string with `${...}` fields — `TICK ${ticks} OPTION ${option}`.
17
+ // One token for the whole thing; `parts` records where its literal text
18
+ // and its field sources sit, and the parser re-lexes each field.
19
+ Template: 'template',
20
+ Number: 'number',
21
+ Identifier: 'identifier',
22
+ Keyword: 'keyword',
23
+ Type: 'type',
24
+ Decorator: 'decorator',
25
+ // `#frames` — a function the compiler evaluates, never the target. The
26
+ // `#` is the one spelling that says "8bitscript resolves this before any
27
+ // target toolchain runs"; a plain `name(...)` always runs on the machine.
28
+ CompileTime: 'compileTime',
29
+ AsmBlock: 'asm',
30
+ Punctuation: 'punctuation',
31
+ Operator: 'operator',
32
+ };
33
+
34
+ export const KEYWORDS = new Set([
35
+ 'let', 'const', 'function', 'return', 'export', 'import', 'from', 'as',
36
+ 'if', 'else', 'while', 'for', 'do', 'break', 'continue',
37
+ 'switch', 'case', 'default', 'true', 'false', 'asm6502', 'namespace',
38
+ ]);
39
+
40
+ // Every primitive integer spelling comes from the shared registry — the
41
+ // canonical names (`utinyint`, `int`, ...) and the low-level aliases (`u8`,
42
+ // `i32`, ...) — so this set can't drift out of sync with the checker, the
43
+ // backends, or hover/completion.
44
+ export const TYPE_NAMES = new Set([
45
+ ...INTEGER_TYPE_NAMES,
46
+ 'bool', 'void', 'string', 'ptr', 'array', 'volatile',
47
+ ]);
48
+
49
+ const BRACKET_PAIRS = { ')': '(', ']': '[', '}': '{' };
50
+ const OPEN_BRACKETS = new Set(['(', '[', '{']);
51
+
52
+ // Operators, longest first, matched by maximal munch against this list only.
53
+ // Greedily globbing operator characters is how `x=-1` ends up lexed as the
54
+ // non-operator `=-`; matching real operators cannot produce a token that no
55
+ // rule of the language recognises.
56
+ const OPERATORS = [
57
+ '<<=', '>>=',
58
+ '==', '!=', '<=', '>=', '&&', '||', '<<', '>>', '++', '--',
59
+ '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=',
60
+ '+', '-', '*', '/', '%', '&', '|', '^', '~', '!', '<', '>', '=',
61
+ '?', ':', ';', ',', '.',
62
+ ];
63
+
64
+ const DIGITS_FOR_RADIX = { 2: /[01_]/, 10: /[0-9_]/, 16: /[0-9a-fA-F_]/ };
65
+
66
+ // `$` is not an identifier character: it introduces hex literals ($900F). A
67
+ // language aimed at this hardware gives the assembly spelling priority.
68
+ const isIdentStart = (c) => /[A-Za-z_]/.test(c);
69
+ const isIdentPart = (c) => /[A-Za-z0-9_]/.test(c);
70
+ const isDigit = (c) => c >= '0' && c <= '9';
71
+
72
+ /**
73
+ * Tokenize a source file.
74
+ *
75
+ * Always returns both tokens and diagnostics: lexing never throws, because an
76
+ * editor asks for tokens on every keystroke and half-typed source is the normal
77
+ * case, not an exceptional one.
78
+ *
79
+ * @param {string} text
80
+ * @param {string} file
81
+ * @returns {{ tokens: object[], diagnostics: object[] }}
82
+ */
83
+ export function tokenize(text, file = '<unknown>') {
84
+ const tokens = [];
85
+ const diagnostics = [];
86
+ const brackets = [];
87
+ let i = 0;
88
+
89
+ const push = (kind, start, end, extra = {}) =>
90
+ tokens.push({ kind, start, length: end - start, text: text.slice(start, end), ...extra });
91
+
92
+ while (i < text.length) {
93
+ const c = text[i];
94
+
95
+ if (c === ' ' || c === '\t' || c === '\r' || c === '\n') {
96
+ i += 1;
97
+ continue;
98
+ }
99
+
100
+ // Comments, both spellings.
101
+ if (c === '/' && text[i + 1] === '/') {
102
+ const start = i;
103
+ while (i < text.length && text[i] !== '\n') i += 1;
104
+ push(TokenKind.Comment, start, i);
105
+ continue;
106
+ }
107
+ if (c === '/' && text[i + 1] === '*') {
108
+ const start = i;
109
+ i += 2;
110
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
111
+ if (i >= text.length) {
112
+ diagnostics.push(
113
+ diagnostic(
114
+ Codes.UNTERMINATED_BLOCK_COMMENT,
115
+ 'unterminated block comment',
116
+ file, start, text.length - start,
117
+ ),
118
+ );
119
+ push(TokenKind.Comment, start, text.length);
120
+ break;
121
+ }
122
+ i += 2;
123
+ push(TokenKind.Comment, start, i);
124
+ continue;
125
+ }
126
+
127
+ // Strings. A newline ends the search: an unterminated string should report
128
+ // on its own line rather than swallowing the rest of the file.
129
+ if (c === '"' || c === "'") {
130
+ const start = i;
131
+ const quote = c;
132
+ i += 1;
133
+ let closed = false;
134
+ while (i < text.length) {
135
+ if (text[i] === '\\') { i += 2; continue; }
136
+ if (text[i] === quote) { i += 1; closed = true; break; }
137
+ if (text[i] === '\n') break;
138
+ i += 1;
139
+ }
140
+ if (!closed) {
141
+ diagnostics.push(
142
+ diagnostic(
143
+ Codes.UNTERMINATED_STRING,
144
+ 'unterminated string literal',
145
+ file, start, i - start,
146
+ ),
147
+ );
148
+ }
149
+ push(TokenKind.String, start, i, closed ? {} : { unterminated: true });
150
+ continue;
151
+ }
152
+
153
+ // Template strings: `TICK ${ticks} OPTION ${option}`. Lexed as one token
154
+ // so the parser sees a single literal, with `parts` marking each run of
155
+ // text and each `${...}` field's source span (the field's own tokens are
156
+ // produced by the parser re-lexing that span, offsets intact). Braces
157
+ // nest inside a field so a future `{`-bearing expression still ends at
158
+ // the right `}`. Single-line, like the other strings.
159
+ if (c === '`') {
160
+ const start = i;
161
+ i += 1;
162
+ const parts = [];
163
+ let textStart = i;
164
+ let closed = false;
165
+ const flushText = (end) => {
166
+ if (end > textStart) parts.push({ kind: 'text', start: textStart, end });
167
+ };
168
+ while (i < text.length) {
169
+ if (text[i] === '\\') { i += 2; continue; }
170
+ if (text[i] === '`') { flushText(i); i += 1; closed = true; break; }
171
+ if (text[i] === '\n') break;
172
+ if (text[i] === '$' && text[i + 1] === '{') {
173
+ flushText(i);
174
+ const fieldStart = i;
175
+ i += 2;
176
+ const sourceStart = i;
177
+ let depth = 1;
178
+ while (i < text.length && text[i] !== '\n') {
179
+ if (text[i] === '{') depth += 1;
180
+ else if (text[i] === '}') { depth -= 1; if (depth === 0) break; }
181
+ i += 1;
182
+ }
183
+ if (depth !== 0) {
184
+ diagnostics.push(diagnostic(
185
+ Codes.UNTERMINATED_STRING, "unterminated '${' field in template string", file, fieldStart, i - fieldStart,
186
+ ));
187
+ // Recorded anyway: a field being typed has no `}` yet, and
188
+ // hover and completion inside it are exactly what a person
189
+ // wants at that moment (half-typed source is the normal input,
190
+ // see this file's header).
191
+ parts.push({ kind: 'field', start: fieldStart, end: i, sourceStart, sourceEnd: i });
192
+ closed = true; // one diagnostic, not this plus "unterminated template"
193
+ break;
194
+ }
195
+ parts.push({ kind: 'field', start: fieldStart, end: i + 1, sourceStart, sourceEnd: i });
196
+ i += 1;
197
+ textStart = i;
198
+ continue;
199
+ }
200
+ i += 1;
201
+ }
202
+ if (!closed) {
203
+ diagnostics.push(diagnostic(
204
+ Codes.UNTERMINATED_STRING, 'unterminated template string', file, start, i - start,
205
+ ));
206
+ }
207
+ push(TokenKind.Template, start, i, { parts });
208
+ continue;
209
+ }
210
+
211
+ // Numbers, in the C spellings and the assembly spellings.
212
+ //
213
+ // `%` is also the modulo operator, so `%101` is a binary literal only where
214
+ // a value is expected: after an identifier, a literal, or a closing bracket
215
+ // the `%` in `x%2` has to be modulo. `$` has no such conflict.
216
+ const prev = tokens[tokens.length - 1];
217
+ const prevIsOperand = prev && (
218
+ prev.kind === TokenKind.Identifier ||
219
+ prev.kind === TokenKind.Number ||
220
+ prev.kind === TokenKind.String ||
221
+ prev.kind === TokenKind.Template ||
222
+ prev.kind === TokenKind.Type ||
223
+ [')', ']'].includes(prev.text)
224
+ );
225
+ const startsBinaryLiteral = c === '%' && /[01]/.test(text[i + 1] ?? '') && !prevIsOperand;
226
+ const startsHexLiteral = c === '$' && /[0-9a-fA-F]/.test(text[i + 1] ?? '');
227
+
228
+ if (isDigit(c) || startsHexLiteral || startsBinaryLiteral) {
229
+ const start = i;
230
+ let radix = 10;
231
+ if (c === '$') { radix = 16; i += 1; }
232
+ else if (c === '%') { radix = 2; i += 1; }
233
+ else if (c === '0' && /[xX]/.test(text[i + 1] ?? '')) { radix = 16; i += 2; }
234
+ else if (c === '0' && /[bB]/.test(text[i + 1] ?? '')) { radix = 2; i += 2; }
235
+ const digitsStart = i;
236
+ const digitPattern = DIGITS_FOR_RADIX[radix];
237
+ while (i < text.length && digitPattern.test(text[i])) i += 1;
238
+ const digits = text.slice(digitsStart, i).replace(/_/g, '');
239
+ if (digits === '') {
240
+ // `0x` with nothing after it. Reported here so the value can never be
241
+ // a silent NaN travelling through the checker.
242
+ diagnostics.push(
243
+ diagnostic(
244
+ Codes.INVALID_NUMBER,
245
+ `invalid number literal '${text.slice(start, i)}'`,
246
+ file, start, i - start,
247
+ ),
248
+ );
249
+ push(TokenKind.Number, start, i, { value: 0, radix });
250
+ continue;
251
+ }
252
+ // A decimal fraction — `0.5` — radix 10 only, and only when a digit
253
+ // actually follows the `.`: a bare `1.` stays `1` then the `.`
254
+ // operator (unchanged), and `array<u8, 16>`-style code elsewhere in
255
+ // the grammar never wants a Number token to swallow a trailing `.`.
256
+ // Recorded as an exact numerator/denominator pair, never as a
257
+ // floating-point value used for arithmetic — the only thing that ever
258
+ // reads `isDecimal`/`numerator`/`denominator` is the `#frames(...)`
259
+ // compile-time fold (packages/compiler/src/fold), which works in
260
+ // exact integers throughout; `value` here is cosmetic only (kept for
261
+ // uniformity with plain-integer Number tokens).
262
+ if (radix === 10 && text[i] === '.' && isDigit(text[i + 1] ?? '')) {
263
+ i += 1; // the '.'
264
+ const fracStart = i;
265
+ while (i < text.length && digitPattern.test(text[i])) i += 1;
266
+ const fracDigits = text.slice(fracStart, i).replace(/_/g, '');
267
+ push(TokenKind.Number, start, i, {
268
+ value: Number.parseFloat(text.slice(start, i)),
269
+ radix,
270
+ isDecimal: true,
271
+ numerator: Number.parseInt(digits + fracDigits, 10),
272
+ denominator: 10 ** fracDigits.length,
273
+ });
274
+ continue;
275
+ }
276
+
277
+ push(TokenKind.Number, start, i, { value: Number.parseInt(digits, radix), radix });
278
+ continue;
279
+ }
280
+
281
+ // `#frames(...)` — a compile-time function (see TokenKind.CompileTime).
282
+ if (c === '#' && isIdentStart(text[i + 1] ?? '')) {
283
+ const start = i;
284
+ i += 1;
285
+ while (i < text.length && isIdentPart(text[i])) i += 1;
286
+ push(TokenKind.CompileTime, start, i);
287
+ continue;
288
+ }
289
+
290
+ // `@address(0x900F)` and friends.
291
+ if (c === '@' && isIdentStart(text[i + 1] ?? '')) {
292
+ const start = i;
293
+ i += 1;
294
+ while (i < text.length && isIdentPart(text[i])) i += 1;
295
+ push(TokenKind.Decorator, start, i);
296
+ continue;
297
+ }
298
+
299
+ if (isIdentStart(c)) {
300
+ const start = i;
301
+ while (i < text.length && isIdentPart(text[i])) i += 1;
302
+ const word = text.slice(start, i);
303
+ const kind = KEYWORDS.has(word)
304
+ ? TokenKind.Keyword
305
+ : TYPE_NAMES.has(word)
306
+ ? TokenKind.Type
307
+ : TokenKind.Identifier;
308
+ push(kind, start, i);
309
+
310
+ // The body of an `asm6502 { ... }` block is 6502 assembly, not
311
+ // 8BitScript. Lexing it as 8BitScript is simply wrong — `lda #$06` would
312
+ // report `#` as an unexpected character — so the whole block is taken as
313
+ // one opaque token and handed to the backend untouched.
314
+ if (word === 'asm6502') {
315
+ let j = i;
316
+ while (j < text.length && /\s/.test(text[j])) j += 1;
317
+ if (text[j] === '{') {
318
+ const bodyStart = j;
319
+ let depth = 0;
320
+ while (j < text.length) {
321
+ if (text[j] === '{') depth += 1;
322
+ else if (text[j] === '}') {
323
+ depth -= 1;
324
+ if (depth === 0) { j += 1; break; }
325
+ }
326
+ j += 1;
327
+ }
328
+ if (depth !== 0) {
329
+ diagnostics.push(
330
+ diagnostic(
331
+ Codes.UNTERMINATED_ASM_BLOCK,
332
+ 'unterminated asm6502 block',
333
+ file, bodyStart, text.length - bodyStart,
334
+ ),
335
+ );
336
+ }
337
+ push(TokenKind.AsmBlock, bodyStart, j);
338
+ i = j;
339
+ }
340
+ }
341
+ continue;
342
+ }
343
+
344
+ if (OPEN_BRACKETS.has(c)) {
345
+ brackets.push({ char: c, offset: i });
346
+ push(TokenKind.Punctuation, i, i + 1);
347
+ i += 1;
348
+ continue;
349
+ }
350
+ if (c in BRACKET_PAIRS) {
351
+ const top = brackets.pop();
352
+ if (!top || top.char !== BRACKET_PAIRS[c]) {
353
+ diagnostics.push(
354
+ diagnostic(Codes.UNMATCHED_BRACKET, `unmatched '${c}'`, file, i, 1),
355
+ );
356
+ if (top) brackets.push(top);
357
+ }
358
+ push(TokenKind.Punctuation, i, i + 1);
359
+ i += 1;
360
+ continue;
361
+ }
362
+
363
+ const operator = OPERATORS.find((op) => text.startsWith(op, i));
364
+ if (operator) {
365
+ push(TokenKind.Operator, i, i + operator.length);
366
+ i += operator.length;
367
+ continue;
368
+ }
369
+
370
+ diagnostics.push(
371
+ diagnostic(Codes.UNEXPECTED_CHARACTER, `unexpected character '${c}'`, file, i, 1),
372
+ );
373
+ i += 1;
374
+ }
375
+
376
+ for (const open of brackets) {
377
+ diagnostics.push(
378
+ diagnostic(Codes.UNCLOSED_BRACKET, `unclosed '${open.char}'`, file, open.offset, 1),
379
+ );
380
+ }
381
+
382
+ return { tokens, diagnostics };
383
+ }
@@ -0,0 +1,121 @@
1
+ // Hardware hazards: writes a target documents as able to damage the machine.
2
+ //
3
+ // The root AGENTS.md warns against turning one game's trivia into a compiler
4
+ // diagnostic. This table is the deliberate exception, and its bar is high: a
5
+ // write goes here only when the target's own documentation says it can
6
+ // destroy hardware — not crash the program, not garble the screen, destroy
7
+ // hardware. There is one entry today. The PET's "killer poke" (POKE 59458,62
8
+ // — `$E842`, the 6522 VIA's data-direction register B) makes port bit PB5
9
+ // an output. PB5 is the vertical-retrace *input*; on the CRTC models (the
10
+ // 12-inch 4032, 8032 and later) it is wired to the CRTC's vertical sync,
11
+ // which also drives the monitor, and driving that line drags its level down
12
+ // until the monitor's vertical deflection misbehaves and, in time, the
13
+ // flyback fails. See packages/pet/AGENTS.md ("Hazards").
14
+ //
15
+ // The rule is narrow on purpose: the register has legitimate uses (its
16
+ // other bits steer the cassette motor and IEEE-488 lines), so a write whose
17
+ // value 8bitscript can see at compile time — a literal or a const — with bit
18
+ // 5 clear is allowed, and only a write it cannot prove safe is refused: a
19
+ // constant with bit 5 set, or a runtime value. Reads are always fine.
20
+ //
21
+ // The check runs in the linker, not the checker, because it needs two
22
+ // things only the linker has: the machine being built for, and every const
23
+ // already inlined (so `memory.write(VIA_DDRB, 62)` with `VIA_DDRB` imported
24
+ // from another module is the same write as the literal). `8bs check` and the
25
+ // editor analyse files without a machine, so this class of diagnostic is a
26
+ // build-time one; docs/compiler.md and docs/language-server.md say so.
27
+
28
+ import { Codes, diagnostic } from '../diagnostics/index.mjs';
29
+
30
+ /**
31
+ * Per machine: the addresses whose writes are checked. `forbiddenBits` is
32
+ * the mask a compile-time value must have clear to be allowed; `name` and
33
+ * `why` are the words of the message.
34
+ */
35
+ export const HARDWARE_HAZARDS = {
36
+ pet: [
37
+ {
38
+ address: 0xE842,
39
+ forbiddenBits: 0x20,
40
+ name: 'the VIA\'s data-direction register B ($E842, POKE 59458)',
41
+ why: 'a value with bit 5 set makes PB5 an output — the PET "killer poke", which drives the monitor\'s '
42
+ + 'vertical sync on the CRTC models (4032, 8032 and later) and can destroy a 12-inch PET display. '
43
+ + 'Only a compile-time value with bit 5 clear may be written here; see packages/pet/AGENTS.md',
44
+ },
45
+ ],
46
+ };
47
+
48
+ const hex = (n) => `$${n.toString(16).toUpperCase().padStart(4, '0')}`;
49
+
50
+ /**
51
+ * Walk the linked program's functions for writes to a hazardous address on
52
+ * `machine`, reporting each as `8BS3003` in the file it was written in.
53
+ *
54
+ * Three ways to write an address are covered: `memory.write(address, v)`
55
+ * with a compile-time address; assignment to a scalar global declared
56
+ * `@address(...)` there; and an element store into an `@address` array that
57
+ * covers it (a compile-time index that lands on it, or a runtime index,
58
+ * which cannot be proved to miss). Everything else — a runtime address to
59
+ * `memory.write`, say — is the program's own responsibility, as on the
60
+ * machine.
61
+ *
62
+ * @param {object} ir the linked IR (globals carry output names and addresses)
63
+ * @param {string|undefined} machine
64
+ * @param {Map<object, string>} fileOf each function to the file it came from
65
+ * @param {object[]} diagnostics
66
+ */
67
+ export function checkHardwareHazards(ir, machine, fileOf, diagnostics) {
68
+ const hazards = machine ? HARDWARE_HAZARDS[machine] : undefined;
69
+ if (!hazards) return;
70
+
71
+ // The globals that map a hazardous address, by output name: scalars at
72
+ // it exactly, arrays that span it (with the element offset that is it).
73
+ const scalars = new Map();
74
+ const arrays = new Map();
75
+ for (const g of ir.globals) {
76
+ if (g.address === null || g.address === undefined) continue;
77
+ for (const hazard of hazards) {
78
+ if (!g.array && g.address === hazard.address) scalars.set(g.name, hazard);
79
+ if (g.array && g.address <= hazard.address && hazard.address < g.address + g.array) {
80
+ arrays.set(g.name, { hazard, offset: hazard.address - g.address });
81
+ }
82
+ }
83
+ }
84
+
85
+ const safe = (value, hazard) => value?.kind === 'const' && (value.value & hazard.forbiddenBits) === 0;
86
+ const report = (node, file, hazard, how) => diagnostics.push(diagnostic(
87
+ Codes.HARDWARE_HAZARD,
88
+ `${how} ${hazard.name} on the ${machine}: ${hazard.why}`,
89
+ file, node.start ?? 0, node.length ?? 0,
90
+ ));
91
+ const describe = (value) => (value?.kind === 'const' ? `writing ${value.value} to` : 'writing a runtime value to');
92
+
93
+ const visit = (s, file) => {
94
+ if (s.kind === 'memoryWrite' && s.address?.kind === 'const') {
95
+ const hazard = hazards.find((h) => h.address === s.address.value);
96
+ if (hazard && !safe(s.value, hazard)) report(s, file, hazard, describe(s.value));
97
+ } else if (s.kind === 'assign' && scalars.has(s.target)) {
98
+ const hazard = scalars.get(s.target);
99
+ if (!safe(s.value, hazard)) report(s, file, hazard, describe(s.value));
100
+ } else if (s.kind === 'storeIndex' && arrays.has(s.array?.name)) {
101
+ const { hazard, offset } = arrays.get(s.array.name);
102
+ const index = s.index;
103
+ if (index?.kind === 'const' && index.value !== offset) return;
104
+ if (!safe(s.value, hazard)) {
105
+ const how = index?.kind === 'const'
106
+ ? describe(s.value)
107
+ : `an element store with a runtime index cannot be proved to miss ${hex(hazard.address)}, so this counts as writing to`;
108
+ report(s, file, hazard, how);
109
+ }
110
+ }
111
+ };
112
+ const walk = (body, file) => {
113
+ for (const s of body) {
114
+ visit(s, file);
115
+ if (s.kind === 'if') { walk(s.then, file); if (s.else) walk(s.else, file); }
116
+ else if (s.kind === 'while' || s.kind === 'block') walk(s.body, file);
117
+ else if (s.kind === 'for') { if (s.init) visit(s.init, file); if (s.update) visit(s.update, file); walk(s.body, file); }
118
+ }
119
+ };
120
+ for (const fn of ir.functions) walk(fn.body, fileOf.get(fn) ?? '');
121
+ }