From 0ef403a8145226d936f7e34ca475fce90057b7e7 Mon Sep 17 00:00:00 2001 From: Jeany Date: Sun, 25 Oct 2026 20:45:57 -0600 Subject: [PATCH] Libraries from other plugins: TrigScript modules a plugin provides, imported by id, kept in the map A plugin provides a library through trigscript.build (service version 2): provideLibrary({ id, label, version, modules }). A script imports "/" or "" (index), as it imports the standard library; only the modules something imports join the build, at __libraries__//. When the script is applied, each library it imports is copied into the map under trigscript\@libraries\\ with a library.json, and the map builds with that copy from then on, with or without the plugin. A newer provided version is offered in the editor (Keep / Update), never taken by itself. The manifest's source hash covers the copies; with none it is the old hash, so existing maps still read as built. Also: Monaco gets each library's modules (completion, hovers), commands trigscript.libraries and trigscript.updateLibraries, state() lists the copies, contract.d.ts documents provideLibrary, and examples/library is a complete library plugin to copy. Played in the editor end to end. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WdKDdRDTseBGCMyjhrAiNG --- README.md | 74 ++++++++++++++++- compile.ts | 9 +- compiler/compiler.ts | 23 +++++- compiler/libraries.ts | 67 +++++++++++++++ compiler/link.ts | 16 +++- contract.d.ts | 30 ++++++- docs/v4-plan.md | 42 ++++++++++ editor.ts | 31 ++++++- examples/library/README.md | 22 +++++ examples/library/embed.mjs | 9 ++ examples/library/lib/announce.ts | 6 ++ examples/library/lib/index.ts | 15 ++++ examples/library/lib/spawn.ts | 11 +++ examples/library/modules.ts | 6 ++ examples/library/plugin.json | 13 +++ examples/library/plugin.ts | 24 ++++++ ko.ts | 7 ++ monaco.ts | 7 +- plugin.ts | 9 +- script.ts | 97 ++++++++++++++++++++-- service.ts | 133 ++++++++++++++++++++++++++++-- tests/compile-worker.test.ts | 19 +++++ tests/libraries.test.ts | 137 +++++++++++++++++++++++++++++++ tests/service.test.ts | 91 +++++++++++++++++++- tsconfig.json | 1 + 25 files changed, 865 insertions(+), 34 deletions(-) create mode 100644 compiler/libraries.ts create mode 100644 examples/library/README.md create mode 100644 examples/library/embed.mjs create mode 100644 examples/library/lib/announce.ts create mode 100644 examples/library/lib/index.ts create mode 100644 examples/library/lib/spawn.ts create mode 100644 examples/library/modules.ts create mode 100644 examples/library/plugin.json create mode 100644 examples/library/plugin.ts create mode 100644 tests/libraries.test.ts diff --git a/README.md b/README.md index ce60bcb..63af4b9 100644 --- a/README.md +++ b/README.md @@ -454,7 +454,7 @@ passed over in silence. | `enum`, `const enum`, `namespace` | Same | Their members are numbers and constants known when the script is built. | | `try` / `catch` / `finally` / `throw` | Mostly | `throw` takes an `Error`, a `RangeError`, a `TypeError` or an instance of a class that extends one. An error nothing catches is said in the game in red, and the program stops. A caught error is not kept in a variable, an array or a field. | | `async` / `await`, promises, generators | Mostly | `await` of `sleep()`, `until()`, an async call, a task, `Promise.all`, `race` and `any`; `.then`; `function*`, `yield*`, `for await`. A race's losers stop where they are, where JavaScript lets them run on. No `setTimeout` or `setInterval`: `trigscript/timers` has `after` and `every`. | -| Modules | Same | `import` between the script's files, from `"trigscript"` and from the standard library (`"trigscript/math"`, …), and of videos, images, sounds and MIDI notes the Explorer imported; nothing from npm. | +| Modules | Same | `import` between the script's files, from `"trigscript"`, from the standard library (`"trigscript/math"`, …) and from [libraries other plugins provide](#libraries-from-other-plugins) (`"waves"`), and of videos, images, sounds and MIDI notes the Explorer imported; nothing from npm. | | The standard library | Some | `Math.min`, `max`, `abs`, `sqrt`, `hypot`, `sin`, `cos`, `tan`, `atan`, `atan2`, `floor`, `ceil`, `round`, `trunc`, `sign`, `log2`, `pow` by a known power; `String(n)`, `n.toString()`, `parseInt`, `isNaN`, `Array.from`, `new Array(n).fill(v)`. For `console.log` there is `print()`, for `Math.random()` there is `random(n)`. Inside a program `Math.random()`, `Date.now()` and `new Date()` are refused, since they would be worked out once when the map is built; game time is `elapsed()` or `memory.game.frame`. No `JSON`, `Date` or `RegExp`. | | — | Added | What TypeScript has no word for: `sleep`, `rose` and `once`; reads of the game (`minerals(p)`); `Unit` objects and loops over units; events (`on.died`, …); `stats()` and `memory`; `keyPressed`, `keyHeld`, `clicked`, `mouse`, `chatted`, `local` and `sync()`; `shared()` and per-player programs. | @@ -1995,6 +1995,43 @@ and `cutscene` each take one of the map's first 63 locations that the map does n with `reserveLocation(why)`. A script of your own can call it too: the location is chosen when the script is built, and the reason is what the map's requirements show. +#### Libraries from other plugins + +Another plugin can provide a library of its own: TrigScript modules a script imports by +the library's name, the way it imports the standard library by `trigscript/…`. +`import { spawnWave } from "waves/spawn"` is the module `spawn` of the library `waves`, and +`import { spawnWave } from "waves"` is its `index`. While the plugin is on, the editor +completes the library's names and its hovers show the library's own source and comments. + +```typescript +import { announce, spawnWave } from "waves"; + +program(() => { + for (let wave = 1; wave <= 5; wave++) { + announce(`Wave ${wave}`); + spawnWave(P2, units.ZergZergling, wave, locations.Spawn); + sleep(seconds(20)); + } +}); +``` + +**The map keeps its own copy.** When the script is applied (saving does it), each library +the script imports is copied into the map, under `trigscript\@libraries\\` beside +the script's files, and from then on the map builds with that copy. A map opened where the +plugin is not installed still builds, and so does a map shared with someone who has a +different version of the plugin. A library the script stops importing leaves the map with +the next apply. + +**A newer version is offered, not taken.** When the plugin installed here has a different +version from the map's copy, the editor says so and offers *Update*, which replaces the +copy. The script then reads as changed, and the next save builds with the new copy. *Keep +the map's copy* leaves it as it is. + +A library is TrigScript like the script's own files, and runs with the same rights: a +library in a map from someone else is no different from that map's own script. A module of +a library joins the build only when something imports it, so a `program()` written at the +top of a module runs only in the maps that import that module. + #### What a program does not have Outside `program()` the script is all of TypeScript, since it simply runs. Inside one, what @@ -2212,12 +2249,14 @@ as they are) or an object of every file by path. | Command | | | --- | --- | -| `trigscript.state()` | `{ files, source, manifest, block, stale, edited, unbuilt, programs }`: the map's script files, `main.ts` on its own, where the script's block sits in the trigger list (null when the records were edited by hand: `stale`, with `edited` counting how many are still the script's and how many changed, when that can be told), whether the files differ from what was last applied, and how many programs the script had then (any means the saved map has programs built into it and needs StarCraft: Remastered). Null with no map. | +| `trigscript.state()` | `{ files, source, manifest, block, stale, edited, unbuilt, programs, libraries }`: the map's script files, `main.ts` on its own, where the script's block sits in the trigger list (null when the records were edited by hand: `stale`, with `edited` counting how many are still the script's and how many changed, when that can be told), whether the files differ from what was last applied, and how many programs the script had then (any means the saved map has programs built into it and needs StarCraft: Remastered), and the libraries the map keeps a copy of (`{ id, label, version }`). Null with no map. | | `trigscript.declarations({ compact? })` | The generated `.d.ts` the script type-checks against: the whole vocabulary for this map. `compact` is the shorter variant meant for a language model. Empty with no map. | | `trigscript.compile(source)` | A promise of a `CompileResult`: `ok`, `diagnostics` (`file`, 1-based `line` / `column`, `message`, `source: "typescript"`, `"compiler"` or `"script"` for an error the script threw), the `trigger()` records, `sources` (per record, the file and line it came from), the `programs` and their `variables`, `ir` (the programs as IR, what the build lays out), `hints` per line, and `refs`: every `locations.X` / `switches.X` the files mention, resolved by the checker, present even when the script does not type-check. A newer compile supersedes an unfinished one, which rejects with `CompileSuperseded`. | | `trigscript.build(source, { takeOver?, replaceStale? })` | Apply: compile and, when clean, write the `trigger()` records as the block and store the files with the map (the programs are built into the file when the map is saved): a promise of `{ compiled, block, refused?, replaced? }`. The build lands only on the map it was compiled for: `block` is null and `refused` says why when there were `"errors"`, the map `"closed"` or another one `"switched"` to the front while the script ran, or the map's names `"changed"` under it (that one is compiled again, twice at most, before it is reported). Files edited in the archive while the script ran are kept, and the state then reads as unbuilt. When the old block was edited by hand, the new one is appended, or with `replaceStale` put in the old one's place with its unchanged records removed and the edited ones kept after it (`replaced` counts both). `takeOver` replaces the whole trigger list with the script's. A settings-style transaction: not undoable, marks the map modified. | | `trigscript.print(triggers, { imports?, header? })` | Records as `trigger()` calls in the script language: what Import map triggers writes. `imports` starts the text with an import of the names it uses. | | `trigscript.simulate(triggers, cycles, { player? })` | The interpreter: `{ cycles, events, switches }`. | +| `trigscript.libraries()` | The [libraries](#libraries-from-other-plugins) the open map keeps a copy of and the ones plugins provide, side by side: `{ id, label, version, provided, update }`, with `version` the map's copy (null when it keeps none), `provided` what a plugin has now (null when none does), and `update` true when the two differ. | +| `trigscript.updateLibraries(ids?)` | Replace the map's copies of these libraries (every one with a newer version provided, by default) with what their plugins provide now; the ids it updated. The map is modified and the script reads as changed. | | `trigscript.triggerAt(file, line)` | Which trigger (index in the map's list) a 1-based line of a file generated; null when none did or the block is stale. | | `trigscript.open({ file?, line? })` | Open the editor, on a file and line. | @@ -2253,6 +2292,37 @@ the plugin's own preflight: the problems at their file and line, and how many pr `onBuild` says how each build went, with `from` naming the contribution a failure was in. Magenta's build rows reach the map this way. +### Providing a library + +A plugin can give scripts a library to import ([Libraries from other +plugins](#libraries-from-other-plugins)) through the same service, from version 2 of it +on: `provideLibrary` takes the library's id, which is what scripts import it by, a label +and version for the editor to show, and each module's TrigScript source by its path in the +library. + +```typescript +api.services.watch("trigscript.build", (build, info) => { + held?.dispose(); + held = build && (info?.version ?? 1) >= 2 + ? build.provideLibrary({ id: "waves", label: "Waves", version: "1.0.0", modules: { "index.ts": INDEX, "spawn.ts": SPAWN } }) + : null; +}); +``` + +- **The id** is lower-case letters, digits and `-`, starting with a letter. `trigscript` is + taken. A second library with the same id replaces the first. +- **A module** ends in `.ts` (or `.tsx` for JSX) and may sit in folders (`ai/waves.ts`). It + imports the library's other modules by a relative path (`./spawn`), another library by its + id, and `"trigscript"` and the standard library as a script does. Test files are refused: + the script's tests would run them. +- **The version** is what the map maker sees beside the map's copy. Change it with every + release, so a map with an older copy is offered the update. + +A plugin cannot import a `.ts` file as text, so the modules travel as strings. +[`examples/library`](examples/library) is a complete library plugin to copy: its TrigScript +is in `lib/`, `node embed.mjs` writes it into `modules.ts`, and `plugin.ts` provides it. It +installs as it is from `github:scm-js/plugin-trigscript/examples/library`. + ## Development ```sh diff --git a/compile.ts b/compile.ts index 430c8c8..c2a77d9 100644 --- a/compile.ts +++ b/compile.ts @@ -21,6 +21,7 @@ */ import type { CompileResult, OwnCheck, ScriptFiles } from "./compiler/compiler"; import type { OwnBuildInput, OwnBuildOutcome } from "./compiler/backend/build"; +import type { ScriptLibraries } from "./compiler/libraries"; import { ENTRY_URL } from "./compiler/entry"; import type { ScriptNames } from "./compiler/names"; import type { TestRunOptions } from "./compiler/testing"; @@ -81,6 +82,8 @@ export interface CompileInput { own?: OwnCheck; /** Files that run as entries after `main.ts`, in order: what another plugin contributes to the build (`CompileOptions.entries`). */ entries?: readonly string[]; + /** Other plugins' libraries the script may import, by id (`CompileOptions.libraries`). */ + libraries?: ScriptLibraries; } interface CompileRequest extends CompileInput { @@ -111,7 +114,7 @@ importScripts(${JSON.stringify(TS_URL)}); let loading = null; let lib = null; self.onmessage = async (e) => { - const { id, moduleUrl, libUrl, vorbisUrl, files, names, tests, own, entries, build } = e.data; + const { id, moduleUrl, libUrl, vorbisUrl, files, names, tests, own, entries, libraries, build } = e.data; try { if (!loading) loading = import(moduleUrl); let mod; @@ -130,7 +133,7 @@ self.onmessage = async (e) => { lib = await r.text(); } // A check names the script's clips and makes none of them; a build makes them, the long ones encoded below. - const result = mod.compileScript(self.ts, files, names, { lib, tests, sounds: Boolean(vorbisUrl), own, entries }); + const result = mod.compileScript(self.ts, files, names, { lib, tests, sounds: Boolean(vorbisUrl), own, entries, libraries }); // A build's long clips encoded as Ogg Vorbis; a check's left as names, their samples not sent. if (typeof mod.finishSounds === "function") { const encoder = vorbisUrl && result.sounds.some((s) => s.vorbis) ? await mod.loadVorbis(vorbisUrl) : null; @@ -297,7 +300,7 @@ export function compileInBackground(input: CompileInput, dist: string = DEFAULT_ return new Promise((resolve, reject) => { const ms = input.sounds ? BUILD_TIMEOUT_MS : COMPILE_TIMEOUT_MS; pending.set(id, { resolve: (r) => resolve(r as CompileResult), reject, timer: setTimeout(() => timeOut(id, ms), ms), build: false }); - const req: CompileRequest = { id, moduleUrl: workerModuleUrl(dist), libUrl: libUrl(dist), files: input.files, names: input.names, ...(input.tests ? { tests: input.tests } : {}), ...(input.sounds ? { vorbisUrl: vorbisUrl(dist) } : {}), ...(input.own ? { own: input.own } : {}), ...(input.entries ? { entries: input.entries } : {}) }; + const req: CompileRequest = { id, moduleUrl: workerModuleUrl(dist), libUrl: libUrl(dist), files: input.files, names: input.names, ...(input.tests ? { tests: input.tests } : {}), ...(input.sounds ? { vorbisUrl: vorbisUrl(dist) } : {}), ...(input.own ? { own: input.own } : {}), ...(input.entries ? { entries: input.entries } : {}), ...(input.libraries ? { libraries: input.libraries } : {}) }; w.postMessage(req); }).catch((err: Error) => { if (err.message === RETRY) return compileInBackground(input, dist); diff --git a/compiler/compiler.ts b/compiler/compiler.ts index 2701fc5..4e5d1df 100644 --- a/compiler/compiler.ts +++ b/compiler/compiler.ts @@ -24,6 +24,7 @@ import { isScriptClass, isScriptFunction, scriptArrow, isTopListener, libraryCal import { locate, resolveModule, runModules, STDLIB_DIR, type LinkedFile } from "./link"; import { isMediaFile, mediaValue } from "./media"; import { STDLIB, stdlibImports, stdlibPath } from "./stdlib"; +import { LIBRARY_DIR, LIBRARY_ID, libraryImports, type ScriptLibraries } from "./libraries"; import { checkProgram } from "./check"; import { checkLocal } from "./local"; import { typeNumbers } from "./numbers"; @@ -215,6 +216,11 @@ export interface CompileOptions { * files is an error. */ entries?: readonly string[]; + /** + * Other plugins' libraries the script may import, by id (`libraries.ts`): "stamps/patrol" is the module `patrol.ts` + * of the library "stamps". Only the modules something imports join the files. + */ + libraries?: ScriptLibraries; } export { LowerError }; @@ -227,6 +233,16 @@ function renamedUnit(message: string): string { : message; } +/** An import of a library nobody has here: TypeScript says it cannot find the module; this says where one comes from. */ +function missingLibrary(message: string, libraries: ScriptLibraries): string { + const m = /^Cannot find module '([^'./][^']*)'/.exec(message); + if (!m || m[1] === MODULE_NAME || m[1].startsWith(`${MODULE_NAME}/`)) return message; + const id = m[1].split("/")[0]; + return id in libraries + ? `${message}\nThe library "${id}" has no such module: it has ${Object.keys(libraries[id]).map((p) => `"${id}/${p.replace(/\.tsx?$/, "")}"`).join(", ")}.` + : `${message}\nA name without ./ in front imports a library another plugin provides ("${id}/…"): no plugin provides "${id}" here, and the map keeps no copy of it.`; +} + export function normalizePath(path: string): string { return path.replace(/\\/g, "/").replace(/^(\.\/)+/, "").replace(/\/+/g, "/"); } @@ -252,6 +268,9 @@ export function compileScript(ts: typeof TS, files: ScriptFiles, names: ScriptNa for (const [path, text] of Object.entries(files)) (isMediaFile(path) ? media : scripts).set(normalizePath(path), text); // The standard library's modules the script imports ("trigscript/fog"), as files of its own: only those, since one // may hold a program of its own. + // Other plugins' libraries the script imports, the same way — before the standard library, which a library may import. + const libraries: ScriptLibraries = Object.fromEntries(Object.entries(options.libraries ?? {}).filter(([id]) => id !== MODULE_NAME && LIBRARY_ID.test(id))); + for (const [path, text] of Object.entries(libraryImports([...scripts.values()], libraries).files)) scripts.set(path, text); for (const name of stdlibImports([...scripts.values()])) scripts.set(stdlibPath(name), STDLIB[name]); const entry = entryOf(scripts.keys()); if (!scripts.has(entry)) { @@ -265,7 +284,7 @@ export function compileScript(ts: typeof TS, files: ScriptFiles, names: ScriptNa const compilerOptions: TS.CompilerOptions = { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, moduleResolution: ts.ModuleResolutionKind.Bundler, strict: true, types: [], sourceMap: true, noLib: false, - paths: { [`${MODULE_NAME}/*`]: [`./${STDLIB_DIR}/*`] }, + paths: { [`${MODULE_NAME}/*`]: [`./${STDLIB_DIR}/*`], ...Object.fromEntries(Object.keys(libraries).flatMap((id) => [[`${id}/*`, [`./${LIBRARY_DIR}/${id}/*`]], [id, [`./${LIBRARY_DIR}/${id}/index`]]])) }, // JSX (in a .tsx file) is a text the game shows: elements are calls of __jsx, which the structured compiler lowers in a program. jsx: ts.JsxEmit.React, jsxFactory: "__jsx", jsxFragmentFactory: "__fragment", }; @@ -328,7 +347,7 @@ export function compileScript(ts: typeof TS, files: ScriptFiles, names: ScriptNa const pos = sf && d.start !== undefined ? position(sf, d.start, d.start + (d.length ?? 0)) : { line: 1, column: 1, endLine: 1, endColumn: 1 }; const file = sf ? sf.fileName : ENTRY_FILE; const where = sf && !scripts.has(sf.fileName) ? `${sf.fileName}: ` : ""; - diagnostics.push({ file: scripts.has(file) ? file : ENTRY_FILE, ...pos, message: where + renamedUnit(ts.flattenDiagnosticMessageText(d.messageText, "\n")), source: "typescript" }); + diagnostics.push({ file: scripts.has(file) ? file : ENTRY_FILE, ...pos, message: where + missingLibrary(renamedUnit(ts.flattenDiagnosticMessageText(d.messageText, "\n")), libraries), source: "typescript" }); } if (diagnostics.length) return result(); diff --git a/compiler/libraries.ts b/compiler/libraries.ts new file mode 100644 index 0000000..cfdb13a --- /dev/null +++ b/compiler/libraries.ts @@ -0,0 +1,67 @@ +/** + * Libraries of other plugins': modules written in TrigScript that a plugin provides (`trigscript.build`'s + * `provideLibrary`) and a script imports by the library's id — `import { patrol } from "stamps/patrol"` — as it imports + * the standard library by "trigscript/". A module joins a script's files only when something imports it, under + * the folder below, for the same reason the standard library's do: one may hold a program of its own. What the map + * keeps of a library (its copy, `script.ts`) and where a library comes from are the plugin's business; the compiler + * takes the modules by id. + */ + +import { LIBRARY_DIR, libraryModule, resolveModule } from "./link"; + +export { LIBRARY_DIR } from "./link"; + +/** Each library's modules by id, each module's source by its path in the library ("patrol.ts", "ai/waves.tsx"). */ +export type ScriptLibraries = Readonly>>>; + +/** A library's id: what a script imports it by. Lower case, so that the id is the same on every archive and file system. */ +export const LIBRARY_ID = /^[a-z][a-z0-9-]{0,39}$/; +/** A module's path in its library: folders with `/`, ending in `.ts` or `.tsx`. */ +export const LIBRARY_PATH = /^(?:[A-Za-z0-9_\-.]+\/)*[A-Za-z0-9_\-.]+\.tsx?$/; + +/** Where a module of a library is among the script's files. */ +export const libraryPath = (id: string, path: string): string => `${LIBRARY_DIR}/${id}/${path}`; + +/** Why a library cannot be taken as it is, in a sentence; null when it can. */ +export function libraryProblem(id: string, modules: Readonly>, reserved: readonly string[] = []): string | null { + if (!LIBRARY_ID.test(id)) return `"${id}" is not a library id: lower-case letters, digits and "-", starting with a letter.`; + if (reserved.includes(id)) return `"${id}" is a name TrigScript keeps for itself.`; + const paths = Object.keys(modules); + if (paths.length === 0) return `The library "${id}" has no modules.`; + for (const path of paths) { + if (!LIBRARY_PATH.test(path) || path.split("/").some((part) => part === "." || part === "..")) return `"${path}" is not a module path of a library: folders with "/", ending in .ts or .tsx.`; + if (/\.test\.ts$/i.test(path)) return `"${path}" is a test file: a library's tests stay in its own repository, since a script would run them with its own.`; + if (typeof modules[path] !== "string") return `The module "${path}" has no source.`; + } + return null; +} + +const SPECIFIER = /(?:\bfrom|\bimport)\s*\(?\s*["']([^"']+)["']/g; + +/** + * The library modules a set of files imports, and those they import in turn — of their own library by a relative path, + * of another library by its id — as files of the script's by their paths there; and the ids of the libraries they are + * of. A specifier no library has is left for TypeScript to say so. + */ +export function libraryImports(texts: Iterable, libraries: ScriptLibraries): { files: Record; used: string[] } { + const all = new Map(); + for (const [id, modules] of Object.entries(libraries)) for (const [path, text] of Object.entries(modules)) all.set(libraryPath(id, path), text); + const names = new Set(all.keys()); + const files: Record = {}; + const used = new Set(); + const add = (file: string) => { + if (file in files) return; + files[file] = all.get(file)!; + used.add(file.slice(LIBRARY_DIR.length + 1).split("/")[0]); + scan(file, files[file]); + }; + const scan = (from: string | null, text: string) => { + for (const m of text.matchAll(SPECIFIER)) { + const spec = m[1]; + const target = spec.startsWith(".") ? (from ? resolveModule(names, from, spec) : null) : libraryModule(names, spec); + if (target) add(target); + } + }; + for (const text of texts) scan(null, text); + return { files, used: [...used].sort() }; +} diff --git a/compiler/link.ts b/compiler/link.ts index d7c8801..5f191e1 100644 --- a/compiler/link.ts +++ b/compiler/link.ts @@ -51,6 +51,17 @@ export const STDLIB_DIR = "__trigscript__"; /** A module of the standard library among the script's files: its extension is `.tsx` when it has JSX in it (`stdlib.ts#STDLIB_EXT`). */ export const stdlibFile = (name: string, ext: ".ts" | ".tsx" = ".ts") => `${STDLIB_DIR}/${name}${ext}`; +/** Where another plugin's libraries sit among a script's files: "stamps/patrol" is `__libraries__/stamps/patrol.ts` (`libraries.ts`). */ +export const LIBRARY_DIR = "__libraries__"; + +/** The file a bare specifier ("stamps/patrol", "stamps") names among a library's files: `.ts`, `.tsx`, `/index.ts(x)`. Null when none. */ +export function libraryModule(files: ReadonlySet, spec: string): string | null { + if (spec.startsWith(".") || spec.startsWith("/")) return null; + const path = `${LIBRARY_DIR}/${spec}`; + for (const candidate of [path, `${path}.ts`, `${path}.tsx`, `${path}/index.ts`, `${path}/index.tsx`]) if (files.has(candidate)) return candidate; + return null; +} + /** * Run the entry module with the library in scope. Resolves to null when the script ran * to the end, else to the error it threw, located when possible. @@ -76,11 +87,14 @@ export function runModules(files: ReadonlyMap, entry: string // A video, an image or a sound (`media.ts`): the value its file gives, by the path the import names. const asMedia = spec.startsWith(".") && options.media ? options.media(resolvePath(file, spec)) : undefined; if (asMedia !== undefined) return { __esModule: true, default: asMedia }; + // Another plugin's library ("stamps/patrol"): among the files when imported, as the standard library is. + const ofLibrary = libraryModule(names, spec); + if (ofLibrary) return load(ofLibrary); const target = resolveModule(names, file, spec); if (!target) { throw new Error(spec.startsWith(".") ? `Cannot find "${spec}" from ${file}: the script's files are ${[...names].join(", ")}.` - : `Cannot import "${spec}": a script imports its own files (./name), "${moduleName}" and the standard library ("${moduleName}/fog").`); + : `Cannot import "${spec}": a script imports its own files (./name), "${moduleName}", the standard library ("${moduleName}/fog") and the libraries other plugins provide ("name/module").`); } return load(target); }; diff --git a/contract.d.ts b/contract.d.ts index 4e2eb2e..c630c1e 100644 --- a/contract.d.ts +++ b/contract.d.ts @@ -1,7 +1,8 @@ /** * The `trigscript.build` service, as other plugins see it (TrigScript 4): TrigScript as the library other plugins * build their programs through. Types only: take this file with `import type` and reach the object through - * `api.services.watch("trigscript.build", …)`. The provider's `ServiceInfo.version` is 1 for this shape. + * `api.services.watch("trigscript.build", …)`. The provider's `ServiceInfo.version` is 2 for this shape (1 had no + * `provideLibrary`). * * A plugin whose rows need code in the map writes them as TrigScript source — `program(() => { … })` bodies, the * language the guide describes — and *contributes* them: on Save, Test Map and an export TrigScript compiles them with @@ -47,6 +48,28 @@ export type TrigScriptBuildEvent = /** What the map maker should hear of beyond the log: a trigger of the map's own left out or never run. */ | { kind: "warning"; message: string }; +/** + * A library for scripts to import (service version 2): TrigScript modules by path, imported by the library's id — + * `import { patrol } from "stamps/patrol"` is the module `patrol.ts`, `import { … } from "stamps"` is `index.ts`. A + * module may import the library's other modules (`./waves`), another library ("other/x") and the standard library + * ("trigscript/…"). It joins a script's files only when something imports it, so a `program()` at its top level runs + * in the maps that import it and no others. + * + * A map copies the modules it imports when its script is applied, and builds with that copy from then on, wherever + * it is opened and whether this plugin is installed there or not. A newer version provided later is offered to the + * map maker as an update; it never replaces the copy by itself. + */ +export interface TrigScriptLibrary { + /** What a script imports it by: lower-case letters, digits and "-", starting with a letter, up to 40; never "trigscript". */ + id: string; + /** What the editor calls it ("Stamp Library"). */ + label: string; + /** The library's version, as the map maker reads it ("1.2.0"). */ + version: string; + /** Each module's TrigScript source by its path in the library, ending in .ts or .tsx: "patrol.ts", "ai/waves.tsx". No test files. */ + modules: Record; +} + export interface TrigScriptBuildService { /** The plugin's version. */ versions: { plugin: string }; @@ -56,6 +79,11 @@ export interface TrigScriptBuildService { onBuild(listener: (event: TrigScriptBuildEvent) => void): { dispose(): void }; /** Compile a contribution's source against the open map without building: for a contributor's own preflight. */ check(files: Record, entries: string[]): Promise; + /** + * Hold a library out for scripts to import while the handle lives; the second of one id replaces the first. Throws + * an `Error` saying what is wrong with a library it cannot take. Service version 2. + */ + provideLibrary(library: TrigScriptLibrary): { dispose(): void }; } /** The service name, as `api.services.get` / `watch` take it. */ diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 311039d..64c2921 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -6581,6 +6581,48 @@ Ship order for every release, as for 3.x: plugin commit and tag → scm-js pin, guide and catalogue note → registry → ai-server prompt. Anything scm-sim needs (A0's build, contract 2) ships first, then plugin-scm-sim, then TrigScript. +## Libraries from other plugins (built 2026-10-25) + +The user (2026-10-25): "if someone makes another plugin they can offer libraries/etc people can import easily into +trigscript". Branch `libraries`. + +**As built.** A library is TrigScript source, not JavaScript: a program's code is compiled, so a function of another +plugin's could never run in the game. A plugin provides one through `trigscript.build` (service version 2): +`provideLibrary({ id, label, version, modules })`, modules by path (`.ts`/`.tsx`, folders, no test files; the id lower +case, never `trigscript`). A script imports `"/"`, or `""` for `index`. + +- *Compiler* (`compiler/libraries.ts`): `CompileOptions.libraries` by id; `libraryImports` follows the imports from the + script's files (bare `/…`, and relative ones inside a library) and only those modules join the files, at + `__libraries__//…`, before the standard library's scan (a library may import `trigscript/…`). `paths` gets + `/*` and ``; the linker resolves a bare specifier among `__libraries__/`; a missing library or module is + TypeScript's "Cannot find module" plus a line saying where one comes from. +- *The map's copy* (`script.ts`): `trigscript\@libraries\\` holds the whole library and `library.json` + (label, version); "@" keeps it out of the script's own files (`pathOf`). Applying writes exactly the libraries the + compile used (a library no longer imported goes); the manifest's `sourceHash` is `hashSources(files, libraries)`, + which is `hashFiles` when there are none, so every older manifest still matches. A compile takes the map's copy + first, then what a plugin provides: a map builds with its copy wherever it is opened, plugin or not. +- *Updates* never happen by themselves: the editor's notice (*Keep the map's copy* / *Update*) when a provided + version differs; `updateLibraries` replaces the copy and the script reads as changed. Commands `trigscript.libraries` + and `trigscript.updateLibraries`; `state()` lists the copies. +- *Editor*: each library's modules are Monaco extra libs at `node_modules//…` (completion, hovers, go to + definition), refreshed when a plugin provides or withdraws one. +- *Example*: `examples/library` ("waves"), installable as `github:scm-js/plugin-trigscript/examples/library`. +- Played in the editor (dev server with this branch as the built-in TrigScript, the compiler bundle served + locally): import → 0 problems and the library's hover; Apply; Save As → the archive holds + `trigscript\@libraries\waves\…`; reopened with the example at 1.1.0 → the notice, *Update* → "Changes not + applied"; reopened with the example not installed → 0 problems, still built. + +**Open:** +- *A Libraries view* in the workspace: what is installed, what the map keeps, each module's exports with their doc + comments, Update per library. Today only the notice and the two commands say it. +- *A library's cost* in the cost report, by library: a module with a `program()` costs every frame once imported. +- *Media in a library* (`.image`, `.sound`, `.video`, `.notes`): refused by the module path rule today; the compiler + already takes them as script files, so it is the copy and `libraryImports` that would grow. +- *A template repository* to copy instead of `examples/library`, and a way for a library's own repository to run + TrigScript's checks and tests on it (the compiler as a package, or `check()` from a script). +- *Libraries without a plugin* (a GitHub folder of `.ts` files read the same way as the map's copy): not started. +- *scm-js*: the guide's TrigScript page and `docs/plugins.md` say nothing of libraries until a release pins this. + ## Work outside this repository - **scm-sim:** the EUD-permitting module build (A0); the trigger hooks (A1); `run(…)` with diff --git a/editor.ts b/editor.ts index 9f9655e..ab5d26d 100644 --- a/editor.ts +++ b/editor.ts @@ -44,7 +44,7 @@ import { type LocationRef, type MonacoApi, type ScriptEditor, type ScriptWarning, } from "./monaco"; import { renamedKeys, renamesInUse, replaceReferences, type Renamed } from "./refs"; -import { FILE_NAME } from "./script"; +import { FILE_NAME, librarySources } from "./script"; import { isMachineLine, readTriggerMap, readVariableMap } from "./compiler/gameVariables"; import { mergeCopies } from "./compiler/gameCopies"; import { costByLine, shortCount, shortMs, type TestCost } from "./compiler/gameCost"; @@ -1276,6 +1276,21 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa ] }); }; + /** A plugin provides a newer version of a library than the map keeps: said once per state, until the map maker chooses. */ + let librariesShown = ""; + const showLibraries = () => { + const newer = svc.libraryStatus().filter((l) => l.update); + if (newer.length === 0) { librariesShown = ""; shell.dismiss("libraries"); return; } + const signature = JSON.stringify(newer); + if (signature === librariesShown) return; + librariesShown = signature; + const list = newer.map((l) => t("{label} {version} (installed: {provided})", { label: l.label, version: l.version || "?", provided: l.provided || "?" })).join(", "); + shell.notify({ key: "libraries", kind: "info", text: t("The map builds with its own copy of {n, plural, one {a library} other {# libraries}}, and the plugin installed here has another version: {list}. The copy stays until you update it.", { n: newer.length, list }), actions: [ + { label: t("Keep the map's copy"), run: () => {} }, + { label: newer.length === 1 ? t("Update") : t("Update all"), primary: true, run: () => { svc.updateLibraries(newer.map((l) => l.id)); } }, + ] }); + }; + const renamedNow = () => renames.flatMap((r) => r.list.map((x) => `${r.object}.${x.from} → ${r.object}.${x.to}`)); let renamesShown = ""; const showRenames = (again = false) => { @@ -1350,6 +1365,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa renderSimulation(); showStale(); showRenames(); + showLibraries(); }; const applyResult = (r: CompileResult, asked?: TestRunOptions) => { @@ -1881,7 +1897,15 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa else renames.push({ object, list }); } } - if (monaco && generated) setDeclarations(monaco, generated.decls); + if (monaco && generated) setDeclarations(monaco, generated.decls, librarySources(svc.libraries())); + render(); + check(); + }; + + /** A plugin provided a library or withdrew one, or the map's copies changed: what the script can import is different. */ + const refreshLibraries = () => { + if (cancelled) return; + if (monaco && generated) setDeclarations(monaco, generated.decls, librarySources(svc.libraries())); render(); check(); }; @@ -1961,6 +1985,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa const releaseWorker = retainCompileWorker(); const subs = [ svc.onBuild(onOwnBuild), + svc.onLibraries(refreshLibraries), api.events.on("settings", refreshNames), api.events.on("locations", refreshNames), api.events.on("triggers", refreshNames), @@ -1971,7 +1996,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa (m) => { if (cancelled) return; monaco = m; - if (generated) setDeclarations(m, generated.decls); + if (generated) setDeclarations(m, generated.decls, librarySources(svc.libraries())); setHoverVariables(m, () => result?.variables ?? []); setLineHints(m, () => [...(result?.hints ?? []), ...costHints(), ...eudHints(), ...debug.hints()]); setMapRefs(m, mapRefs); diff --git a/examples/library/README.md b/examples/library/README.md new file mode 100644 index 0000000..b73375a --- /dev/null +++ b/examples/library/README.md @@ -0,0 +1,22 @@ +# Waves: an example TrigScript library + +A plugin that gives TrigScript scripts a library to import. Copy this folder to make one +of your own. + +```typescript +import { announce, spawnWave, waveSize } from "waves"; +``` + +- `lib/` is the library: TrigScript modules, each imported by `"waves/"`, with + `"waves"` alone meaning `lib/index.ts`. +- `node embed.mjs` writes `lib/` into `modules.ts` as text. Run it after every change to a + module: a plugin cannot import a `.ts` file as text. +- `plugin.ts` hands the modules to TrigScript through its `trigscript.build` service + (version 2 and later), and hands them again when TrigScript restarts. +- `plugin.json` requires TrigScript, so installing this installs TrigScript first. + Raise its `version` with every release. Maps keep the copy they took, and are offered + the newer version. + +To try it, paste `github:scm-js/plugin-trigscript/examples/library` into **Plugins ▸ +Manage Plugins…** and press **Add**. TrigScript's README, under *Libraries from other +plugins* and *Providing a library*, describes the rest. diff --git a/examples/library/embed.mjs b/examples/library/embed.mjs new file mode 100644 index 0000000..dd257ca --- /dev/null +++ b/examples/library/embed.mjs @@ -0,0 +1,9 @@ +// The library's modules, lib/*.ts, as text for plugin.ts to hand to TrigScript: run `node embed.mjs` after editing +// one. (A plugin cannot import a .ts file as text, so the modules travel in modules.ts.) +import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join, relative } from "node:path"; +const root = import.meta.dirname, lib = join(root, "lib"); +const walk = (dir) => readdirSync(dir).flatMap((f) => (statSync(join(dir, f)).isDirectory() ? walk(join(dir, f)) : /\.tsx?$/.test(f) ? [join(dir, f)] : [])); +const modules = Object.fromEntries(walk(lib).sort().map((f) => [relative(lib, f).replace(/\\/g, "/"), readFileSync(f, "utf8")])); +writeFileSync(join(root, "modules.ts"), `// Generated by embed.mjs from lib/ — do not edit.\nexport const MODULES: Record = ${JSON.stringify(modules, null, 1)};\n`); +console.log(`modules.ts: ${Object.keys(modules).join(", ")}`); diff --git a/examples/library/lib/announce.ts b/examples/library/lib/announce.ts new file mode 100644 index 0000000..7cc63be --- /dev/null +++ b/examples/library/lib/announce.ts @@ -0,0 +1,6 @@ +import { print } from "trigscript"; + +/** Say a line to every player, between dashes. */ +export function announce(text: string) { + print(`— ${text} —`); +} diff --git a/examples/library/lib/index.ts b/examples/library/lib/index.ts new file mode 100644 index 0000000..256c555 --- /dev/null +++ b/examples/library/lib/index.ts @@ -0,0 +1,15 @@ +/** + * Waves: an example library, from examples/library in the TrigScript repository. A script imports it by its id: + * + * import { announce, spawnWave } from "waves"; + * + * program(() => { + * for (let wave = 1; wave <= 5; wave++) { + * announce(`Wave ${wave}`); + * spawnWave(P2, units.ZergZergling, wave, locations.Spawn); + * sleep(seconds(20)); + * } + * }); + */ +export { announce } from "./announce"; +export { spawnWave, waveSize } from "./spawn"; diff --git a/examples/library/lib/spawn.ts b/examples/library/lib/spawn.ts new file mode 100644 index 0000000..c95ef07 --- /dev/null +++ b/examples/library/lib/spawn.ts @@ -0,0 +1,11 @@ +import { createUnit, type Location, type Player, type UnitType } from "trigscript"; + +/** How many units wave `n` has: four in the first, two more in each after it. */ +export function waveSize(n: number): number { + return 2 + n * 2; +} + +/** Make wave `n`: `waveSize(n)` of `unit` for `player` at `at`. */ +export function spawnWave(player: Player, unit: UnitType, n: number, at: Location) { + createUnit(player, unit, waveSize(n), at); +} diff --git a/examples/library/modules.ts b/examples/library/modules.ts new file mode 100644 index 0000000..ad96ffd --- /dev/null +++ b/examples/library/modules.ts @@ -0,0 +1,6 @@ +// Generated by embed.mjs from lib/ — do not edit. +export const MODULES: Record = { + "announce.ts": "import { print } from \"trigscript\";\n\n/** Say a line to every player, between dashes. */\nexport function announce(text: string) {\n print(`— ${text} —`);\n}\n", + "index.ts": "/**\n * Waves: an example library, from examples/library in the TrigScript repository. A script imports it by its id:\n *\n * import { announce, spawnWave } from \"waves\";\n *\n * program(() => {\n * for (let wave = 1; wave <= 5; wave++) {\n * announce(`Wave ${wave}`);\n * spawnWave(P2, units.ZergZergling, wave, locations.Spawn);\n * sleep(seconds(20));\n * }\n * });\n */\nexport { announce } from \"./announce\";\nexport { spawnWave, waveSize } from \"./spawn\";\n", + "spawn.ts": "import { createUnit, type Location, type Player, type UnitType } from \"trigscript\";\n\n/** How many units wave `n` has: four in the first, two more in each after it. */\nexport function waveSize(n: number): number {\n return 2 + n * 2;\n}\n\n/** Make wave `n`: `waveSize(n)` of `unit` for `player` at `at`. */\nexport function spawnWave(player: Player, unit: UnitType, n: number, at: Location) {\n createUnit(player, unit, waveSize(n), at);\n}\n" +}; diff --git a/examples/library/plugin.json b/examples/library/plugin.json new file mode 100644 index 0000000..4230795 --- /dev/null +++ b/examples/library/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "Waves (example TrigScript library)", + "id": "waves", + "version": "1.0.0", + "description": "An example of a plugin that provides a TrigScript library: scripts import it as \"waves\" — spawnWave, waveSize, announce. Copy it to make a library of your own.", + "author": "Jeany", + "homepage": "https://github.com/scm-js/plugin-trigscript/tree/main/examples/library", + "entry": "plugin.ts", + "api": 1, + "requires": [ + "github:scm-js/plugin-trigscript" + ] +} diff --git a/examples/library/plugin.ts b/examples/library/plugin.ts new file mode 100644 index 0000000..4f36435 --- /dev/null +++ b/examples/library/plugin.ts @@ -0,0 +1,24 @@ +/** + * An example of a plugin that provides a TrigScript library. The library is the TrigScript in lib/, carried as text in + * modules.ts (`node embed.mjs`), and handed to TrigScript through its `trigscript.build` service whenever TrigScript is + * there — it may start before this plugin or after it, and come back after an update. Scripts then import it by the + * id: `import { spawnWave } from "waves"`. + */ +import type { PluginApi } from "@scm-js/plugin-api"; +import { MODULES } from "./modules"; + +/** The part of TrigScript's `trigscript.build` a library needs (its `contract.d.ts` has the whole). */ +interface TrigScriptBuild { + provideLibrary(library: { id: string; label: string; version: string; modules: Record }): { dispose(): void }; +} + +export default function activate(api: PluginApi) { + let held: { dispose(): void } | null = null; + const watched = api.services.watch("trigscript.build", (build, info) => { + held?.dispose(); + held = null; + // Version 2 of the service is the first with libraries. + if (build && (info?.version ?? 1) >= 2) held = build.provideLibrary({ id: "waves", label: "Waves", version: api.plugin.version ?? "1.0.0", modules: MODULES }); + }); + return () => { watched.dispose(); held?.dispose(); }; +} diff --git a/ko.ts b/ko.ts index fe9ec95..a2f8dc6 100644 --- a/ko.ts +++ b/ko.ts @@ -468,4 +468,11 @@ export const KO: Record = { "{label} brought trigger() calls, and a contribution holds programs only.": "{label}이(가) trigger() 호출을 가져왔지만, 기여는 프로그램만 담습니다.", "Built by TrigScript: {n, plural, one {# program} other {# programs}} laid out into the map, {contributors} among them": "TrigScript가 빌드: 프로그램 {n}개를 맵에 배치, 그중 {contributors}의 것 포함", "{label}: TrigScript does not take this yet ({reason}).": "{label}: TrigScript가 아직 이것을 받지 못합니다({reason}).", + "{label} {version} (installed: {provided})": "{label} {version} (설치된 버전: {provided})", + "The map builds with its own copy of {n, plural, one {a library} other {# libraries}}, and the plugin installed here has another version: {list}. The copy stays until you update it.": "이 맵은 라이브러리 {n}개를 맵 안의 사본으로 빌드하는데, 여기에 설치된 플러그인은 다른 버전입니다: {list}. 업데이트하기 전까지 사본은 그대로 남습니다.", + "Keep the map's copy": "맵의 사본 유지", + "Update": "업데이트", + "Update all": "모두 업데이트", + "TrigScript: libraries": "TrigScript: 라이브러리", + "TrigScript: update libraries": "TrigScript: 라이브러리 업데이트", }; diff --git a/monaco.ts b/monaco.ts index bfca1c0..e056014 100644 --- a/monaco.ts +++ b/monaco.ts @@ -19,6 +19,7 @@ import type * as Monaco from "monaco-editor"; import { DECLARATIONS_FILE } from "./compiler/declarations"; import { MODULE_NAME } from "./compiler/api"; import { STDLIB, STDLIB_EXT } from "./compiler/stdlib"; +import type { ScriptLibraries } from "./compiler/libraries"; import type { LineHint, ScriptDiagnostic, ScriptFiles, SourceRange, VariableInfo } from "./compiler/compiler"; import { describeVariable, normalizePath } from "./compiler/compiler"; import { isMediaFile } from "./compiler/media"; @@ -159,12 +160,14 @@ export function loadMonaco(base: string = DEFAULT_DIST): Promise { /** * Point the language service at a fresh declaration file (the map's names changed), and at the standard library's * modules, where `import { fog } from "trigscript/fog"` finds them by the bundler's rules — so a hover and go to - * definition show the library's own TrigScript. + * definition show the library's own TrigScript. Other plugins' libraries (`libraries`, by id) sit beside it the same + * way: "stamps/patrol" is `node_modules/stamps/patrol.ts`. */ -export function setDeclarations(monaco: MonacoApi, content: string) { +export function setDeclarations(monaco: MonacoApi, content: string, libraries: ScriptLibraries = {}) { tsLanguage(monaco).typescriptDefaults.setExtraLibs([ { content, filePath: `file:///${DECLARATIONS_FILE}` }, ...Object.entries(STDLIB).map(([name, source]) => ({ content: source, filePath: `file:///node_modules/${MODULE_NAME}/${name}${STDLIB_EXT[name] ?? ".ts"}` })), + ...Object.entries(libraries).filter(([id]) => id !== MODULE_NAME).flatMap(([id, modules]) => Object.entries(modules).map(([path, source]) => ({ content: source, filePath: `file:///node_modules/${id}/${path}` }))), ]); } diff --git a/plugin.ts b/plugin.ts index cd1a60c..299cba4 100644 --- a/plugin.ts +++ b/plugin.ts @@ -13,7 +13,7 @@ import { openScriptEditor, relabelScriptEditor } from "./editor"; import { msg } from "./i18n"; import { KO } from "./ko"; import { VERSION } from "./version"; -import { ScriptService, type ScriptInput } from "./service"; +import { BUILD_SERVICE_VERSION, ScriptService, type ScriptInput } from "./service"; export default function activate(api: PluginApi) { // The plugin's words in the editor's language. Menu labels and command titles stay English, marked with msg(): the editor translates them where it draws them. @@ -40,12 +40,15 @@ export default function activate(api: PluginApi) { api.commands.register({ id: "build", title: msg("TrigScript: build"), run: (input, options) => svc.build(scriptInput(input), { takeOver: isRecord(options) && options.takeOver === true, replaceStale: isRecord(options) && options.replaceStale === true }) }); api.commands.register({ id: "print", title: msg("TrigScript: print records as script"), run: (triggers, options) => svc.print(records(triggers), isRecord(options) ? { imports: options.imports === true, header: str(options.header) } : undefined) }); api.commands.register({ id: "simulate", title: msg("TrigScript: simulate records"), run: (triggers, cycles, options) => svc.simulate(records(triggers), Math.max(1, Math.round(Number(cycles) || 30)), { player: isRecord(options) && typeof options.player === "number" ? options.player : undefined }) }); + api.commands.register({ id: "libraries", title: msg("TrigScript: libraries"), run: () => svc.libraryStatus() }); + api.commands.register({ id: "updateLibraries", title: msg("TrigScript: update libraries"), run: (ids) => svc.updateLibraries(Array.isArray(ids) ? ids.filter((x): x is string => typeof x === "string") : undefined) }); api.commands.register({ id: "triggerAt", title: msg("TrigScript: trigger at a source line"), run: (file, line) => svc.triggerAt(str(file) ?? "main.ts", Number(line) || 0) }); // Save, Test Map and export apply the script first and build its programs — the other plugins' parts too - // (`trigscript.build`, the service Magenta contributes its rows through; `contract.d.ts`). + // (`trigscript.build`, the service Magenta contributes its rows through and other plugins provide libraries through; + // `contract.d.ts`). const attached = svc.attach(); - const provided = api.services.provide("build", svc.buildService(VERSION), { version: 1 }); + const provided = api.services.provide("build", svc.buildService(VERSION), { version: BUILD_SERVICE_VERSION }); return () => { provided.dispose(); attached.dispose(); }; } diff --git a/script.ts b/script.ts index 8e7fd20..0dc8ce0 100644 --- a/script.ts +++ b/script.ts @@ -20,6 +20,7 @@ import { cloneTrigger, encodeTriggers, type TriggerRecord } from "./vendor/triggers"; import { ENTRY_FILE, normalizePath, type CompileResult, type ScriptFiles, type TriggerSource } from "./compiler/compiler"; import { HEAP_CELLS, STACK_DEPTH, heapCells, stackDepth } from "./compiler/ir"; +import { LIBRARY_ID, LIBRARY_PATH, libraryPath, type ScriptLibraries } from "./compiler/libraries"; /** The archive folder the script's files live in, next to `staredit\`. */ export const SCRIPT_FOLDER = "trigscript\\"; @@ -132,9 +133,9 @@ export function memberOf(path: string): string { return `${SCRIPT_FOLDER}${normalizePath(path).replace(/\//g, "\\")}`; } -/** The script path of a member under the folder, or null for anything else (the manifest included). */ +/** The script path of a member under the folder, or null for anything else (the manifest and the libraries' copies included). */ export function pathOf(member: string): string | null { - if (!isScriptMember(member)) return null; + if (!isScriptMember(member) || isLibraryMember(member)) return null; const rest = member.replace(/\//g, "\\").slice(SCRIPT_FOLDER.length); return SCRIPT_FILE.test(rest) ? rest.replace(/\\/g, "/") : null; } @@ -169,6 +170,69 @@ export function readFiles(extras: Extras): ScriptFiles { return out; } +/** + * Where the map keeps its copy of each library of another plugin's the script imports: `trigscript\@libraries\\`, + * the modules at their paths and `library.json` saying which library it is. No file of the script's can be there: "@" + * is not in a file name. The copy is what the map builds with, wherever it is opened — the plugin that provides the + * library is only where a copy comes from, and a newer one (`ScriptService.updateLibraries`). + */ +export const LIBRARIES_FOLDER = `${SCRIPT_FOLDER}@libraries\\`; +const LIBRARY_INFO = "library.json"; + +/** A library as the map keeps it: which one, the version it was copied at, and its modules by path. */ +export interface LibraryCopy { + id: string; + /** What the editor calls it ("Stamp Library"). */ + label: string; + version: string; + modules: Record; +} + +export const isLibraryMember = (name: string): boolean => memberKey(name).startsWith(memberKey(LIBRARIES_FOLDER)); + +/** The libraries the map keeps copies of, by id; a folder with no module in it, or an id no library has, is passed over. */ +export function readLibraries(extras: Extras): Record { + const out: Record = {}; + for (const [name, bytes] of extras) { + if (!isLibraryMember(name)) continue; + const [id, ...rest] = name.replace(/\//g, "\\").slice(LIBRARIES_FOLDER.length).split("\\"); + if (!LIBRARY_ID.test(id) || rest.length === 0) continue; + const path = rest.join("/"); + const copy = (out[id] ??= { id, label: id, version: "", modules: {} }); + if (path === LIBRARY_INFO) { + try { + const info = JSON.parse(decoder.decode(bytes)) as { label?: unknown; version?: unknown } | null; + if (typeof info?.label === "string" && info.label) copy.label = info.label; + if (typeof info?.version === "string") copy.version = info.version; + } catch { /* A broken library.json leaves the id for its name. */ } + } else if (LIBRARY_PATH.test(path)) copy.modules[path] = decoder.decode(bytes); + } + for (const id of Object.keys(out)) if (Object.keys(out[id].modules).length === 0) delete out[id]; + return out; +} + +/** The members with the map's copies of the libraries replaced by these: a library not among them is removed. A member whose text is the same keeps its bytes. */ +export function withLibraries(extras: Extras, libraries: Readonly>): Map { + const next = new Map(); + const was = new Map(); + for (const [name, bytes] of extras) (isLibraryMember(name) ? was.set(memberKey(name), bytes) : next.set(name, bytes)); + const put = (name: string, text: string) => { + const old = was.get(memberKey(name)); + next.set(name, old && decoder.decode(old) === text ? old : encoder.encode(text)); + }; + for (const lib of Object.values(libraries)) { + const folder = `${LIBRARIES_FOLDER}${lib.id}\\`; + put(`${folder}${LIBRARY_INFO}`, JSON.stringify({ label: lib.label, version: lib.version }, null, 2)); + for (const [path, text] of Object.entries(lib.modules)) put(`${folder}${path.replace(/\//g, "\\")}`, text); + } + return next; +} + +/** The copies as the compiler takes them: each library's modules by id. */ +export function librarySources(libraries: Readonly>): ScriptLibraries { + return Object.fromEntries(Object.values(libraries).map((l) => [l.id, l.modules])); +} + /** The members with the script's files replaced by these (a file not in `files` is removed). */ export function withFiles(extras: Extras, files: ScriptFiles): Map { const next = new Map(); @@ -226,6 +290,18 @@ export function hashFiles(files: ScriptFiles): string { return hashText(paths.map((p) => `${p}\0${files[p] ?? ""}\0`).join("")); } +/** + * `hashFiles` over the files and the libraries they were compiled with, so that a library updated in the map counts as + * a change of the script's. With no libraries it is `hashFiles` itself: a manifest from before libraries still matches. + */ +export function hashSources(files: ScriptFiles, libraries: Readonly> = {}): string { + const libs = Object.values(libraries); + if (libs.length === 0) return hashFiles(files); + const all: ScriptFiles = { ...files }; + for (const lib of libs) for (const [path, text] of Object.entries(lib.modules)) all[libraryPath(lib.id, path)] = text; + return hashFiles(all); +} + /** Where the manifest's block actually is in the list, or null when its records are gone. */ export function findBlock(list: TriggerRecord[], manifest: ScriptManifest): ScriptBlock | null { const { start, count } = manifest; @@ -274,17 +350,20 @@ export interface ScriptState { unbuilt: boolean; /** The script had programs when it was last applied: the saved map is built with them and needs StarCraft: Remastered. */ programs: number; + /** The libraries of other plugins' the map keeps a copy of: what the script builds with. */ + libraries: { id: string; label: string; version: string }[]; } export function scriptState(triggers: TriggerRecord[] | null, extras: Extras): ScriptState { const read = readFiles(extras); const files = Object.keys(read).length ? read : null; + const libraries = readLibraries(extras); const manifest = readManifest(extras); const block = triggers && manifest ? findBlock(triggers, manifest) : null; - const unbuilt = files !== null && (!manifest || manifest.sourceHash !== hashFiles(files)); + const unbuilt = files !== null && (!manifest || manifest.sourceHash !== hashSources(files, libraries)); const stale = !!manifest && !block; const parts = stale && triggers ? staleRecords(triggers, manifest!) : null; - return { files, source: files?.[ENTRY_FILE] ?? null, manifest, block, stale, edited: parts ? { unchanged: parts.unchanged.length, changed: parts.changed.length } : null, unbuilt, programs: manifest?.programs ?? 0 }; + return { files, source: files?.[ENTRY_FILE] ?? null, manifest, block, stale, edited: parts ? { unchanged: parts.unchanged.length, changed: parts.changed.length } : null, unbuilt, programs: manifest?.programs ?? 0, libraries: Object.values(libraries).map(({ id, label, version }) => ({ id, label, version })) }; } export function isGenerated(state: ScriptState, index: number): boolean { @@ -343,6 +422,11 @@ export interface BuildOptions { * the old records all stay. */ replaceStale?: boolean; + /** + * The libraries the compile used, to keep as the map's copies in place of the ones there (a library the script no + * longer imports goes). Absent: the copies stay as they are, and the files are taken to have been compiled with them. + */ + libraries?: Readonly>; } export interface BuildPlan { @@ -384,8 +468,9 @@ export function buildScript(triggers: TriggerRecord[], extras: Extras, files: Sc } else { start = triggers.length; before = triggers.slice(); after = []; } - const manifest: ScriptManifest = { version: 2, programs: compiled.ir.length, start, count: records.length, hash: hashTriggers(records), sources: compiled.sources, files: Object.keys(files).map(normalizePath).sort(), sourceHash: hashFiles(files), records: records.map(hashRecord) }; - return { list: [...before, ...records, ...after], extras: withSounds(withManifest(options.keepFiles ? extras : withFiles(extras, files), manifest), compiled.sounds ?? []), block: { start, count: records.length, sources: manifest.sources }, ...(replaced ? { replaced } : {}) }; + const manifest: ScriptManifest = { version: 2, programs: compiled.ir.length, start, count: records.length, hash: hashTriggers(records), sources: compiled.sources, files: Object.keys(files).map(normalizePath).sort(), sourceHash: hashSources(files, options.libraries ?? readLibraries(extras)), records: records.map(hashRecord) }; + const stored = options.keepFiles ? extras : options.libraries ? withLibraries(withFiles(extras, files), options.libraries) : withFiles(extras, files); + return { list: [...before, ...records, ...after], extras: withSounds(withManifest(stored, manifest), compiled.sounds ?? []), block: { start, count: records.length, sources: manifest.sources }, ...(replaced ? { replaced } : {}) }; } /** Which trigger a source line of a file belongs to (the trigger whose source starts at or before the line), if any. */ diff --git a/service.ts b/service.ts index 97f2654..6e28b1a 100644 --- a/service.ts +++ b/service.ts @@ -26,9 +26,11 @@ import { simulate, type SimulationEvent } from "./compiler/simulate"; import type { TestResult, TestRunOptions, TestWorld } from "./compiler/testing"; import { DEFAULT_DIST, DIST_STORAGE_KEY } from "./monaco"; import { - DEFAULT_SETTINGS, buildScript, findBlock, hashFiles, hashText, isBuiltSound, isScriptMember, readManifest, readSettings, relocateManifest, scriptState, triggerAtLine, withFiles, withSettings, type ScriptSettings, - type BuildOptions, type Extras, type ScriptBlock, type ScriptState, + DEFAULT_SETTINGS, buildScript, findBlock, hashFiles, hashSources, hashText, isBuiltSound, isScriptMember, librarySources, readLibraries, readManifest, readSettings, relocateManifest, scriptState, triggerAtLine, withFiles, + withLibraries, withSettings, type ScriptSettings, type BuildOptions, type Extras, type LibraryCopy, type ScriptBlock, type ScriptState, } from "./script"; +import { libraryImports, libraryProblem } from "./compiler/libraries"; +import { MODULE_NAME } from "./compiler/api"; import { english, msg, type Translate } from "./i18n"; import { SCM_SIM_SERVICE, type ScmSimEngine } from "./vendor/scmSim"; @@ -56,6 +58,8 @@ export interface ScriptArtifact { context: string; /** `hashFiles` of the archive's files when the compile started: what the build may overwrite. */ archived: string; + /** The libraries of other plugins' the files import, as the compile had them: what applying keeps as the map's copies. */ + libraries: Record; } /** Why an artifact was not installed. */ @@ -164,14 +168,45 @@ export interface ContributedScript { files: ScriptFiles; entries: string[] } /** What `check` says of a contribution's source, as a build would compile it: each problem at its file and line. */ export interface ContributionCheck { problems: { file: string; line: number; column: number; message: string }[]; programs: number } +/** + * A library another plugin provides (`provideLibrary`): TrigScript modules a script imports by the id — + * `import { patrol } from "stamps/patrol"` is the module `patrol.ts`, `"stamps"` alone is `index.ts`. + */ +export interface ProvidedLibrary { + /** What a script imports it by: lower-case letters, digits and "-", starting with a letter; never "trigscript". */ + id: string; + /** What the editor calls it. */ + label: string; + /** The library's version: a map keeps the copy it took until someone updates it. */ + version: string; + /** The modules' TrigScript source by path in the library ("patrol.ts", "ai/waves.tsx"); no tests. */ + modules: Record; +} + +/** A library as the open map has it, and as a plugin provides it. */ +export interface LibraryStatus { + id: string; + label: string; + /** The version the map keeps a copy of; null when it keeps none. */ + version: string | null; + /** The version a plugin provides now; null when none does. */ + provided: string | null; + /** The provided library differs from the map's copy: `updateLibraries` would take it. */ + update: boolean; +} + /** The service other plugins reach as `trigscript.build` (`contract.d.ts` is its published shape). */ export interface BuildService { versions: { plugin: string }; contribute(contribution: BuildContribution): { dispose(): void }; onBuild(listener: (event: OwnBuildEvent) => void): { dispose(): void }; check(files: ScriptFiles, entries: string[]): Promise; + provideLibrary(library: ProvidedLibrary): { dispose(): void }; } +/** The version of `trigscript.build` (`ServiceInfo.version`): 2 has `provideLibrary`. */ +export const BUILD_SERVICE_VERSION = 2; + /** * What a build of the programs says as it goes (`onBuild`): its start, each line of its log, its end or its failure — * and a warning: a trigger of the map's own left out or never run (`OwnBuild.warnings`). (The shape is the eudplib @@ -213,6 +248,9 @@ export class ScriptService { private readonly ownListeners = new Set<(event: OwnBuildEvent) => void>(); /** The other plugins' parts of the build (`contribute`), by id. */ private readonly contributions = new Map(); + /** The libraries other plugins provide (`provideLibrary`), by id, and who hears when they come and go. */ + private readonly provided = new Map(); + private readonly libraryListeners = new Set<() => void>(); private lastManifest: Uint8Array | null | undefined; /** The last compile, for a save that comes right after an apply: the same files against the same names need no second compile. */ private lastArtifact: ScriptArtifact | null = null; @@ -310,9 +348,78 @@ export class ScriptService { contribute: (c) => this.contribute(c), onBuild: (l) => this.onBuild(l), check: (files, entries) => this.check(files, entries), + provideLibrary: (library) => this.provideLibrary(library), }; } + /** + * Another plugin's library, for scripts to import while the handle lives; the second of one id replaces the first. + * Throws, in a sentence, for a library that cannot be taken (an id or a path it may not have, a test file). + */ + provideLibrary(library: ProvidedLibrary): { dispose(): void } { + const modules = Object.fromEntries(Object.entries(library.modules ?? {}).map(([path, text]) => [path.replace(/\\/g, "/").replace(/^\.\//, ""), text])); + const problem = libraryProblem(library.id, modules, [MODULE_NAME]); + if (problem) throw new Error(problem); + const copy: LibraryCopy = { id: library.id, label: library.label || library.id, version: String(library.version ?? ""), modules }; + this.provided.set(copy.id, copy); + this.librariesChanged(); + return { dispose: () => { if (this.provided.get(copy.id) === copy) { this.provided.delete(copy.id); this.librariesChanged(); } } }; + } + + /** Hear when a plugin provides a library or withdraws one, and when the map's copies change. */ + onLibraries(listener: () => void): { dispose(): void } { + this.libraryListeners.add(listener); + return { dispose: () => { this.libraryListeners.delete(listener); } }; + } + + private librariesChanged(): void { + for (const l of this.libraryListeners) l(); + } + + /** The map's copies of the libraries; none with no map. */ + private mapLibraries(): Record { + return this.api.document.isOpen() ? readLibraries(snapshotExtras(this.api)) : {}; + } + + /** Every library a script of the open map can import: the map's copy where it keeps one, else what a plugin provides. */ + libraries(): Record { + return { ...Object.fromEntries(this.provided), ...this.mapLibraries() }; + } + + /** The libraries these files import (and those import in turn), as a compile of them takes them. */ + librariesFor(files: ScriptFiles): Record { + const all = this.libraries(); + const { used } = libraryImports(Object.values(files), librarySources(all)); + return Object.fromEntries(used.map((id) => [id, all[id]])); + } + + /** The libraries the map keeps and the plugins provide, side by side, by id. */ + libraryStatus(): LibraryStatus[] { + const kept = this.mapLibraries(); + const ids = [...new Set([...Object.keys(kept), ...this.provided.keys()])].sort(); + return ids.map((id) => { + const copy = kept[id], offer = this.provided.get(id); + const differs = !!copy && !!offer && (copy.version !== offer.version || hashSources({}, { [id]: copy }) !== hashSources({}, { [id]: offer })); + return { id, label: (copy ?? offer)!.label, version: copy?.version ?? null, provided: offer?.version ?? null, update: differs }; + }); + } + + /** + * Replace the map's copies of these libraries (all that have one, by default) with what their plugins provide now. + * The map is modified and the script reads as changed, so the next save applies it with them. Returns the ids updated. + */ + updateLibraries(ids?: readonly string[]): string[] { + if (!this.api.document.isOpen()) return []; + const wanted = this.libraryStatus().filter((l) => l.update && (!ids || ids.includes(l.id))).map((l) => l.id); + if (wanted.length === 0) return []; + const before = snapshotExtras(this.api); + const kept = readLibraries(before); + for (const id of wanted) kept[id] = this.provided.get(id)!; + commitExtras(this.api, before, withLibraries(before, kept)); + this.librariesChanged(); + return wanted; + } + /** * A contribution's source as a build would compile it, without building: the problems (TypeScript's, the compiler's, * a `trigger()` where only programs go, what the own build does not take), for the contributor's own preflight. @@ -322,7 +429,8 @@ export class ScriptService { const at = entries[0] ?? Object.keys(files)[0] ?? ENTRY_FILE; if (!map) return { problems: [{ file: at, line: 1, column: 1, message: this.t("No map is open.") }], programs: 0 }; const own = this.ownCheck(); - const r = await this.compiler({ files: { [ENTRY_FILE]: "", ...files }, names: map.names, entries, ...(own ? { own } : {}) }, this.dist()); + const libraries = librarySources(this.librariesFor(files)); + const r = await this.compiler({ files: { [ENTRY_FILE]: "", ...files }, names: map.names, entries, ...(own ? { own } : {}), ...(Object.keys(libraries).length ? { libraries } : {}) }, this.dist()); const problems: ContributionCheck["problems"] = r.diagnostics.map((d) => ({ file: d.file, line: d.line, column: d.column, message: d.message })); if (r.ok && r.triggers.length) problems.push({ file: at, line: 1, column: 1, message: this.t("A contribution holds programs only: its trigger() calls are not written into the map.") }); if (r.ok && r.ownRefusal) { @@ -405,7 +513,7 @@ export class ScriptService { let compiled: CompileResult | null = null; if (state?.files) { const cached = this.lastArtifact; - const fresh = cached && cached.context === map.context && cached.document === this.documentId() && hashFiles(cached.files) === hashFiles(state.files) && (!guard || !cached.compiled.tests || cached.compiled.tests.results.length > 0); + const fresh = cached && cached.context === map.context && cached.document === this.documentId() && hashSources(cached.files, cached.libraries) === hashSources(state.files, readLibraries(snapshotExtras(this.api))) && (!guard || !cached.compiled.tests || cached.compiled.tests.results.length > 0); const artifact = fresh ? cached : await this.prepare(state.files, map, guard ? { world: this.world() } : undefined); if (!artifact.compiled.ok) throw new Error(firstFault(artifact.compiled.diagnostics)); if (guard) this.guardTests(artifact.compiled); @@ -414,7 +522,8 @@ export class ScriptService { // With contributions: the script and what they bring, compiled together, their entries after the script's. if (brought.entries.length) { const files = { ...(state?.files ?? { [ENTRY_FILE]: "" }), ...brought.files }; - compiled = await this.compiler({ files, names: map.names, entries: brought.entries }, this.dist()); + const libraries = librarySources(this.librariesFor(files)); + compiled = await this.compiler({ files, names: map.names, entries: brought.entries, ...(Object.keys(libraries).length ? { libraries } : {}) }, this.dist()); if (!compiled.ok) { const fault = compiled.diagnostics[0]; const c = fault && brought.of.get(fault.file); @@ -592,8 +701,11 @@ export class ScriptService { const archived = hashFiles(this.state()?.files ?? {}); // (And whether TrigScript's own build takes the programs for this map: the apply writes the answer into the manifest.) const own = this.ownCheck(); - const compiled = await this.compiler({ files, names: map.names, ...(tests ? { tests } : {}), ...(sounds ? { sounds } : {}), ...(own ? { own } : {}) }, this.dist()); - const artifact = { files, compiled, document, context: map.context, archived }; + // The libraries the files import: the map's copies, and what a plugin provides of one the map has no copy of yet. + const libraries = this.librariesFor(files); + const sources = librarySources(libraries); + const compiled = await this.compiler({ files, names: map.names, ...(tests ? { tests } : {}), ...(sounds ? { sounds } : {}), ...(own ? { own } : {}), ...(Object.keys(sources).length ? { libraries: sources } : {}) }, this.dist()); + const artifact = { files, compiled, document, context: map.context, archived, libraries }; this.lastArtifact = artifact; return artifact; } @@ -607,7 +719,7 @@ export class ScriptService { * `takeOver` replaces the whole trigger list with the script's. */ install(artifact: ScriptArtifact, options: BuildOptions = {}): ScriptBuildResult { - const { compiled, files } = artifact; + const { compiled, files, libraries } = artifact; const refuse = (refused: BuildRefusal): ScriptBuildResult => ({ compiled, block: null, refused }); if (!this.api.document.isOpen()) return refuse("closed"); if (this.documentId() !== artifact.document) return refuse("switched"); @@ -618,16 +730,19 @@ export class ScriptService { const keepFiles = hashFiles(this.state()?.files ?? {}) !== artifact.archived; let block: ScriptBlock | null = null; let replaced: ScriptBuildResult["replaced"]; + const keptBefore = JSON.stringify(this.libraryStatus()); this.api.document.update("Apply TrigScript", (tx) => { const before = snapshotExtras(this.api); const intern = (text: string) => tx.strings.intern(text); - const plan = buildScript(tx.triggers.list(), before, files, compiled, intern, { ...options, keepFiles }); + // The libraries the script was compiled with become the map's copies: it builds with them wherever it is opened. + const plan = buildScript(tx.triggers.list(), before, files, compiled, intern, { ...options, keepFiles, libraries }); tx.triggers.set(plan.list); commitExtras(this.api, before, plan.extras); block = plan.block; replaced = plan.replaced; }); this.claim.refresh(); + if (JSON.stringify(this.libraryStatus()) !== keptBefore) this.librariesChanged(); if (block) { const b: ScriptBlock = block; const n = compiled.ir.length; diff --git a/tests/compile-worker.test.ts b/tests/compile-worker.test.ts index feddf12..b6d0d84 100644 --- a/tests/compile-worker.test.ts +++ b/tests/compile-worker.test.ts @@ -109,3 +109,22 @@ describe("the compile worker", () => { expect(posted.at(-1)).toEqual({ id: 1, result: "compiled" }); }); }); + +describe("the worker's own source", () => { + it("hands the compiler what a request carries, the libraries among it", async () => { + vi.resetModules(); + const { WORKER_SOURCE } = await import("../compile"); + const seen: unknown[] = []; + const posted: unknown[] = []; + // The compiler the worker imports (a dynamic import, which a Function here cannot make): it keeps the options it was called with. + const mod = { compileScript: (_ts: unknown, _files: unknown, _names: unknown, options: unknown) => { seen.push(options); return { sounds: [] }; } }; + expect(WORKER_SOURCE).toContain("import(moduleUrl)"); + const self: { ts: unknown; onmessage?: (e: { data: unknown }) => Promise } = { ts: {} }; + const run = new Function("self", "importScripts", "postMessage", "fetch", "__import", WORKER_SOURCE.replace("import(moduleUrl)", "__import(moduleUrl)")); + run(self, () => {}, (m: unknown) => posted.push(m), async () => ({ ok: true, text: async () => "lib" }), async () => mod); + const libraries = { stamps: { "say.ts": "export const a = 1;" } }; + await self.onmessage!({ data: { id: 1, moduleUrl: "compiler.js", libUrl: "lib.d.ts", files: { "main.ts": "" }, names: {}, entries: ["x.ts"], libraries } }); + expect(posted).toEqual([{ id: 1, result: { sounds: [] } }]); + expect(seen).toEqual([{ lib: "lib", tests: undefined, sounds: false, own: undefined, entries: ["x.ts"], libraries }]); + }); +}); diff --git a/tests/libraries.test.ts b/tests/libraries.test.ts new file mode 100644 index 0000000..0cacf6b --- /dev/null +++ b/tests/libraries.test.ts @@ -0,0 +1,137 @@ +/** + * Libraries of other plugins' (`compiler/libraries.ts`): TrigScript modules a plugin provides, imported by the + * library's id ("stamps/patrol") as the standard library is by "trigscript/fog". Which modules join the script's + * files, what they may import, and what a script is told when a library is not there. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { compileScript, type CompileResult } from "../compiler/compiler"; +import { libraryImports, libraryProblem, type ScriptLibraries } from "../compiler/libraries"; +import { defaultScriptNames } from "../compiler/names"; +import { simulatePrograms } from "../compiler/simulateIr"; +import { defaultLib } from "../bundle/lib.mjs"; +import { MODULES } from "../examples/library/modules"; +import { LIBRARIES_FOLDER, hashFiles, hashSources, pathOf, readFiles, readLibraries, withFiles, withLibraries } from "../script"; + +const LIB = defaultLib(); +const NAMES = defaultScriptNames(); +const STAMPS: ScriptLibraries = { + stamps: { + "index.ts": `export { double } from "./math/double";\nexport const NAME = "stamps";`, + "math/double.ts": `export function double(n: number) { return n * 2; }`, + "say.ts": `import { double } from "./math/double";\nexport function say(n: number) { print(\`stamps \${double(n)}\`); }`, + "ticker.ts": `program(() => { print("ticker"); });\nexport const TICKER = 1;`, + "fogged.ts": `import { fog } from "trigscript/fog";\nexport const lit = () => fog;`, + }, + waves: { "index.tsx": `import { double } from "stamps";\nexport const wave = (n: number) => double(n) + 1;` }, +}; +const raw = (main: string, libraries: ScriptLibraries = STAMPS): CompileResult => compileScript(ts, { "main.ts": main }, NAMES, { lib: LIB, libraries }); +const shown = (main: string, libraries?: ScriptLibraries) => { + const r = raw(main, libraries); + expect(r.diagnostics.map((d) => `${d.file}:${d.line}: ${d.message}`)).toEqual([]); + const sim = simulatePrograms(r.ir, 1, { strings: r.strings }); + expect(sim.faults).toEqual([]); + return sim.events.map((e) => e.text); +}; + +describe("libraries of other plugins", () => { + it("a module by its id and path, with what it imports of its own library", () => { + expect(shown(`import { say } from "stamps/say";\nprogram(() => { let k = 4; say(k); });`)).toEqual(["stamps 8"]); + }); + it("beside the library's own names, from the same file", () => { + expect(shown(`import { units } from "trigscript";\nimport { double } from "stamps";\nprogram(() => { let k = 3; setDeaths(P1, units.TerranMarine, "set", double(k)); print(\`\${double(k)}\`); });`)).toEqual(["6"]); + }); + it("the library by its id alone is its index module", () => { + expect(shown(`import { double, NAME } from "stamps";\nprogram(() => { let k = 3; print(\`\${NAME} \${double(k)}\`); });`)).toEqual(["stamps 6"]); + }); + it("a library that imports another library", () => { + expect(shown(`import { wave } from "waves";\nprogram(() => { let k = 2; print(\`\${wave(k)}\`); });`)).toEqual(["5"]); + }); + it("only the modules something imports join the files: a program in another module is not built", () => { + const r = raw(`import { say } from "stamps/say";\nprogram(() => { say(1); });`); + expect(Object.keys(libraryImports([`import { say } from "stamps/say";`], STAMPS).files).sort()).toEqual(["__libraries__/stamps/math/double.ts", "__libraries__/stamps/say.ts"]); + expect(r.ir).toHaveLength(1); + const ticked = raw(`import { TICKER } from "stamps/ticker";\nprogram(() => { print(\`\${TICKER}\`); });`); + expect(ticked.ir.map((p) => p.at?.file)).toEqual(["__libraries__/stamps/ticker.ts", "main.ts"]); + }); + it("says which libraries a set of files uses, through the ones they import", () => { + expect(libraryImports([`import { wave } from "waves";`], STAMPS).used).toEqual(["stamps", "waves"]); + expect(libraryImports([`import { units } from "trigscript"; import { x } from "./x";`], STAMPS).used).toEqual([]); + }); + it("a library module may import the standard library", () => { + expect(raw(`import { lit } from "stamps/fogged";\nprogram(() => { lit(); });`).diagnostics.filter((d) => /Cannot find module/.test(d.message))).toEqual([]); + }); + it("a library nobody provides is said as one", () => { + const r = raw(`import { x } from "nobody/thing";\nprogram(() => {});`); + expect(r.ok).toBe(false); + expect(r.diagnostics[0].message).toMatch(/no plugin provides "nobody" here, and the map keeps no copy of it/); + }); + it("a module the library does not have is said with the ones it has", () => { + const r = raw(`import { x } from "stamps/nope";\nprogram(() => {});`); + expect(r.diagnostics[0].message).toMatch(/The library "stamps" has no such module: it has "stamps\/index", "stamps\/math\/double"/); + }); + it("the standard library's name is never a library's", () => { + const r = raw(`import { fog } from "trigscript/fog";\nprogram(() => { fog; });`, { trigscript: { "fog.ts": "export const fog = 1;" } }); + expect(r.diagnostics.filter((d) => /fog/.test(d.message))).toEqual([]); + }); +}); + +describe("what a library may be", () => { + it("takes ids, paths and modules", () => { + expect(libraryProblem("stamps", { "a.ts": "", "b/c.tsx": "" })).toBeNull(); + expect(libraryProblem("Stamps", { "a.ts": "" })).toMatch(/not a library id/); + expect(libraryProblem("trigscript", { "a.ts": "" }, ["trigscript"])).toMatch(/keeps for itself/); + expect(libraryProblem("stamps", {})).toMatch(/no modules/); + expect(libraryProblem("stamps", { "../a.ts": "" })).toMatch(/not a module path/); + expect(libraryProblem("stamps", { "a.js": "" })).toMatch(/not a module path/); + expect(libraryProblem("stamps", { "a.test.ts": "" })).toMatch(/test file/); + }); +}); + +describe("the map's copy of a library", () => { + const copy = { id: "stamps", label: "Stamp Library", version: "1.2.0", modules: { "say.ts": "export const a = 1;", "ai/waves.tsx": "export const b = 2;" } }; + it("is kept under its own folder, apart from the script's files", () => { + const extras = withLibraries(withFiles(new Map(), { "main.ts": "x", "libraries/own.ts": "y" }), { stamps: copy }); + expect([...extras.keys()].filter((n) => n.startsWith(LIBRARIES_FOLDER)).sort()).toEqual([ + "trigscript\\@libraries\\stamps\\ai\\waves.tsx", "trigscript\\@libraries\\stamps\\library.json", "trigscript\\@libraries\\stamps\\say.ts", + ]); + expect(readFiles(extras)).toEqual({ "main.ts": "x", "libraries/own.ts": "y" }); + expect(pathOf("trigscript\\@libraries\\stamps\\say.ts")).toBeNull(); + expect(readLibraries(extras)).toEqual({ stamps: copy }); + // Writing the script's files leaves the copy; writing no libraries removes it. + expect(readLibraries(withFiles(extras, { "main.ts": "z" }))).toEqual({ stamps: copy }); + expect(readLibraries(withLibraries(extras, {}))).toEqual({}); + }); + it("keeps a member's bytes when its text is the same", () => { + const extras = withLibraries(new Map(), { stamps: copy }); + const again = withLibraries(extras, { stamps: copy }); + for (const [name, bytes] of again) expect(extras.get(name)).toBe(bytes); + }); + it("counts in the script's hash, and a script with none hashes as before", () => { + const files = { "main.ts": "x" }; + expect(hashSources(files)).toBe(hashFiles(files)); + expect(hashSources(files, {})).toBe(hashFiles(files)); + expect(hashSources(files, { stamps: copy })).not.toBe(hashFiles(files)); + expect(hashSources(files, { stamps: { ...copy, modules: { ...copy.modules, "say.ts": "export const a = 2;" } } })).not.toBe(hashSources(files, { stamps: copy })); + }); +}); + +describe("the example library (examples/library)", () => { + it("carries lib/ as it is", () => { + const lib = join(import.meta.dirname, "../examples/library/lib"); + const files = readdirSync(lib).filter((f) => f.endsWith(".ts")).sort(); + expect(Object.keys(MODULES).sort()).toEqual(files); + for (const f of files) expect(MODULES[f]).toBe(readFileSync(join(lib, f), "utf8")); + }); + it("is a library TrigScript takes, and its index's example runs", () => { + expect(libraryProblem("waves", MODULES, ["trigscript"])).toBeNull(); + const example = /\* {3}(import[\s\S]*?\}\);)\n/.exec(MODULES["index.ts"])![1].replace(/^ \*(?: {3})?/gm, ""); + const r = compileScript(ts, { "main.ts": example.replace("locations.Spawn", "locations.Anywhere") }, NAMES, { lib: LIB, libraries: { waves: MODULES } }); + expect(r.diagnostics.map((d) => `${d.file}:${d.line}: ${d.message}`)).toEqual([]); + const sim = simulatePrograms(r.ir, 1, { strings: r.strings }); + expect(sim.faults).toEqual([]); + expect(sim.events.map((e) => e.text).filter(Boolean)[0]).toBe("— Wave 1 —"); + }); +}); diff --git a/tests/service.test.ts b/tests/service.test.ts index 9e6569b..0018ea1 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -13,7 +13,7 @@ import { compileScript } from "../compiler/compiler"; import { buildOwnMap } from "../compiler/backend/build"; import { Sections, readFacts, writeChunks } from "../compiler/backend/chk"; import { firstFault, MAGENTA_MEMBER, positionIn, refusalAt, ScriptService, type BuildContribution, type Compiler } from "../service"; -import { readFiles, readManifest, scriptState } from "../script"; +import { LIBRARIES_FOLDER, readFiles, readLibraries, readManifest, scriptState } from "../script"; import type { OwnBuildEvent } from "../service"; import { defaultLib } from "../bundle/lib.mjs"; @@ -87,7 +87,7 @@ function host() { /** The real compiler, at once — with a hook to run something between the compile starting and its result landing. */ function compiler(during: () => void = () => {}): Compiler { return async (input) => { - const r = compileScript(ts, input.files, input.names, { lib: LIB, ...(input.own ? { own: input.own } : {}), ...(input.entries ? { entries: input.entries } : {}) }); + const r = compileScript(ts, input.files, input.names, { lib: LIB, ...(input.own ? { own: input.own } : {}), ...(input.entries ? { entries: input.entries } : {}), ...(input.libraries ? { libraries: input.libraries } : {}) }); during(); return r; }; @@ -432,3 +432,90 @@ describe("service: what other plugins contribute to the build", () => { expect(svc.whyNotBuilt()).not.toBeNull(); }); }); + +describe("service: libraries other plugins provide", () => { + const STAMPS = { id: "stamps", label: "Stamp Library", version: "1.0.0", modules: { "say.ts": `import { twice } from "./twice";\nexport function say(n: number) { displayText(\`stamps \${twice(n)}\`); }`, "twice.ts": "export const twice = (n: number) => n * 2;", "unused.ts": "export const x = 1;" } }; + const MAIN = `import { say } from "stamps/say";\n${SRC}\nprogram(() => { let k = 2; say(k); });`; + const kept = (d: { extras: Map }) => [...d.extras.keys()].filter((n) => n.startsWith(LIBRARIES_FOLDER)).sort(); + + it("applying copies the modules the script imports into the map, and the map builds with the copy once the plugin is gone", async () => { + const h = host(); + const d = h.open(1); + const svc = new ScriptService(h.api, () => {}, compiler(), builder); + svc.attach(); + const handle = svc.buildService("4.1.0").provideLibrary(STAMPS); + const out = await svc.build(MAIN); + expect(out.compiled.diagnostics).toEqual([]); + expect(kept(d)).toEqual([`${LIBRARIES_FOLDER}stamps\\library.json`, `${LIBRARIES_FOLDER}stamps\\say.ts`, `${LIBRARIES_FOLDER}stamps\\twice.ts`, `${LIBRARIES_FOLDER}stamps\\unused.ts`]); + // The copy is none of the script's files, and the state says which library it is. + expect(Object.keys(readFiles(d.extras))).toEqual(["main.ts"]); + expect(scriptState(d.triggers, d.extras)).toMatchObject({ unbuilt: false, libraries: [{ id: "stamps", label: "Stamp Library", version: "1.0.0" }] }); + handle.dispose(); + expect(svc.libraryStatus()).toEqual([{ id: "stamps", label: "Stamp Library", version: "1.0.0", provided: null, update: false }]); + expect((await svc.compile(MAIN)).diagnostics).toEqual([]); + const built = await h.steps[0].run({ map: chk(), fileName: "x.scx", purpose: "save", signal: new AbortController().signal }); + expect(new Sections(built).get("TRIG")!.length).toBeGreaterThan(0); + }); + + it("without the plugin and with no copy, the import is a problem that says so", async () => { + const h = host(); + h.open(1); + const svc = new ScriptService(h.api, () => {}, compiler()); + const r = await svc.compile(MAIN); + expect(r.ok).toBe(false); + expect(r.diagnostics[0].message).toMatch(/no plugin provides "stamps" here/); + }); + + it("a newer version is offered, not taken: updating replaces the copy and the script reads as changed", async () => { + const h = host(); + const d = h.open(1); + const svc = new ScriptService(h.api, () => {}, compiler()); + const heard: number[] = []; + svc.onLibraries(() => heard.push(1)); + const first = svc.provideLibrary(STAMPS); + await svc.build(MAIN); + first.dispose(); + const newer = { ...STAMPS, version: "1.1.0", modules: { ...STAMPS.modules, "twice.ts": "export const twice = (n: number) => n * 2 + 1;" } }; + svc.provideLibrary(newer); + expect(svc.libraryStatus()).toEqual([{ id: "stamps", label: "Stamp Library", version: "1.0.0", provided: "1.1.0", update: true }]); + // The map's copy is what a compile takes until the update. + expect(svc.librariesFor({ "main.ts": MAIN }).stamps.version).toBe("1.0.0"); + expect(svc.state()?.unbuilt).toBe(false); + expect(svc.updateLibraries()).toEqual(["stamps"]); + expect(readLibraries(d.extras).stamps).toMatchObject({ version: "1.1.0", modules: newer.modules }); + expect(svc.state()?.unbuilt).toBe(true); + expect(svc.libraryStatus()[0].update).toBe(false); + expect(svc.updateLibraries()).toEqual([]); + expect(heard.length).toBeGreaterThanOrEqual(4); + }); + + it("a library the script no longer imports leaves the map with the next apply", async () => { + const h = host(); + const d = h.open(1); + const svc = new ScriptService(h.api, () => {}, compiler()); + svc.provideLibrary(STAMPS); + await svc.build(MAIN); + expect(kept(d).length).toBe(4); + await svc.build(SRC); + expect(kept(d)).toEqual([]); + expect(svc.state()?.unbuilt).toBe(false); + }); + + it("a contribution may import a library too", async () => { + const h = host(); + h.open(1); + const svc = new ScriptService(h.api, () => {}, compiler()); + svc.provideLibrary(STAMPS); + const r = await svc.check({ "magenta/main.ts": `import { twice } from "stamps/twice";\nprogram(() => { let k = 1; k = twice(k); });` }, ["magenta/main.ts"]); + expect(r).toEqual({ problems: [], programs: 1 }); + }); + + it("refuses a library it cannot take", () => { + const h = host(); + h.open(1); + const svc = new ScriptService(h.api, () => {}, compiler()); + expect(() => svc.provideLibrary({ ...STAMPS, id: "trigscript" })).toThrow(/keeps for itself/); + expect(() => svc.provideLibrary({ ...STAMPS, id: "Bad Id" })).toThrow(/not a library id/); + expect(() => svc.provideLibrary({ ...STAMPS, modules: { "say.test.ts": "" } })).toThrow(/test file/); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 647a100..6ab3e17 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,7 @@ "compiler/*.ts", "compiler/generated/*.ts", "vendor/*.ts", + "examples/library/*.ts", "tests" ] }