@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,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
+ }