UNPKG

@plugjs/plug

Version:
343 lines (300 loc) 13.5 kB
/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ import { sep } from 'node:path' import { assert, assertPromises } from './asserts' import { getLogger } from './logging' import { getAbsoluteParent, getCurrentWorkingDirectory, resolveAbsolutePath } from './paths' import type { Files } from './files' import type { Pipe } from './index' import type { Logger } from './logging' import type { AbsolutePath } from './paths' import type { Result } from './types' /* ========================================================================== * * PLUGS * * ========================================================================== */ /** A convenience type indicating what can be returned by a {@link Plug}. */ export type PlugResult = Files | undefined | void /** * The {@link Plug} interface describes _build plugin_. * * A {@link Plug} receives a {@link Files} instance in its input (for example * a list of _source `.ts` files_) and optionally produces a possibly different * list (for example the _compiled `.js` files_). */ export interface Plug<T extends PlugResult> { pipe(files: Files, context: Context): T | Promise<T> } /** A type identifying a {@link Plug} as a `function` */ export type PlugFunction<T extends PlugResult> = Plug<T>['pipe'] /* ========================================================================== * * PLUG CONTEXT * * ========================================================================== */ /** * The {@link Context} class defines the context in which a {@link Plug} * is invoked. */ export class Context { /** The directory of the file where the task was defined (convenience). */ public readonly buildDir: AbsolutePath /** The {@link Logger} associated with this instance. */ public readonly log: Logger constructor( /** The absolute file name where the task was defined. */ public readonly buildFile: AbsolutePath, /** The _name_ of the task associated with this {@link Context}. */ public readonly taskName: string, ) { this.buildDir = getAbsoluteParent(buildFile) this.log = getLogger(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: string, ...paths: string[]): AbsolutePath { // 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<Context, ContextPromises>() /** * 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 { private readonly _cold = new Set<Promise<Result>>() private readonly _hot = new Set<Promise<Result>>() /* Private constructor */ private constructor(readonly context: Context) {} /** Track a {@link Promise} _hot_ (failure will fail the task) */ hot(promise: Promise<Result>): void { this._cold.delete(promise) this._hot.add(promise) } /** Track a {@link Promise} _cold_ (failure will be ignored) */ cold(promise: Promise<Result>): void { 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: Context): Promise<void> { 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: Context): ContextPromises { 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 interface PipeImpl extends Pipe { // used simply for merging types } /** The default implementation of the {@link Pipe} interface. */ export class PipeImpl implements Pipe { readonly [Symbol.toStringTag] = 'Pipe' constructor( private readonly _context: Context, private readonly _promise: Promise<Result>, ) { // 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<R1 = Files, R2 = never>( onfulfilled?: ((value: Files) => R1 | PromiseLike<R1>) | null | undefined, onrejected?: ((reason: any) => R2 | PromiseLike<R2>) | null | undefined, ): Promise<R1 | R2> { // We are delegating the handling of this promise to the caller ContextPromises.get(this._context).cold(this._promise) return this._promise.then(onfulfilled as (value: Result) => R1 | PromiseLike<R1>, onrejected) } catch<R = never>( onrejected?: ((reason: any) => R | PromiseLike<R>) | null | undefined, ): Promise<Files | R> { // We are delegating the handling of this promise to the caller ContextPromises.get(this._context).cold(this._promise) return this._promise.catch(onrejected) as Promise<Files | R> } finally(onfinally?: (() => void) | null | undefined): Promise<Files> { // We are delegating the handling of this promise to the caller ContextPromises.get(this._context).cold(this._promise) return this._promise.finally(onfinally) as Promise<Files> } /* ------------------------------------------------------------------------ * * Pipe implementation * * ------------------------------------------------------------------------ */ plug(plug: Plug<Files>): Pipe plug(plug: PlugFunction<Files>): Pipe plug(plug: Plug<void | undefined>): Promise<undefined> plug(plug: PlugFunction<void | undefined>): Promise<undefined> plug(arg: Plug<PlugResult> | PlugFunction<PlugResult>): Pipe | Promise<undefined> { 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 })) } } /* ========================================================================== * * PLUG INSTALLATION (NEW) * * ========================================================================== */ /** The names which can be installed as direct plugs. */ export type PlugName = string & Exclude<keyof Pipe, 'plug' | keyof Promise<any>> /** The parameters of the plug extension with the given name */ export type PipeParameters<Name extends PlugName> = PipeOverloads<Name>['args'] /** Extract arguments and return types from function overloads. */ type PipeOverloads<Name extends PlugName> = Pipe[Name] extends { (...args: infer A0): infer R0 (...args: infer A1): infer R1 (...args: infer A2): infer R2 (...args: infer A3): infer R3 (...args: infer A4): infer R4 } ? | (R0 extends (Pipe | Promise<undefined>) ? { args: A0, ret: R0 } : never) | (R1 extends (Pipe | Promise<undefined>) ? { args: A1, ret: R1 } : never) | (R2 extends (Pipe | Promise<undefined>) ? { args: A2, ret: R2 } : never) | (R3 extends (Pipe | Promise<undefined>) ? { args: A3, ret: R3 } : never) | (R4 extends (Pipe | Promise<undefined>) ? { args: A4, ret: R4 } : never) : Pipe[Name] extends { (...args: infer A0): infer R0 (...args: infer A1): infer R1 (...args: infer A2): infer R2 (...args: infer A3): infer R3 } ? | (R0 extends (Pipe | Promise<undefined>) ? { args: A0, ret: R0 } : never) | (R1 extends (Pipe | Promise<undefined>) ? { args: A1, ret: R1 } : never) | (R2 extends (Pipe | Promise<undefined>) ? { args: A2, ret: R2 } : never) | (R3 extends (Pipe | Promise<undefined>) ? { args: A3, ret: R3 } : never) : Pipe[Name] extends { (...args: infer A0): infer R0 (...args: infer A1): infer R1 (...args: infer A2): infer R2 } ? | (R0 extends (Pipe | Promise<undefined>) ? { args: A0, ret: R0 } : never) | (R1 extends (Pipe | Promise<undefined>) ? { args: A1, ret: R1 } : never) | (R2 extends (Pipe | Promise<undefined>) ? { args: A2, ret: R2 } : never) : Pipe[Name] extends { (...args: infer A0): infer R0 (...args: infer A1): infer R1 } ? | (R0 extends (Pipe | Promise<undefined>) ? { args: A0, ret: R0 } : never) | (R1 extends (Pipe | Promise<undefined>) ? { args: A1, ret: R1 } : never) : Pipe[Name] extends { (...args: infer A0): infer R0 } ? | (R0 extends (Pipe | Promise<undefined>) ? { args: A0, ret: R0 } : never) : never /** The parameters of the plug extension with the given name */ type PipeResult<Name extends PlugName> = PipeOverloads<Name>['ret'] /** * A type defining the correct constructor for a {@link Plug}, inferring * argument types and instance type from the definitions in {@link Pipe}. */ type PlugConstructor<Name extends PlugName> = PipeResult<Name> extends Pipe ? new (...args: PipeParameters<Name>) => Plug<Files> : PipeResult<Name> extends Promise<undefined> ? new (...args: PipeParameters<Name>) => Plug<void | undefined> : PipeResult<Name> extends (Pipe | Promise<undefined>) ? new (...args: PipeParameters<Name>) => Plug<Files | void | undefined> : never /** * 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 extends PlugName, Ctor extends PlugConstructor<Name>, >(name: Name, ctor: Ctor): void { /* The function plugging the newly constructed plug in a pipe */ function plug(this: PipeImpl, ...args: PipeParameters<Name>): Pipe | Promise<undefined> { // eslint-disable-next-line new-cap return this.plug(new ctor(...args) as any) } /* 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 }) }