@plugjs/plug
Version:
PlugJS Build System ===================
205 lines • 8.36 kB
JavaScript
/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */
import { sep } from 'node:path';
import { assert, assertPromises } from "./asserts.js";
import { getLogger } from "./logging.js";
import { getAbsoluteParent, getCurrentWorkingDirectory, resolveAbsolutePath } from "./paths.js";
/* ========================================================================== *
* PLUG CONTEXT *
* ========================================================================== */
/**
* The {@link Context} class defines the context in which a {@link Plug}
* is invoked.
*/
export class Context {
buildFile;
taskName;
/** The directory of the file where the task was defined (convenience). */
buildDir;
/** The {@link Logger} associated with this instance. */
log;
constructor(
/** The absolute file name where the task was defined. */
buildFile,
/** The _name_ of the task associated with this {@link Context}. */
taskName) {
this.buildFile = buildFile;
this.taskName = taskName;
this.buildDir = getAbsoluteParent(buildFile);
this.log = getLogger(taskName);
}
/** Return a new {@link Context} with the specified task name */
withTaskName(taskName) {
return new Context(this.buildFile, taskName);
}
/**
* Resolve a (set of) path(s) in this {@link Context}.
*
* If the path (or first component thereof) starts with `@...`, then the
* resolved path will be relative to the directory containing the build file
* where the current task was defined, otherwise it will be relative to the
* current working directory.
*/
resolve(path, ...paths) {
// Paths starting with "@" are relative to the build file directory
if (path && path.startsWith('@')) {
// We can have paths like "@/../foo/bar" or "@../foo/bar"... both are ok
const components = path.substring(1).split(sep).filter((s) => !!s);
return resolveAbsolutePath(this.buildDir, ...components, ...paths);
}
// No path? Resolve to the CWD!
if (!path)
return getCurrentWorkingDirectory();
// For all the rest, normal resolution!
return resolveAbsolutePath(getCurrentWorkingDirectory(), path, ...paths);
}
}
/* ========================================================================== *
* PIPES *
* ========================================================================== */
/**
* In pipe chains, we want to keep track of the _leaf_ promises (that
* is, when a derived pipe is created calling `plug` we want to track only the
* new, derived, promise).
*
* We key these _leaf_ promises by _context_ (with a WeakMap), and those will
* be awaited at the end of the task.
*/
const contextPromises = new WeakMap();
/**
* An internal class recording _hot_ (failure will fail the task) and _cold_
* (failure will be ignored) {@link Promise}s for a task's {@link Context}.
*/
export class ContextPromises {
context;
_cold = new Set();
_hot = new Set();
/* Private constructor */
constructor(context) {
this.context = context;
}
/** Track a {@link Promise} _hot_ (failure will fail the task) */
hot(promise) {
this._cold.delete(promise);
this._hot.add(promise);
}
/** Track a {@link Promise} _cold_ (failure will be ignored) */
cold(promise) {
this._hot.delete(promise);
this._cold.add(promise);
}
/**
* Await all tracked {@link Promise}s, triggering a build failure if any of
* the _hot_ ones is rejected.
*/
static async wait(context) {
const instance = contextPromises.get(context);
if (!instance)
return;
await Promise.allSettled([...instance._cold]);
await assertPromises([...instance._hot]);
}
/** Get a {@link ContextPromises} instance for the given {@link Context} */
static get(context) {
let promises = contextPromises.get(context);
if (!promises) {
promises = new ContextPromises(context);
contextPromises.set(context, promises);
}
return promises;
}
}
/** The default implementation of the {@link Pipe} interface. */
export class PipeImpl {
_context;
_promise;
[Symbol.toStringTag] = 'Pipe';
constructor(_context, _promise) {
this._context = _context;
this._promise = _promise;
// New "Pipe", remember the promise!
ContextPromises.get(_context).hot(_promise);
}
/* ------------------------------------------------------------------------ *
* Promise implementation *
* ------------------------------------------------------------------------ *
* From a _types_ point of view, the `Pipe` implements a `Promise<Files>` *
* (because only when plugging the correct `Plug` the correct value are *
* returned). *
* *
* Whether to return (as a type) another `Pipe` or a `Promise<undefined>` *
* is determined by the type of the `plug` parameter below. *
* *
* That said, in practice, a `Pipe` implements `Promise<Files | undefined>` *
* because the result of the plug is _eventually_ computed asynchronously *
* while `plug` returns immediately.
* *
* So, all those "as whatever" below are kind-of-legit... *
* ------------------------------------------------------------------------ */
then(onfulfilled, onrejected) {
// We are delegating the handling of this promise to the caller
ContextPromises.get(this._context).cold(this._promise);
return this._promise.then(onfulfilled, onrejected);
}
catch(onrejected) {
// We are delegating the handling of this promise to the caller
ContextPromises.get(this._context).cold(this._promise);
return this._promise.catch(onrejected);
}
finally(onfinally) {
// We are delegating the handling of this promise to the caller
ContextPromises.get(this._context).cold(this._promise);
return this._promise.finally(onfinally);
}
plug(arg) {
const plug = typeof arg === 'function' ? { pipe: arg } : arg;
// We are creating a new "leaf" Pipe, we can forget our promise
ContextPromises.get(this._context).cold(this._promise);
// Create and return the new Pipe
return new PipeImpl(this._context, this._promise.then(async (result) => {
assert(result, 'Unable to extend pipe');
const result2 = await plug.pipe(result, this._context);
return result2 || undefined;
}));
}
}
/**
* Install a {@link Plug} into our {@link Pipe} prototype.
*
* This allows our shorthand syntax for well-defined plugs such as:
*
* ```
* find('./src', '*.ts').write('./target')
* // Nicer and easier than...
* find('./src', '*.ts').plug(new Write('./target'))
* ```
*
* Use this alongside interface merging like:
*
* ```
* declare module '@plugjs/plug/pipe' {
* export interface Pipe {
* write(): Pipe
* }
* }
*
* install('write', class Write implements Plug {
* constructorg(...args: PipeParams<'write'>) {
* // here `args` is automatically inferred by whatever was declared above
* }
*
* // ... the plug implementation lives here
* })
* ```
*/
export function install(name, ctor) {
/* The function plugging the newly constructed plug in a pipe */
function plug(...args) {
// eslint-disable-next-line new-cap
return this.plug(new ctor(...args));
}
/* Setup name so that stack traces look better */
Object.defineProperty(plug, 'name', { value: name });
/* Inject the create function in the Pipe's prototype */
void Object.defineProperty(PipeImpl.prototype, name, { value: plug });
}
//# sourceMappingURL=pipe.js.map