@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,534 @@
|
|
|
1
|
+
// Module resolution.
|
|
2
|
+
//
|
|
3
|
+
// This is the one layer that touches the filesystem, which is why it lives
|
|
4
|
+
// apart from the lexer and the checker: those are pure functions over text, and
|
|
5
|
+
// keeping them that way means they stay trivially testable and can never fail
|
|
6
|
+
// because of a broken dependency on disk.
|
|
7
|
+
//
|
|
8
|
+
// It works on tokens rather than the AST on purpose: imports sit at the top of
|
|
9
|
+
// a file, and a syntax error further down should never stop them being checked.
|
|
10
|
+
// Token scanning degrades gracefully where a parse does not.
|
|
11
|
+
//
|
|
12
|
+
// The contract implemented here is the one specified in docs/packages.md: a
|
|
13
|
+
// bare specifier resolves through node_modules to a package whose package.json
|
|
14
|
+
// carries an "8bitscript" entry field, a package subpath (`@scope/name/thing`)
|
|
15
|
+
// resolves through that package's "8bitscript".exports map, and Node itself
|
|
16
|
+
// is never asked to understand a .8bs file.
|
|
17
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
18
|
+
import { basename, dirname, isAbsolute, join, resolve as resolvePath } from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { Codes, diagnostic } from '../diagnostics/index.mjs';
|
|
21
|
+
import { TokenKind } from '../lexer/index.mjs';
|
|
22
|
+
|
|
23
|
+
/** A bare specifier naming exactly one package: `name` or `@scope/name`. */
|
|
24
|
+
const BARE_PACKAGE = /^(?:@[^/\s]+\/[^/\s]+|[^@./\s][^/\s]*)$/;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A package name followed by a subpath: `name/thing`, `@scope/name/thing`,
|
|
28
|
+
* or deeper. Group 1 is the package, group 2 the subpath — the key the
|
|
29
|
+
* package's `"8bitscript".exports` map is looked up by, as `./thing`, the
|
|
30
|
+
* same shape Node's own "exports" field uses so nobody learns a second one.
|
|
31
|
+
*/
|
|
32
|
+
const PACKAGE_SUBPATH = /^((?:@[^/\s]+\/)?[^@./\s][^/\s]*)\/([^\s]+)$/;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every machine a program can be built for — the names `8bs build --target`
|
|
36
|
+
* accepts, the keys a target-conditional entry is written in, and the
|
|
37
|
+
* suffixes a system-specific source file carries (see variantOf). The CLI
|
|
38
|
+
* reads this list rather than keeping its own, so a new target is added in
|
|
39
|
+
* exactly one place.
|
|
40
|
+
*/
|
|
41
|
+
export const MACHINES = Object.freeze([
|
|
42
|
+
'vic20', 'c64', 'pet', 'c128', 'atari8', 'nes', 'cx16', 'mega65', 'web',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The system-specific twin of a `.8bs` path: `main.8bs` on the NES is
|
|
47
|
+
* `main.nes.8bs`, the machine's name slotted in before the extension. The
|
|
48
|
+
* portable file keeps the plain name; a machine that needs its own version
|
|
49
|
+
* of that file gets the suffixed one beside it, and a build for that
|
|
50
|
+
* machine picks it up without anything else having to name it. Any file in
|
|
51
|
+
* the graph can have one — the entry, a module it imports, a package's
|
|
52
|
+
* entry — because the rule is about files, not about configuration.
|
|
53
|
+
*
|
|
54
|
+
* With a hardware tag as well — one of the tags the build's hardware
|
|
55
|
+
* carries (`8032` on a PET built as an 8032, `expanded` on a VIC-20 with
|
|
56
|
+
* 8K or more) — the twin is one level more specific:
|
|
57
|
+
* `geometry.pet.8032.8bs`, the tag after the machine's name. It is looked
|
|
58
|
+
* for first, and a machine's plain twin is what every other build of that
|
|
59
|
+
* machine gets.
|
|
60
|
+
*/
|
|
61
|
+
export function variantOf(path, machine, tag) {
|
|
62
|
+
const stem = path.slice(0, -'.8bs'.length);
|
|
63
|
+
return tag ? `${stem}.${machine}.${tag}.8bs` : `${stem}.${machine}.8bs`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The hardware tags a build carries, from the options a caller passes:
|
|
68
|
+
* `tags` outright, or the older single `profile`, which is one tag.
|
|
69
|
+
*/
|
|
70
|
+
export function tagsOf(options = {}) {
|
|
71
|
+
if (Array.isArray(options.tags)) return options.tags;
|
|
72
|
+
return options.profile ? [options.profile] : [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether a path already names one machine's version — `x.nes.8bs` — or
|
|
77
|
+
* one hardware tag's: `x.pet.8032.8bs`. A tag is one word (letters,
|
|
78
|
+
* digits, `_`, `-`), so `x.pet.8032.8bs` is recognised and `x.data.8bs` is
|
|
79
|
+
* not: `data` is no machine.
|
|
80
|
+
*/
|
|
81
|
+
export function isVariantPath(path) {
|
|
82
|
+
const stem = path.slice(0, -'.8bs'.length);
|
|
83
|
+
return MACHINES.some((machine) => stem.endsWith(`.${machine}`)
|
|
84
|
+
|| new RegExp(`\\.${machine}\\.[A-Za-z0-9_-]+$`).test(stem));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every system-specific twin of `path` that exists beside it — a machine's
|
|
89
|
+
* (`x.nes.8bs`) or a tag's (`x.pet.8032.8bs`) — as `{ machine, tag }`
|
|
90
|
+
* pairs, `tag` undefined for a machine's plain twin. One directory
|
|
91
|
+
* listing, filtered by name, rather than a probe per machine per possible
|
|
92
|
+
* tag: the tags are not the resolver's to know.
|
|
93
|
+
*/
|
|
94
|
+
function variantsPresent(path) {
|
|
95
|
+
const stem = basename(path, '.8bs');
|
|
96
|
+
let entries;
|
|
97
|
+
try {
|
|
98
|
+
entries = readdirSync(dirname(path));
|
|
99
|
+
} catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
const found = [];
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (!entry.startsWith(`${stem}.`) || !entry.endsWith('.8bs')) continue;
|
|
105
|
+
const suffix = entry.slice(stem.length + 1, -'.8bs'.length).split('.');
|
|
106
|
+
if (!MACHINES.includes(suffix[0])) continue;
|
|
107
|
+
if (suffix.length === 1) found.push({ machine: suffix[0] });
|
|
108
|
+
else if (suffix.length === 2 && /^[A-Za-z0-9_-]+$/.test(suffix[1])) found.push({ machine: suffix[0], tag: suffix[1] });
|
|
109
|
+
}
|
|
110
|
+
return found;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Pick the file a `.8bs` path actually means, given the machine — and the
|
|
115
|
+
* hardware tags its build carries — in hand.
|
|
116
|
+
*
|
|
117
|
+
* With a machine: a tag's variant if exactly one of the build's tags has
|
|
118
|
+
* one, else the machine's, else the plain file. Two tags each with a
|
|
119
|
+
* variant of their own is `8BS3004`: the file has two versions that both
|
|
120
|
+
* claim this build, and the resolver will not guess. With none of those,
|
|
121
|
+
* but with *other* machines' or tags' variants present, the file
|
|
122
|
+
* genuinely has nothing for this target — `8BS3002`, the same code a
|
|
123
|
+
* conditional package entry gives for a machine it has no branch for,
|
|
124
|
+
* because it is the same situation spelled in filenames. Without a
|
|
125
|
+
* machine (`8bs check` and the editor analyse files, not builds): the
|
|
126
|
+
* plain file if it exists, else `path: null` — "valid, and
|
|
127
|
+
* target-dependent" — if any variant does.
|
|
128
|
+
*
|
|
129
|
+
* A path that already names a machine's or a tag's version
|
|
130
|
+
* (`x.nes.8bs`, `x.pet.8032.8bs`) is taken literally: it is the explicit
|
|
131
|
+
* form, and stacking another suffix on it would mean nothing.
|
|
132
|
+
*
|
|
133
|
+
* Returns `null` when nothing exists at all, so the caller can report the
|
|
134
|
+
* missing file with the code that fits how the path was named.
|
|
135
|
+
*/
|
|
136
|
+
function chooseVariant(specifier, path, machine, tags = []) {
|
|
137
|
+
const base = existsSync(path);
|
|
138
|
+
if (isVariantPath(path)) return base ? { path } : null;
|
|
139
|
+
if (machine) {
|
|
140
|
+
const forTags = tags.filter((tag) => existsSync(variantOf(path, machine, tag)));
|
|
141
|
+
if (forTags.length > 1) {
|
|
142
|
+
return {
|
|
143
|
+
code: Codes.AMBIGUOUS_VARIANT,
|
|
144
|
+
message: `'${specifier}' has a version for each of this build's '${forTags.join("' and '")}' hardware — `
|
|
145
|
+
+ 'one file cannot serve two tags at once; give the build one of them, or one file both',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (forTags.length === 1) return { path: variantOf(path, machine, forTags[0]) };
|
|
149
|
+
const variant = variantOf(path, machine);
|
|
150
|
+
if (existsSync(variant)) return { path: variant };
|
|
151
|
+
if (base) return { path };
|
|
152
|
+
const others = variantsPresent(path);
|
|
153
|
+
if (others.length > 0) {
|
|
154
|
+
const names = [...new Set(others.map((v) => (v.tag ? `${v.machine} (${v.tag})` : v.machine)))];
|
|
155
|
+
const here = tags.length > 0 ? `${machine} target's ${tags.join(', ')} hardware` : `${machine} target`;
|
|
156
|
+
return {
|
|
157
|
+
code: Codes.NOT_ON_THIS_TARGET,
|
|
158
|
+
message: `'${specifier}' has no version for the ${here} (targets: ${names.join(', ')})`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
if (base) return { path };
|
|
164
|
+
if (variantsPresent(path).length > 0) return { path: null };
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Extract every `import ... from "specifier"` and bare `import "specifier"`.
|
|
170
|
+
*
|
|
171
|
+
* Token-level, because there is no parser. The scan is bounded: it gives up at
|
|
172
|
+
* a `;` or at any keyword that cannot continue an import, so a typo cannot pair
|
|
173
|
+
* an `import` with a string much further down the file.
|
|
174
|
+
*
|
|
175
|
+
* @param {object[]} tokens
|
|
176
|
+
* @returns {{ specifier: string, start: number, length: number }[]}
|
|
177
|
+
*/
|
|
178
|
+
export function findImports(tokens) {
|
|
179
|
+
const t = tokens.filter((tok) => tok.kind !== TokenKind.Comment);
|
|
180
|
+
const STOP = new Set(['let', 'const', 'function', 'export', 'import', 'return']);
|
|
181
|
+
const found = [];
|
|
182
|
+
|
|
183
|
+
for (let i = 0; i < t.length; i += 1) {
|
|
184
|
+
if (t[i].kind !== TokenKind.Keyword || t[i].text !== 'import') continue;
|
|
185
|
+
|
|
186
|
+
for (let j = i + 1; j < t.length; j += 1) {
|
|
187
|
+
const tok = t[j];
|
|
188
|
+
if (tok.text === ';') break;
|
|
189
|
+
if (tok.kind === TokenKind.Keyword && STOP.has(tok.text)) break;
|
|
190
|
+
|
|
191
|
+
if (tok.kind === TokenKind.String) {
|
|
192
|
+
// `import "x"` is fine; `import { a } from "x"` must have passed `from`.
|
|
193
|
+
const direct = j === i + 1;
|
|
194
|
+
const viaFrom = t.slice(i + 1, j).some((x) => x.text === 'from');
|
|
195
|
+
if (direct || viaFrom) {
|
|
196
|
+
const raw = tok.text;
|
|
197
|
+
const quote = raw[0];
|
|
198
|
+
// An unterminated string already reported 8BS1002; a second
|
|
199
|
+
// diagnostic on the same span would just be noise.
|
|
200
|
+
if (raw.length >= 2 && raw.endsWith(quote)) {
|
|
201
|
+
found.push({ specifier: raw.slice(1, -1), start: tok.start, length: tok.length });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return found;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The native sources a file brings with it by where it sits: the nearest
|
|
214
|
+
* package.json above it — the package boundary, as Node draws it — and
|
|
215
|
+
* that manifest's `"8bitscript".native` list, if any. A package's files
|
|
216
|
+
* are the package's code however they were reached: through its entry, a
|
|
217
|
+
* subpath, a relative import between two of its files, or as the entry of
|
|
218
|
+
* a build inside the package (its own probe programs under `test/`). So a
|
|
219
|
+
* relative import of a file under @8bitscript/c64 carries raster.s the same
|
|
220
|
+
* way `@8bitscript/c64/raster` does. `{ native: [] }` for a file with no
|
|
221
|
+
* package above it, or one whose manifest lists nothing.
|
|
222
|
+
*/
|
|
223
|
+
export function nativeSourcesBeside(file) {
|
|
224
|
+
let dir = dirname(file);
|
|
225
|
+
for (;;) {
|
|
226
|
+
const manifestPath = join(dir, 'package.json');
|
|
227
|
+
if (existsSync(manifestPath)) {
|
|
228
|
+
let manifest;
|
|
229
|
+
try {
|
|
230
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
231
|
+
} catch {
|
|
232
|
+
return { native: [] };
|
|
233
|
+
}
|
|
234
|
+
const name = typeof manifest?.name === 'string' ? manifest.name : dir;
|
|
235
|
+
return nativeSourcesOf(name, dir, manifest);
|
|
236
|
+
}
|
|
237
|
+
const parent = dirname(dir);
|
|
238
|
+
if (parent === dir) return { native: [] };
|
|
239
|
+
dir = parent;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Walk up from a directory looking for `node_modules/<name>`. */
|
|
244
|
+
function findPackageDir(fromDir, name) {
|
|
245
|
+
let dir = fromDir;
|
|
246
|
+
for (;;) {
|
|
247
|
+
const candidate = join(dir, 'node_modules', name);
|
|
248
|
+
if (existsSync(join(candidate, 'package.json'))) return candidate;
|
|
249
|
+
const parent = dirname(dir);
|
|
250
|
+
if (parent === dir) return null;
|
|
251
|
+
dir = parent;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* A package's `"8bitscript".native` list: files that are not 8BitScript
|
|
257
|
+
* but belong in the build anyway — hand-written 6502 assembly, or data such
|
|
258
|
+
* as @8bitscript/nes's CHR-ROM font, which no .8bs construct can express
|
|
259
|
+
* yet. Paths are relative to the package and resolved here to absolute ones;
|
|
260
|
+
* the linker collects them across the module graph and the 6502 backend
|
|
261
|
+
* hands them to LLVM-MOS alongside the generated C. A package that ships
|
|
262
|
+
* only .8bs simply has no such field. Every listed file must exist: a
|
|
263
|
+
* package whose manifest names a file it does not ship is `8BS2008`,
|
|
264
|
+
* reported at resolution time for the same reason a missing entry is —
|
|
265
|
+
* before anything is built against it.
|
|
266
|
+
*/
|
|
267
|
+
function nativeSourcesOf(specifier, packageDir, manifest) {
|
|
268
|
+
const native = manifest?.['8bitscript']?.native;
|
|
269
|
+
if (native === undefined) return { native: [] };
|
|
270
|
+
if (!Array.isArray(native) || native.some((v) => typeof v !== 'string')) {
|
|
271
|
+
return {
|
|
272
|
+
code: Codes.NOT_AN_8BS_PACKAGE,
|
|
273
|
+
message: `'${specifier}' has a malformed "8bitscript".native value: expected an array of relative paths`,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
const resolved = [];
|
|
277
|
+
for (const value of native) {
|
|
278
|
+
const target = resolvePath(packageDir, value);
|
|
279
|
+
if (!existsSync(target)) {
|
|
280
|
+
return {
|
|
281
|
+
code: Codes.MISSING_NATIVE_SOURCE,
|
|
282
|
+
message: `'${specifier}' declares native source '${value}', which does not exist`,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
resolved.push(target);
|
|
286
|
+
}
|
|
287
|
+
return { native: resolved };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* A package's `"8bitscript".exports` map: the subpaths it offers besides its
|
|
292
|
+
* entry, each a relative path into the package — `{ "./screen": "./src/
|
|
293
|
+
* screen.8bs" }` makes `@scope/name/screen` resolve to that file. This is
|
|
294
|
+
* how a target package such as @8bitscript/c64 offers its `screen` and
|
|
295
|
+
* `text` implementations to the portable @8bitscript/screen and
|
|
296
|
+
* @8bitscript/text packages, whose machine-keyed entries delegate to
|
|
297
|
+
* `@8bitscript/c64/screen` and so on: the per-machine code stays inside the
|
|
298
|
+
* machine's own package, next to the registers it is built on.
|
|
299
|
+
*
|
|
300
|
+
* The exported file follows the same filename rule every other .8bs path
|
|
301
|
+
* does (`screen.8bs` with a `screen.nes.8bs` beside it — see chooseVariant),
|
|
302
|
+
* and the package's native sources ride along with it, since it is that
|
|
303
|
+
* package's code being linked. A subpath the map has no key for is
|
|
304
|
+
* `8BS2011`: the package is sound, it just does not offer that; a key whose
|
|
305
|
+
* file does not exist is `8BS2003`, like a missing entry.
|
|
306
|
+
*/
|
|
307
|
+
function resolveSubpath(specifier, name, packageDir, manifest, subpath, options) {
|
|
308
|
+
const key = `./${subpath}`;
|
|
309
|
+
const exports = manifest?.['8bitscript']?.exports;
|
|
310
|
+
if (exports !== undefined && (typeof exports !== 'object' || exports === null || Array.isArray(exports))) {
|
|
311
|
+
return {
|
|
312
|
+
code: Codes.NOT_AN_8BS_PACKAGE,
|
|
313
|
+
message: `'${name}' has a malformed "8bitscript".exports value: expected an object of './subpath' keys to relative paths`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
const value = exports?.[key];
|
|
317
|
+
if (value === undefined) {
|
|
318
|
+
const offered = exports ? Object.keys(exports) : [];
|
|
319
|
+
return {
|
|
320
|
+
code: Codes.NO_SUCH_SUBPATH,
|
|
321
|
+
message: offered.length > 0
|
|
322
|
+
? `'${name}' does not export '${key}' (exports: ${offered.join(', ')})`
|
|
323
|
+
: `'${name}' does not export '${key}': its package.json has no "8bitscript".exports field`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (typeof value !== 'string' || !value.startsWith('.')) {
|
|
327
|
+
return {
|
|
328
|
+
code: Codes.NOT_AN_8BS_PACKAGE,
|
|
329
|
+
message: `'${name}' has a malformed "8bitscript".exports value for '${key}': expected a relative path`,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
const target = resolvePath(packageDir, value);
|
|
333
|
+
const chosen = chooseVariant(specifier, target, options.machine, tagsOf(options));
|
|
334
|
+
if (!chosen) {
|
|
335
|
+
return { code: Codes.MISSING_PACKAGE_ENTRY, message: `'${name}' exports '${key}' as '${value}', which does not exist` };
|
|
336
|
+
}
|
|
337
|
+
if (chosen.code) return chosen;
|
|
338
|
+
const sources = nativeSourcesOf(name, packageDir, manifest);
|
|
339
|
+
if (sources.code) return sources;
|
|
340
|
+
return { path: chosen.path, native: sources.native };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* One machine's entry value: a relative path into the package, or a bare
|
|
345
|
+
* specifier delegating to another package — its entry, or one of its
|
|
346
|
+
* subpaths (`@8bitscript/c64/screen`) — resolved from the package's own
|
|
347
|
+
* directory, so its own dependencies serve the delegation. A relative
|
|
348
|
+
* entry carries its own package's native sources; a delegation carries the
|
|
349
|
+
* delegated package's, since that is whose code is actually being linked.
|
|
350
|
+
*/
|
|
351
|
+
function resolveEntryValue(specifier, packageDir, value, options, seen, native = []) {
|
|
352
|
+
if (typeof value !== 'string') {
|
|
353
|
+
return {
|
|
354
|
+
code: Codes.NOT_AN_8BS_PACKAGE,
|
|
355
|
+
message: `'${specifier}' has a malformed "8bitscript".entry value`,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
if (value.startsWith('.')) {
|
|
359
|
+
const target = resolvePath(packageDir, value);
|
|
360
|
+
if (!existsSync(target)) {
|
|
361
|
+
return { code: Codes.MISSING_PACKAGE_ENTRY, message: `'${specifier}' declares entry '${value}', which does not exist` };
|
|
362
|
+
}
|
|
363
|
+
return { path: target, native };
|
|
364
|
+
}
|
|
365
|
+
if (seen.has(packageDir)) {
|
|
366
|
+
return { code: Codes.MISSING_PACKAGE_ENTRY, message: `'${specifier}' delegates its entry in a cycle` };
|
|
367
|
+
}
|
|
368
|
+
seen.add(packageDir);
|
|
369
|
+
const delegated = resolveSpecifier(value, join(packageDir, 'package.json'), options, seen);
|
|
370
|
+
if (!delegated) {
|
|
371
|
+
return {
|
|
372
|
+
code: Codes.NOT_AN_8BS_PACKAGE,
|
|
373
|
+
message: `'${specifier}' delegates its entry to '${value}', which is not a resolvable specifier`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
return delegated;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* An entry object keyed by machine — `{ "vic20": …, "c64": … }` — is how a
|
|
381
|
+
* package provides a target-conditional implementation. With a machine in
|
|
382
|
+
* hand, that machine's branch resolves (a missing branch is `8BS3002`: the
|
|
383
|
+
* package genuinely has nothing for this target). Without one — `8bs check`
|
|
384
|
+
* and the editor analyse files, not builds — every branch is validated, so a
|
|
385
|
+
* broken branch is reported before anyone builds for that machine.
|
|
386
|
+
*/
|
|
387
|
+
function resolveConditionalEntry(specifier, packageDir, entry, options, seen, native) {
|
|
388
|
+
const { machine } = options;
|
|
389
|
+
if (machine) {
|
|
390
|
+
const value = entry[machine];
|
|
391
|
+
if (value === undefined) {
|
|
392
|
+
return {
|
|
393
|
+
code: Codes.NOT_ON_THIS_TARGET,
|
|
394
|
+
message: `'${specifier}' has no entry for the ${machine} target (targets: ${Object.keys(entry).join(', ')})`,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
return resolveEntryValue(specifier, packageDir, value, options, seen, native);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
for (const [branchMachine, value] of Object.entries(entry)) {
|
|
401
|
+
const resolved = resolveEntryValue(
|
|
402
|
+
specifier, packageDir, value,
|
|
403
|
+
{ ...options, machine: branchMachine }, new Set(seen), native,
|
|
404
|
+
);
|
|
405
|
+
if (resolved?.code) {
|
|
406
|
+
return { code: resolved.code, message: `for the ${branchMachine} target: ${resolved.message}` };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Every branch is sound, but there is no single file to name without a
|
|
410
|
+
// machine: `path: null` is "valid, and target-dependent".
|
|
411
|
+
return { path: null };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Resolve one import specifier to the absolute path of the module it names.
|
|
416
|
+
*
|
|
417
|
+
* Deliberately narrow: it implements only what docs/packages.md actually
|
|
418
|
+
* specifies — a relative `.8bs` path, a bare package name (its entry), or a
|
|
419
|
+
* package subpath (an `"8bitscript".exports` key). A relative specifier
|
|
420
|
+
* without a `.8bs` extension is unspecified, so it returns `null` — not
|
|
421
|
+
* resolved, not an error — rather than guessing at a rule.
|
|
422
|
+
*
|
|
423
|
+
* @param {string} specifier
|
|
424
|
+
* @param {string} fromFile Absolute path of the importing file.
|
|
425
|
+
* @param {{ machine?: string, tags?: string[], profile?: string }} [options]
|
|
426
|
+
* The machine being built for (one of MACHINES), if one is known;
|
|
427
|
+
* conditional package entries resolve to that machine's branch, and a
|
|
428
|
+
* `.8bs` file with a `.<machine>.8bs` twin resolves to the twin. With
|
|
429
|
+
* the build's hardware tags as well (`tags`; the older `profile` is one
|
|
430
|
+
* tag), a `.<machine>.<tag>.8bs`
|
|
431
|
+
* twin is taken before the machine's own (see variantOf).
|
|
432
|
+
* @returns {{ path: string|null, native?: string[] } | { code: string, message: string } | null}
|
|
433
|
+
* `native` — absolute paths of the resolved package's `"8bitscript".native`
|
|
434
|
+
* files (see nativeSourcesOf) — rides along with a package resolution,
|
|
435
|
+
* and with a relative import of a file inside a package that has one
|
|
436
|
+
* (nativeSourcesBeside).
|
|
437
|
+
*/
|
|
438
|
+
export function resolveSpecifier(specifier, fromFile, options = {}, seen = new Set()) {
|
|
439
|
+
const fromDir = dirname(fromFile);
|
|
440
|
+
|
|
441
|
+
if (specifier.startsWith('.') || specifier.startsWith('/')) {
|
|
442
|
+
if (!specifier.endsWith('.8bs')) return null;
|
|
443
|
+
const target = resolvePath(fromDir, specifier);
|
|
444
|
+
// `./hardware.8bs` on the NES is `./hardware.nes.8bs` when that file
|
|
445
|
+
// exists beside it — see chooseVariant.
|
|
446
|
+
const chosen = chooseVariant(specifier, target, options.machine, tagsOf(options));
|
|
447
|
+
if (!chosen) {
|
|
448
|
+
return { code: Codes.UNRESOLVED_RELATIVE_IMPORT, message: `cannot find module '${specifier}'` };
|
|
449
|
+
}
|
|
450
|
+
// The file's own package's native sources come with it (see
|
|
451
|
+
// nativeSourcesBeside); a package whose manifest names a missing one
|
|
452
|
+
// is reported here as it would be at the package's own resolution.
|
|
453
|
+
const sources = nativeSourcesBeside(chosen.path ?? target);
|
|
454
|
+
if (sources.code) return sources;
|
|
455
|
+
return sources.native.length > 0 ? { ...chosen, native: sources.native } : chosen;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const subpath = PACKAGE_SUBPATH.exec(specifier);
|
|
459
|
+
if (!subpath && !BARE_PACKAGE.test(specifier)) return null;
|
|
460
|
+
const name = subpath ? subpath[1] : specifier;
|
|
461
|
+
|
|
462
|
+
const packageDir = findPackageDir(fromDir, name);
|
|
463
|
+
if (!packageDir) {
|
|
464
|
+
return { code: Codes.UNRESOLVED_PACKAGE, message: `cannot find package '${name}'. Is it installed?` };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
let manifest;
|
|
468
|
+
try {
|
|
469
|
+
manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'));
|
|
470
|
+
} catch {
|
|
471
|
+
manifest = null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (subpath) {
|
|
475
|
+
if (!manifest?.['8bitscript']) {
|
|
476
|
+
return {
|
|
477
|
+
code: Codes.NOT_AN_8BS_PACKAGE,
|
|
478
|
+
message: `'${name}' is not an 8BitScript package: its package.json has no "8bitscript" field`,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
return resolveSubpath(specifier, name, packageDir, manifest, subpath[2], options);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const entry = manifest?.['8bitscript']?.entry;
|
|
485
|
+
if (typeof entry === 'string') {
|
|
486
|
+
// A package's entry follows the same filename rule a relative import
|
|
487
|
+
// does: `./src/index.8bs` with an `index.nes.8bs` beside it is the NES
|
|
488
|
+
// version of the package, without the manifest having to say so.
|
|
489
|
+
const target = resolvePath(packageDir, entry);
|
|
490
|
+
const chosen = chooseVariant(specifier, target, options.machine, tagsOf(options));
|
|
491
|
+
if (!chosen) {
|
|
492
|
+
return { code: Codes.MISSING_PACKAGE_ENTRY, message: `'${specifier}' declares entry '${entry}', which does not exist` };
|
|
493
|
+
}
|
|
494
|
+
if (chosen.code) return chosen;
|
|
495
|
+
const sources = nativeSourcesOf(specifier, packageDir, manifest);
|
|
496
|
+
if (sources.code) return sources;
|
|
497
|
+
return { path: chosen.path, native: sources.native };
|
|
498
|
+
}
|
|
499
|
+
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
|
500
|
+
const sources = nativeSourcesOf(specifier, packageDir, manifest);
|
|
501
|
+
if (sources.code) return sources;
|
|
502
|
+
return resolveConditionalEntry(specifier, packageDir, entry, options, seen, sources.native);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
code: Codes.NOT_AN_8BS_PACKAGE,
|
|
507
|
+
message: `'${specifier}' is not an 8BitScript package: its package.json has no "8bitscript".entry field`,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Check every import in a file and report the ones that do not resolve.
|
|
513
|
+
*
|
|
514
|
+
* A thin consumer of resolveSpecifier: same rules, but reported as
|
|
515
|
+
* diagnostics on the specifier's span, which is what `8bs check` and the
|
|
516
|
+
* editor show.
|
|
517
|
+
*
|
|
518
|
+
* @param {object[]} tokens
|
|
519
|
+
* @param {string} file Absolute path of the importing file.
|
|
520
|
+
* @returns {object[]} diagnostics
|
|
521
|
+
*/
|
|
522
|
+
export function resolveImports(tokens, file) {
|
|
523
|
+
if (!file || !isAbsolute(file)) return [];
|
|
524
|
+
const diagnostics = [];
|
|
525
|
+
|
|
526
|
+
for (const { specifier, start, length } of findImports(tokens)) {
|
|
527
|
+
const resolved = resolveSpecifier(specifier, file);
|
|
528
|
+
if (resolved && resolved.code) {
|
|
529
|
+
diagnostics.push(diagnostic(resolved.code, resolved.message, file, start, length));
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return diagnostics;
|
|
534
|
+
}
|