diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 6c17dcaec..d205bcaf8 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -11,130 +11,28 @@ * @packageDocumentation * @module application */ -import { topologicSort } from '@lumino/algorithm'; import { CommandRegistry } from '@lumino/commands'; -import { PromiseDelegate, Token } from '@lumino/coreutils'; +import { + type IPlugin, + PluginRegistry, + PromiseDelegate, + type Token +} from '@lumino/coreutils'; import { ContextMenu, Menu, Widget } from '@lumino/widgets'; +// Export IPlugin for API backward compatibility /** - * A user-defined application plugin. - * - * @typeparam T - The type for the application. - * - * @typeparam U - The service type, if the plugin `provides` one. - * - * #### Notes - * Plugins are the foundation for building an extensible application. - * - * Plugins consume and provide "services", which are nothing more than - * concrete implementations of interfaces and/or abstract types. - * - * Unlike regular imports and exports, which tie the service consumer - * to a particular implementation of the service, plugins decouple the - * service producer from the service consumer, allowing an application - * to be easily customized by third parties in a type-safe fashion. + * @deprecated You should import it from @lumino/coreutils. */ -export interface IPlugin { - /** - * The human readable ID of the plugin. - * - * #### Notes - * This must be unique within an application. - */ - id: string; - - /** - * Plugin description. - * - * #### Notes - * This can be used to provide user documentation on the feature - * brought by a plugin. - */ - description?: string; - - /** - * Whether the plugin should be activated on application start or waiting for being - * required. If the value is 'defer' then the plugin should be activated only after - * the application is started. - * - * #### Notes - * The default is `false`. - */ - autoStart?: boolean | 'defer'; - - /** - * The types of required services for the plugin, if any. - * - * #### Notes - * These tokens correspond to the services that are required by - * the plugin for correct operation. - * - * When the plugin is activated, a concrete instance of each type - * will be passed to the `activate()` function, in the order they - * are specified in the `requires` array. - */ - requires?: Token[]; - - /** - * The types of optional services for the plugin, if any. - * - * #### Notes - * These tokens correspond to the services that can be used by the - * plugin if available, but are not necessarily required. - * - * The optional services will be passed to the `activate()` function - * following all required services. If an optional service cannot be - * resolved, `null` will be passed in its place. - */ - optional?: Token[]; - - /** - * The type of service provided by the plugin, if any. - * - * #### Notes - * This token corresponds to the service exported by the plugin. - * - * When the plugin is activated, the return value of `activate()` - * is used as the concrete instance of the type. - */ - provides?: Token | null; - - /** - * A function invoked to activate the plugin. - * - * @param app - The application which owns the plugin. - * - * @param args - The services specified by the `requires` property. - * - * @returns The provided service, or a promise to the service. - * - * #### Notes - * This function will be called whenever the plugin is manually - * activated, or when another plugin being activated requires - * the service it provides. - * - * This function will not be called unless all of its required - * services can be fulfilled. - */ - activate: (app: T, ...args: any[]) => U | Promise; - - /** - * A function invoked to deactivate the plugin. - * - * @param app - The application which owns the plugin. - * - * @param args - The services specified by the `requires` property. - */ - deactivate?: ((app: T, ...args: any[]) => void | Promise) | null; -} +export { type IPlugin }; /** * A class for creating pluggable applications. * - * @typeparam T - The type of the application shell. + * @typeParam T - The type of the application shell. * * #### Notes * The `Application` class is useful when creating large, complex @@ -148,6 +46,10 @@ export class Application { * @param options - The options for creating the application. */ constructor(options: Application.IOptions) { + this.pluginRegistry = + options.pluginRegistry ?? new PluginRegistry(options); + this.pluginRegistry.application = this; + // Initialize the application state. this.commands = new CommandRegistry(); this.contextMenu = new ContextMenu({ @@ -177,6 +79,13 @@ export class Application { */ readonly shell: T; + /** + * The list of all the deferred plugins. + */ + get deferredPlugins(): string[] { + return this.pluginRegistry.deferredPlugins; + } + /** * A promise which resolves after the application has started. * @@ -188,6 +97,51 @@ export class Application { return this._delegate.promise; } + /** + * Activate all the deferred plugins. + * + * @returns A promise which will resolve when each plugin is activated + * or rejects with an error if one cannot be activated. + */ + async activateDeferredPlugins(): Promise { + await this.pluginRegistry.activatePlugins('defer'); + } + + /** + * Activate the plugin with the given ID. + * + * @param id - The ID of the plugin of interest. + * + * @returns A promise which resolves when the plugin is activated + * or rejects with an error if it cannot be activated. + */ + async activatePlugin(id: string): Promise { + return this.pluginRegistry.activatePlugin(id); + } + + /** + * Deactivate the plugin and its downstream dependents if and only if the + * plugin and its dependents all support `deactivate`. + * + * @param id - The ID of the plugin of interest. + * + * @returns A list of IDs of downstream plugins deactivated with this one. + */ + async deactivatePlugin(id: string): Promise { + return this.pluginRegistry.deactivatePlugin(id); + } + + /** + * Deregister a plugin with the application. + * + * @param id - The ID of the plugin of interest. + * + * @param force - Whether to deregister the plugin even if it is active. + */ + deregisterPlugin(id: string, force?: boolean): void { + this.pluginRegistry.deregisterPlugin(id, force); + } + /** * Get a plugin description. * @@ -196,7 +150,7 @@ export class Application { * @returns The plugin description. */ getPluginDescription(id: string): string { - return this._plugins.get(id)?.description ?? ''; + return this.pluginRegistry.getPluginDescription(id); } /** @@ -207,7 +161,7 @@ export class Application { * @returns `true` if the plugin is registered, `false` otherwise. */ hasPlugin(id: string): boolean { - return this._plugins.has(id); + return this.pluginRegistry.hasPlugin(id); } /** @@ -218,7 +172,7 @@ export class Application { * @returns `true` if the plugin is activated, `false` otherwise. */ isPluginActivated(id: string): boolean { - return this._plugins.get(id)?.activated ?? false; + return this.pluginRegistry.isPluginActivated(id); } /** @@ -227,7 +181,7 @@ export class Application { * @returns A new array of the registered plugin IDs. */ listPlugins(): string[] { - return Array.from(this._plugins.keys()); + return this.pluginRegistry.listPlugins(); } /** @@ -243,24 +197,7 @@ export class Application { * by another plugin, the new service will override the old service. */ registerPlugin(plugin: IPlugin): void { - // Throw an error if the plugin ID is already registered. - if (this._plugins.has(plugin.id)) { - throw new TypeError(`Plugin '${plugin.id}' is already registered.`); - } - - // Create the normalized plugin data. - const data = Private.createPluginData(plugin); - - // Ensure the plugin does not cause a cyclic dependency. - Private.ensureNoCycle(data, this._plugins, this._services); - - // Add the service token to the service map. - if (data.provides) { - this._services.set(data.provides, data.id); - } - - // Add the plugin to the plugin map. - this._plugins.set(data.id, data); + this.pluginRegistry.registerPlugin(plugin); } /** @@ -272,142 +209,16 @@ export class Application { * This calls `registerPlugin()` for each of the given plugins. */ registerPlugins(plugins: IPlugin[]): void { - for (const plugin of plugins) { - this.registerPlugin(plugin); - } + this.pluginRegistry.registerPlugins(plugins); } /** - * Deregister a plugin with the application. - * - * @param id - The ID of the plugin of interest. - * - * @param force - Whether to deregister the plugin even if it is active. - */ - deregisterPlugin(id: string, force?: boolean): void { - const plugin = this._plugins.get(id); - if (!plugin) { - return; - } - - if (plugin.activated && !force) { - throw new Error(`Plugin '${id}' is still active.`); - } - - this._plugins.delete(id); - } - - /** - * Activate the plugin with the given ID. - * - * @param id - The ID of the plugin of interest. - * - * @returns A promise which resolves when the plugin is activated - * or rejects with an error if it cannot be activated. - */ - async activatePlugin(id: string): Promise { - // Reject the promise if the plugin is not registered. - const plugin = this._plugins.get(id); - if (!plugin) { - throw new ReferenceError(`Plugin '${id}' is not registered.`); - } - - // Resolve immediately if the plugin is already activated. - if (plugin.activated) { - return; - } - - // Return the pending resolver promise if it exists. - if (plugin.promise) { - return plugin.promise; - } - - // Resolve the required services for the plugin. - const required = plugin.requires.map(t => this.resolveRequiredService(t)); - - // Resolve the optional services for the plugin. - const optional = plugin.optional.map(t => this.resolveOptionalService(t)); - - // Setup the resolver promise for the plugin. - plugin.promise = Promise.all([...required, ...optional]) - .then(services => plugin!.activate.apply(undefined, [this, ...services])) - .then(service => { - plugin!.service = service; - plugin!.activated = true; - plugin!.promise = null; - }) - .catch(error => { - plugin!.promise = null; - throw error; - }); - - // Return the pending resolver promise. - return plugin.promise; - } - - /** - * Deactivate the plugin and its downstream dependents if and only if the - * plugin and its dependents all support `deactivate`. - * - * @param id - The ID of the plugin of interest. - * - * @returns A list of IDs of downstream plugins deactivated with this one. - */ - async deactivatePlugin(id: string): Promise { - // Reject the promise if the plugin is not registered. - const plugin = this._plugins.get(id); - if (!plugin) { - throw new ReferenceError(`Plugin '${id}' is not registered.`); - } - - // Bail early if the plugin is not activated. - if (!plugin.activated) { - return []; - } - - // Check that this plugin can deactivate. - if (!plugin.deactivate) { - throw new TypeError(`Plugin '${id}'#deactivate() method missing`); - } - - // Find the optimal deactivation order for plugins downstream of this one. - const manifest = Private.findDependents(id, this._plugins, this._services); - const downstream = manifest.map(id => this._plugins.get(id)!); - - // Check that all downstream plugins can deactivate. - for (const plugin of downstream) { - if (!plugin.deactivate) { - throw new TypeError( - `Plugin ${plugin.id}#deactivate() method missing (depends on ${id})` - ); - } - } - - // Deactivate all downstream plugins. - for (const plugin of downstream) { - const services = [...plugin.requires, ...plugin.optional].map(service => { - const id = this._services.get(service); - return id ? this._plugins.get(id)!.service : null; - }); - - // Await deactivation so the next plugins only receive active services. - await plugin.deactivate!(this, ...services); - plugin.service = null; - plugin.activated = false; - } - - // Remove plugin ID and return manifest of deactivated plugins. - manifest.pop(); - return manifest; - } - - /** - * Resolve a required service of a given type. + * Resolve an optional service of a given type. * * @param token - The token for the service type of interest. * * @returns A promise which resolves to an instance of the requested - * service, or rejects with an error if it cannot be resolved. + * service, or `null` if it cannot be resolved. * * #### Notes * Services are singletons. The same instance will be returned each @@ -417,32 +228,20 @@ export class Application { * resolving the service will automatically activate the plugin. * * User code will not typically call this method directly. Instead, - * the required services for the user's plugins will be resolved + * the optional services for the user's plugins will be resolved * automatically when the plugin is activated. */ - async resolveRequiredService(token: Token): Promise { - // Reject the promise if there is no provider for the type. - const id = this._services.get(token); - if (!id) { - throw new TypeError(`No provider for: ${token.name}.`); - } - - // Activate the plugin if necessary. - const plugin = this._plugins.get(id)!; - if (!plugin.activated) { - await this.activatePlugin(id); - } - - return plugin.service; + async resolveOptionalService(token: Token): Promise { + return this.pluginRegistry.resolveOptionalService(token); } /** - * Resolve an optional service of a given type. + * Resolve a required service of a given type. * * @param token - The token for the service type of interest. * * @returns A promise which resolves to an instance of the requested - * service, or `null` if it cannot be resolved. + * service, or rejects with an error if it cannot be resolved. * * #### Notes * Services are singletons. The same instance will be returned each @@ -452,28 +251,11 @@ export class Application { * resolving the service will automatically activate the plugin. * * User code will not typically call this method directly. Instead, - * the optional services for the user's plugins will be resolved + * the required services for the user's plugins will be resolved * automatically when the plugin is activated. */ - async resolveOptionalService(token: Token): Promise { - // Resolve with `null` if there is no provider for the type. - const id = this._services.get(token); - if (!id) { - return null; - } - - // Activate the plugin if necessary. - const plugin = this._plugins.get(id)!; - if (!plugin.activated) { - try { - await this.activatePlugin(id); - } catch (reason) { - console.error(reason); - return null; - } - } - - return plugin.service; + async resolveRequiredService(token: Token): Promise { + return this.pluginRegistry.resolveRequiredService(token); } /** @@ -497,7 +279,7 @@ export class Application { * 3. Attach the shell widget to the DOM * 4. Add the application event listeners */ - start(options: Application.IStartOptions = {}): Promise { + async start(options: Application.IStartOptions = {}): Promise { // Return immediately if the application is already started. if (this._started) { return this._delegate.promise; @@ -506,55 +288,17 @@ export class Application { // Mark the application as started; this._started = true; - this._bubblingKeydown = options.bubblingKeydown || false; + this._bubblingKeydown = options.bubblingKeydown ?? false; // Parse the host ID for attaching the shell. - const hostID = options.hostID || ''; - - // Collect the ids of the startup plugins. - const startups = Private.collectStartupPlugins(this._plugins, options); - - // Generate the activation promises. - const promises = startups.map(id => { - return this.activatePlugin(id).catch(error => { - console.error(`Plugin '${id}' failed to activate.`); - console.error(error); - }); - }); + const hostID = options.hostID ?? ''; // Wait for the plugins to activate, then finalize startup. - Promise.all(promises).then(() => { - this.attachShell(hostID); - this.addEventListeners(); - this._delegate.resolve(); - }); - - // Return the pending delegate promise. - return this._delegate.promise; - } + await this.pluginRegistry.activatePlugins('startUp', options); - /** - * The list of all the deferred plugins. - */ - get deferredPlugins(): string[] { - return Array.from(this._plugins) - .filter(([id, plugin]) => plugin.autoStart === 'defer') - .map(([id, plugin]) => id); - } - - /** - * Activate all the deferred plugins. - * - * @returns A promise which will resolve when each plugin is activated - * or rejects with an error if one cannot be activated. - */ - async activateDeferredPlugins(): Promise { - const promises = this.deferredPlugins - .filter(pluginId => this._plugins.get(pluginId)!.autoStart) - .map(pluginId => { - return this.activatePlugin(pluginId); - }); - await Promise.all(promises); + this.attachShell(hostID); + this.addEventListeners(); + this._delegate.resolve(); } /** @@ -678,9 +422,11 @@ export class Application { this.shell.update(); } + /** + * Application plugin registry. + */ + protected pluginRegistry: PluginRegistry; private _delegate = new PromiseDelegate(); - private _plugins = new Map(); - private _services = new Map, string>(); private _started = false; private _bubblingKeydown = false; } @@ -692,7 +438,7 @@ export namespace Application { /** * An options object for creating an application. */ - export interface IOptions { + export interface IOptions extends PluginRegistry.IOptions { /** * The shell widget to use for the application. * @@ -706,6 +452,14 @@ export namespace Application { * A custom renderer for the context menu. */ contextMenuRenderer?: Menu.IRenderer; + + /** + * Application plugin registry. + * + * If defined the options related to the plugin registry will + * be ignored. + */ + pluginRegistry?: PluginRegistry; } /** @@ -745,244 +499,3 @@ export namespace Application { bubblingKeydown?: boolean; } } - -/** - * The namespace for the module implementation details. - */ -namespace Private { - /** - * An object which holds the full application state for a plugin. - */ - export interface IPluginData { - /** - * The human readable ID of the plugin. - */ - readonly id: string; - - /** - * The description of the plugin. - */ - readonly description: string; - - /** - * Whether the plugin should be activated on application start or waiting for being - * required. If the value is 'defer' then the plugin should be activated only after - * the application is started. - */ - readonly autoStart: boolean | 'defer'; - - /** - * The types of required services for the plugin, or `[]`. - */ - readonly requires: Token[]; - - /** - * The types of optional services for the the plugin, or `[]`. - */ - readonly optional: Token[]; - - /** - * The type of service provided by the plugin, or `null`. - */ - readonly provides: Token | null; - - /** - * The function which activates the plugin. - */ - readonly activate: (app: Application, ...args: any[]) => any; - - /** - * The optional function which deactivates the plugin. - */ - readonly deactivate: - | ((app: Application, ...args: any[]) => void | Promise) - | null; - - /** - * Whether the plugin has been activated. - */ - activated: boolean; - - /** - * The resolved service for the plugin, or `null`. - */ - service: any | null; - - /** - * The pending resolver promise, or `null`. - */ - promise: Promise | null; - } - - /** - * Create a normalized plugin data object for the given plugin. - */ - export function createPluginData(plugin: IPlugin): IPluginData { - return { - id: plugin.id, - description: plugin.description ?? '', - service: null, - promise: null, - activated: false, - activate: plugin.activate, - deactivate: plugin.deactivate ?? null, - provides: plugin.provides ?? null, - autoStart: plugin.autoStart ?? false, - requires: plugin.requires ? plugin.requires.slice() : [], - optional: plugin.optional ? plugin.optional.slice() : [] - }; - } - - /** - * Ensure no cycle is present in the plugin resolution graph. - * - * If a cycle is detected, an error will be thrown. - */ - export function ensureNoCycle( - plugin: IPluginData, - plugins: Map, - services: Map, string> - ): void { - const dependencies = [...plugin.requires, ...plugin.optional]; - const visit = (token: Token): boolean => { - if (token === plugin.provides) { - return true; - } - const id = services.get(token); - if (!id) { - return false; - } - const visited = plugins.get(id)!; - const dependencies = [...visited.requires, ...visited.optional]; - if (dependencies.length === 0) { - return false; - } - trace.push(id); - if (dependencies.some(visit)) { - return true; - } - trace.pop(); - return false; - }; - - // Bail early if there cannot be a cycle. - if (!plugin.provides || dependencies.length === 0) { - return; - } - - // Setup a stack to trace service resolution. - const trace = [plugin.id]; - - // Throw an exception if a cycle is present. - if (dependencies.some(visit)) { - throw new ReferenceError(`Cycle detected: ${trace.join(' -> ')}.`); - } - } - - /** - * Find dependents in deactivation order. - * - * @param id - The ID of the plugin of interest. - * - * @param plugins - The map containing all plugins. - * - * @param services - The map containing all services. - * - * @returns A list of dependent plugin IDs in order of deactivation - * - * #### Notes - * The final item of the returned list is always the plugin of interest. - */ - export function findDependents( - id: string, - plugins: Map, - services: Map, string> - ): string[] { - const edges = new Array<[string, string]>(); - const add = (id: string): void => { - const plugin = plugins.get(id)!; - // FIXME In the case of missing optional dependencies, we may consider - // deactivating and reactivating the plugin without the missing service. - const dependencies = [...plugin.requires, ...plugin.optional]; - edges.push( - ...dependencies.reduce<[string, string][]>((acc, dep) => { - const service = services.get(dep); - if (service) { - // An edge is oriented from dependent to provider. - acc.push([id, service]); - } - return acc; - }, []) - ); - }; - - for (const id of plugins.keys()) { - add(id); - } - - // Filter edges - // - Get all packages that dependent on the package to be deactivated - const newEdges = edges.filter(edge => edge[1] === id); - let oldSize = 0; - while (newEdges.length > oldSize) { - const previousSize = newEdges.length; - // Get all packages that dependent on packages that will be deactivated - const packagesOfInterest = new Set(newEdges.map(edge => edge[0])); - for (const poi of packagesOfInterest) { - edges - .filter(edge => edge[1] === poi) - .forEach(edge => { - // We check it is not already included to deal with circular dependencies - if (!newEdges.includes(edge)) { - newEdges.push(edge); - } - }); - } - oldSize = previousSize; - } - - const sorted = topologicSort(newEdges); - const index = sorted.findIndex(candidate => candidate === id); - - if (index === -1) { - return [id]; - } - - return sorted.slice(0, index + 1); - } - - /** - * Collect the IDs of the plugins to activate on startup. - */ - export function collectStartupPlugins( - plugins: Map, - options: Application.IStartOptions - ): string[] { - // Create a set to hold the plugin IDs. - const collection = new Set(); - - // Collect the auto-start (non deferred) plugins. - for (const id of plugins.keys()) { - if (plugins.get(id)!.autoStart === true) { - collection.add(id); - } - } - - // Add the startup plugins. - if (options.startPlugins) { - for (const id of options.startPlugins) { - collection.add(id); - } - } - - // Remove the ignored plugins. - if (options.ignorePlugins) { - for (const id of options.ignorePlugins) { - collection.delete(id); - } - } - - // Return the collected startup plugins. - return Array.from(collection); - } -} diff --git a/packages/application/tests/src/index.spec.ts b/packages/application/tests/src/index.spec.ts index c73ef01c6..f2e535eea 100644 --- a/packages/application/tests/src/index.spec.ts +++ b/packages/application/tests/src/index.spec.ts @@ -12,7 +12,7 @@ import { expect } from 'chai'; import { Application } from '@lumino/application'; import { ContextMenu, Widget } from '@lumino/widgets'; import { CommandRegistry } from '@lumino/commands'; -import { Token } from '@lumino/coreutils'; +import { PluginRegistry, Token } from '@lumino/coreutils'; describe('@lumino/application', () => { describe('Application', () => { @@ -28,6 +28,35 @@ describe('@lumino/application', () => { expect(app.contextMenu).to.be.instanceOf(ContextMenu); expect(app.shell).to.equal(shell); }); + + it('should accept an external plugin registry', async () => { + const shell = new Widget(); + const pluginRegistry = new PluginRegistry(); + const id1 = 'plugin1'; + pluginRegistry.registerPlugin({ + id: id1, + activate: () => { + // no-op + } + }); + const id2 = 'plugin2'; + pluginRegistry.registerPlugin({ + id: id2, + activate: () => { + // no-op + } + }); + + const app = new Application({ + shell, + pluginRegistry + }); + + await pluginRegistry.activatePlugin(id2); + + expect(app.hasPlugin(id1)).to.be.true; + expect(app.isPluginActivated(id2)).to.be.true; + }); }); describe('#getPluginDescription', () => { diff --git a/packages/coreutils/package.json b/packages/coreutils/package.json index 55d9ded3b..66a98b86d 100644 --- a/packages/coreutils/package.json +++ b/packages/coreutils/package.json @@ -41,6 +41,9 @@ "test:webkit-headless": "cd tests && karma start --browsers=WebkitHeadless", "watch": "tsc --build --watch" }, + "dependencies": { + "@lumino/algorithm": "^2.0.1" + }, "devDependencies": { "@lumino/buildutils": "^2.0.1", "@microsoft/api-extractor": "^7.36.0", diff --git a/packages/coreutils/src/index.common.ts b/packages/coreutils/src/index.common.ts index d2dcb599c..872e0fbd4 100644 --- a/packages/coreutils/src/index.common.ts +++ b/packages/coreutils/src/index.common.ts @@ -9,5 +9,6 @@ |----------------------------------------------------------------------------*/ export * from './json'; export * from './mime'; +export * from './plugins'; export * from './promise'; export * from './token'; diff --git a/packages/coreutils/src/plugins.ts b/packages/coreutils/src/plugins.ts new file mode 100644 index 000000000..518468f92 --- /dev/null +++ b/packages/coreutils/src/plugins.ts @@ -0,0 +1,867 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. +import { topologicSort } from '@lumino/algorithm'; + +import { Token } from './token'; + +/** + * A user-defined application plugin. + * + * @typeParam T - The type for the application. + * + * @typeParam U - The service type, if the plugin `provides` one. + * + * #### Notes + * Plugins are the foundation for building an extensible application. + * + * Plugins consume and provide "services", which are nothing more than + * concrete implementations of interfaces and/or abstract types. + * + * Unlike regular imports and exports, which tie the service consumer + * to a particular implementation of the service, plugins decouple the + * service producer from the service consumer, allowing an application + * to be easily customized by third parties in a type-safe fashion. + */ +export interface IPlugin { + /** + * The human readable ID of the plugin. + * + * #### Notes + * This must be unique within an application. + */ + id: string; + + /** + * Plugin description. + * + * #### Notes + * This can be used to provide user documentation on the feature + * brought by a plugin. + */ + description?: string; + + /** + * Whether the plugin should be activated on application start or waiting for being + * required. If the value is 'defer' then the plugin should be activated only after + * the application is started. + * + * #### Notes + * The default is `false`. + */ + autoStart?: boolean | 'defer'; + + /** + * The types of required services for the plugin, if any. + * + * #### Notes + * These tokens correspond to the services that are required by + * the plugin for correct operation. + * + * When the plugin is activated, a concrete instance of each type + * will be passed to the `activate()` function, in the order they + * are specified in the `requires` array. + */ + requires?: Token[]; + + /** + * The types of optional services for the plugin, if any. + * + * #### Notes + * These tokens correspond to the services that can be used by the + * plugin if available, but are not necessarily required. + * + * The optional services will be passed to the `activate()` function + * following all required services. If an optional service cannot be + * resolved, `null` will be passed in its place. + */ + optional?: Token[]; + + /** + * The type of service provided by the plugin, if any. + * + * #### Notes + * This token corresponds to the service exported by the plugin. + * + * When the plugin is activated, the return value of `activate()` + * is used as the concrete instance of the type. + */ + provides?: Token | null; + + /** + * A function invoked to activate the plugin. + * + * @param app - The application provided by {@link PluginRegistry.application} . + * + * @param args - The services specified by the `requires` property. + * + * @returns The provided service, or a promise to the service. + * + * #### Notes + * This function will be called whenever the plugin is manually + * activated, or when another plugin being activated requires + * the service it provides. + * + * This function will not be called unless all of its required + * services can be fulfilled. + */ + activate: (app: T, ...args: any[]) => U | Promise; + + /** + * A function invoked to deactivate the plugin. + * + * @param app - The application {@link PluginRegistry.application} . + * + * @param args - The services specified by the `requires` property. + */ + deactivate?: ((app: T, ...args: any[]) => void | Promise) | null; +} + +/** + * Plugin registry. + */ +export class PluginRegistry { + constructor(options: PluginRegistry.IOptions = {}) { + if (options.validatePlugin) { + console.info( + 'Plugins may be rejected by the custom validation plugin method.' + ); + this._validatePlugin = options.validatePlugin; + } + } + + /** + * The application object. + * + * It will be provided as first argument to the + * plugins activation and deactivation functions. + * + * It can only be set once. + * + * By default, it is `null`. + */ + get application(): T { + return this._application; + } + set application(v: T) { + if (this._application !== null) { + throw Error( + 'PluginRegistry.application is already set. It cannot be overridden.' + ); + } + + this._application = v; + } + + /** + * The list of all the deferred plugins. + */ + get deferredPlugins(): string[] { + return Array.from(this._plugins) + .filter(([id, plugin]) => plugin.autoStart === 'defer') + .map(([id, plugin]) => id); + } + + /** + * Get a plugin description. + * + * @param id - The ID of the plugin of interest. + * + * @returns The plugin description. + */ + getPluginDescription(id: string): string { + return this._plugins.get(id)?.description ?? ''; + } + + /** + * Test whether a plugin is registered with the application. + * + * @param id - The ID of the plugin of interest. + * + * @returns `true` if the plugin is registered, `false` otherwise. + */ + hasPlugin(id: string): boolean { + return this._plugins.has(id); + } + + /** + * Test whether a plugin is activated with the application. + * + * @param id - The ID of the plugin of interest. + * + * @returns `true` if the plugin is activated, `false` otherwise. + */ + isPluginActivated(id: string): boolean { + return this._plugins.get(id)?.activated ?? false; + } + + /** + * List the IDs of the plugins registered with the application. + * + * @returns A new array of the registered plugin IDs. + */ + listPlugins(): string[] { + return Array.from(this._plugins.keys()); + } + + /** + * Register a plugin with the application. + * + * @param plugin - The plugin to register. + * + * #### Notes + * An error will be thrown if a plugin with the same ID is already + * registered, or if the plugin has a circular dependency. + * + * If the plugin provides a service which has already been provided + * by another plugin, the new service will override the old service. + */ + registerPlugin(plugin: IPlugin): void { + // Throw an error if the plugin ID is already registered. + if (this._plugins.has(plugin.id)) { + throw new TypeError(`Plugin '${plugin.id}' is already registered.`); + } + + if (!this._validatePlugin(plugin)) { + throw new Error(`Plugin '${plugin.id}' is not valid.`); + } + + // Create the normalized plugin data. + const data = Private.createPluginData(plugin); + + // Ensure the plugin does not cause a cyclic dependency. + Private.ensureNoCycle(data, this._plugins, this._services); + + // Add the service token to the service map. + if (data.provides) { + this._services.set(data.provides, data.id); + } + + // Add the plugin to the plugin map. + this._plugins.set(data.id, data); + } + + /** + * Register multiple plugins with the application. + * + * @param plugins - The plugins to register. + * + * #### Notes + * This calls `registerPlugin()` for each of the given plugins. + */ + registerPlugins(plugins: IPlugin[]): void { + for (const plugin of plugins) { + this.registerPlugin(plugin); + } + } + + /** + * Deregister a plugin with the application. + * + * @param id - The ID of the plugin of interest. + * + * @param force - Whether to deregister the plugin even if it is active. + */ + deregisterPlugin(id: string, force?: boolean): void { + const plugin = this._plugins.get(id); + if (!plugin) { + return; + } + + if (plugin.activated && !force) { + throw new Error(`Plugin '${id}' is still active.`); + } + + this._plugins.delete(id); + } + + /** + * Activate the plugin with the given ID. + * + * @param id - The ID of the plugin of interest. + * + * @returns A promise which resolves when the plugin is activated + * or rejects with an error if it cannot be activated. + */ + async activatePlugin(id: string): Promise { + // Reject the promise if the plugin is not registered. + const plugin = this._plugins.get(id); + if (!plugin) { + throw new ReferenceError(`Plugin '${id}' is not registered.`); + } + + // Resolve immediately if the plugin is already activated. + if (plugin.activated) { + return; + } + + // Return the pending resolver promise if it exists. + if (plugin.promise) { + return plugin.promise; + } + + // Resolve the required services for the plugin. + const required = plugin.requires.map(t => this.resolveRequiredService(t)); + + // Resolve the optional services for the plugin. + const optional = plugin.optional.map(t => this.resolveOptionalService(t)); + + // Setup the resolver promise for the plugin. + plugin.promise = Promise.all([...required, ...optional]) + .then(services => + plugin!.activate.apply(undefined, [this.application, ...services]) + ) + .then(service => { + plugin!.service = service; + plugin!.activated = true; + plugin!.promise = null; + }) + .catch(error => { + plugin!.promise = null; + throw error; + }); + + // Return the pending resolver promise. + return plugin.promise; + } + + /** + * Activate all the deferred plugins. + * + * @returns A promise which will resolve when each plugin is activated + * or rejects with an error if one cannot be activated. + */ + async activatePlugins( + kind: 'startUp' | 'defer', + options: PluginRegistry.IStartOptions = {} + ): Promise { + switch (kind) { + case 'defer': { + const promises = this.deferredPlugins + .filter(pluginId => this._plugins.get(pluginId)!.autoStart) + .map(pluginId => { + return this.activatePlugin(pluginId); + }); + await Promise.all(promises); + break; + } + case 'startUp': { + // Collect the ids of the startup plugins. + const startups = Private.collectStartupPlugins(this._plugins, options); + + // Generate the activation promises. + const promises = startups.map(async id => { + try { + return await this.activatePlugin(id); + } catch (error) { + console.error(`Plugin '${id}' failed to activate.`, error); + } + }); + await Promise.all(promises); + break; + } + } + } + + /** + * Deactivate the plugin and its downstream dependents if and only if the + * plugin and its dependents all support `deactivate`. + * + * @param id - The ID of the plugin of interest. + * + * @returns A list of IDs of downstream plugins deactivated with this one. + */ + async deactivatePlugin(id: string): Promise { + // Reject the promise if the plugin is not registered. + const plugin = this._plugins.get(id); + if (!plugin) { + throw new ReferenceError(`Plugin '${id}' is not registered.`); + } + + // Bail early if the plugin is not activated. + if (!plugin.activated) { + return []; + } + + // Check that this plugin can deactivate. + if (!plugin.deactivate) { + throw new TypeError(`Plugin '${id}'#deactivate() method missing`); + } + + // Find the optimal deactivation order for plugins downstream of this one. + const manifest = Private.findDependents(id, this._plugins, this._services); + const downstream = manifest.map(id => this._plugins.get(id)!); + + // Check that all downstream plugins can deactivate. + for (const plugin of downstream) { + if (!plugin.deactivate) { + throw new TypeError( + `Plugin ${plugin.id}#deactivate() method missing (depends on ${id})` + ); + } + } + + // Deactivate all downstream plugins. + for (const plugin of downstream) { + const services = [...plugin.requires, ...plugin.optional].map(service => { + const id = this._services.get(service); + return id ? this._plugins.get(id)!.service : null; + }); + + // Await deactivation so the next plugins only receive active services. + await plugin.deactivate!(this.application, ...services); + plugin.service = null; + plugin.activated = false; + } + + // Remove plugin ID and return manifest of deactivated plugins. + manifest.pop(); + return manifest; + } + + /** + * Resolve a required service of a given type. + * + * @param token - The token for the service type of interest. + * + * @returns A promise which resolves to an instance of the requested + * service, or rejects with an error if it cannot be resolved. + * + * #### Notes + * Services are singletons. The same instance will be returned each + * time a given service token is resolved. + * + * If the plugin which provides the service has not been activated, + * resolving the service will automatically activate the plugin. + * + * User code will not typically call this method directly. Instead, + * the required services for the user's plugins will be resolved + * automatically when the plugin is activated. + */ + async resolveRequiredService(token: Token): Promise { + // Reject the promise if there is no provider for the type. + const id = this._services.get(token); + if (!id) { + throw new TypeError(`No provider for: ${token.name}.`); + } + + // Activate the plugin if necessary. + const plugin = this._plugins.get(id)!; + if (!plugin.activated) { + await this.activatePlugin(id); + } + + return plugin.service; + } + + /** + * Resolve an optional service of a given type. + * + * @param token - The token for the service type of interest. + * + * @returns A promise which resolves to an instance of the requested + * service, or `null` if it cannot be resolved. + * + * #### Notes + * Services are singletons. The same instance will be returned each + * time a given service token is resolved. + * + * If the plugin which provides the service has not been activated, + * resolving the service will automatically activate the plugin. + * + * User code will not typically call this method directly. Instead, + * the optional services for the user's plugins will be resolved + * automatically when the plugin is activated. + */ + async resolveOptionalService(token: Token): Promise { + // Resolve with `null` if there is no provider for the type. + const id = this._services.get(token); + if (!id) { + return null; + } + + // Activate the plugin if necessary. + const plugin = this._plugins.get(id)!; + if (!plugin.activated) { + try { + await this.activatePlugin(id); + } catch (reason) { + console.error(reason); + return null; + } + } + + return plugin.service; + } + + private _application: any = null; + private _validatePlugin: (plugin: IPlugin) => boolean = () => true; + private _plugins = new Map>(); + private _services = new Map, string>(); +} + +/** + * PluginRegistry namespace + */ +export namespace PluginRegistry { + /** + * PluginRegistry constructor options. + */ + export interface IOptions { + /** + * Validate that a plugin is allowed to be registered. + * + * Default is `() => true`. + * + * @param plugin The plugin to validate + * @returns Whether the plugin can be registered or not. + * + * #### Notes + * We recommend you print a console message with the reason + * a plugin is invalid. + */ + validatePlugin?: (plugin: IPlugin) => boolean; + } + + /** + * An options object for application startup. + */ + export interface IStartOptions { + /** + * The plugins to activate on startup. + * + * #### Notes + * These will be *in addition* to any `autoStart` plugins. + */ + startPlugins?: string[]; + + /** + * The plugins to **not** activate on startup. + * + * #### Notes + * This will override `startPlugins` and any `autoStart` plugins. + */ + ignorePlugins?: string[]; + } +} + +/** + * The namespace for the module implementation details. + */ +namespace Private { + /** + * An object which holds the full application state for a plugin. + */ + export interface IPluginData { + /** + * The human readable ID of the plugin. + */ + readonly id: string; + + /** + * The description of the plugin. + */ + readonly description: string; + + /** + * Whether the plugin should be activated on application start or waiting for being + * required. If the value is 'defer' then the plugin should be activated only after + * the application is started. + */ + readonly autoStart: boolean | 'defer'; + + /** + * The types of required services for the plugin, or `[]`. + */ + readonly requires: Token[]; + + /** + * The types of optional services for the the plugin, or `[]`. + */ + readonly optional: Token[]; + + /** + * The type of service provided by the plugin, or `null`. + */ + readonly provides: Token | null; + + /** + * The function which activates the plugin. + */ + readonly activate: (app: T, ...args: any[]) => any; + + /** + * The optional function which deactivates the plugin. + */ + readonly deactivate: + | ((app: T, ...args: any[]) => void | Promise) + | null; + + /** + * Whether the plugin has been activated. + */ + activated: boolean; + + /** + * The resolved service for the plugin, or `null`. + */ + service: any | null; + + /** + * The pending resolver promise, or `null`. + */ + promise: Promise | null; + } + + class PluginData implements IPluginData { + private _activated = false; + private _promise: Promise | null = null; + private _service: U | null = null; + + constructor(plugin: IPlugin) { + this.id = plugin.id; + this.description = plugin.description ?? ''; + this.activate = plugin.activate; + this.deactivate = plugin.deactivate ?? null; + this.provides = plugin.provides ?? null; + this.autoStart = plugin.autoStart ?? false; + this.requires = plugin.requires ? plugin.requires.slice() : []; + this.optional = plugin.optional ? plugin.optional.slice() : []; + } + + /** + * The human readable ID of the plugin. + */ + readonly id: string; + + /** + * The description of the plugin. + */ + readonly description: string; + + /** + * Whether the plugin should be activated on application start or waiting for being + * required. If the value is 'defer' then the plugin should be activated only after + * the application is started. + */ + readonly autoStart: boolean | 'defer'; + + /** + * The types of required services for the plugin, or `[]`. + */ + readonly requires: Token[]; + + /** + * The types of optional services for the the plugin, or `[]`. + */ + readonly optional: Token[]; + + /** + * The type of service provided by the plugin, or `null`. + */ + readonly provides: Token | null; + + /** + * The function which activates the plugin. + */ + readonly activate: (app: T, ...args: any[]) => any; + + /** + * The optional function which deactivates the plugin. + */ + readonly deactivate: + | ((app: T, ...args: any[]) => void | Promise) + | null; + + /** + * Whether the plugin has been activated. + */ + get activated(): boolean { + return this._activated; + } + set activated(a: boolean) { + this._activated = a; + } + + /** + * The resolved service for the plugin, or `null`. + */ + get service(): U | null { + return this._service; + } + set service(s: U | null) { + this._service = s; + } + + /** + * The pending resolver promise, or `null`. + */ + get promise(): Promise | null { + return this._promise; + } + set promise(p: Promise | null) { + this._promise = p; + } + } + + /** + * Create a normalized plugin data object for the given plugin. + */ + export function createPluginData( + plugin: IPlugin + ): IPluginData { + return new PluginData(plugin); + } + + /** + * Ensure no cycle is present in the plugin resolution graph. + * + * If a cycle is detected, an error will be thrown. + */ + export function ensureNoCycle( + plugin: IPluginData, + plugins: Map, + services: Map, string> + ): void { + const dependencies = [...plugin.requires, ...plugin.optional]; + const visit = (token: Token): boolean => { + if (token === plugin.provides) { + return true; + } + const id = services.get(token); + if (!id) { + return false; + } + const visited = plugins.get(id)!; + const dependencies = [...visited.requires, ...visited.optional]; + if (dependencies.length === 0) { + return false; + } + trace.push(id); + if (dependencies.some(visit)) { + return true; + } + trace.pop(); + return false; + }; + + // Bail early if there cannot be a cycle. + if (!plugin.provides || dependencies.length === 0) { + return; + } + + // Setup a stack to trace service resolution. + const trace = [plugin.id]; + + // Throw an exception if a cycle is present. + if (dependencies.some(visit)) { + throw new ReferenceError(`Cycle detected: ${trace.join(' -> ')}.`); + } + } + + /** + * Find dependents in deactivation order. + * + * @param id - The ID of the plugin of interest. + * + * @param plugins - The map containing all plugins. + * + * @param services - The map containing all services. + * + * @returns A list of dependent plugin IDs in order of deactivation + * + * #### Notes + * The final item of the returned list is always the plugin of interest. + */ + export function findDependents( + id: string, + plugins: Map, + services: Map, string> + ): string[] { + const edges = new Array<[string, string]>(); + const add = (id: string): void => { + const plugin = plugins.get(id)!; + // FIXME In the case of missing optional dependencies, we may consider + // deactivating and reactivating the plugin without the missing service. + const dependencies = [...plugin.requires, ...plugin.optional]; + edges.push( + ...dependencies.reduce<[string, string][]>((acc, dep) => { + const service = services.get(dep); + if (service) { + // An edge is oriented from dependent to provider. + acc.push([id, service]); + } + return acc; + }, []) + ); + }; + + for (const id of plugins.keys()) { + add(id); + } + + // Filter edges + // - Get all packages that dependent on the package to be deactivated + const newEdges = edges.filter(edge => edge[1] === id); + let oldSize = 0; + while (newEdges.length > oldSize) { + const previousSize = newEdges.length; + // Get all packages that dependent on packages that will be deactivated + const packagesOfInterest = new Set(newEdges.map(edge => edge[0])); + for (const poi of packagesOfInterest) { + edges + .filter(edge => edge[1] === poi) + .forEach(edge => { + // We check it is not already included to deal with circular dependencies + if (!newEdges.includes(edge)) { + newEdges.push(edge); + } + }); + } + oldSize = previousSize; + } + + const sorted = topologicSort(newEdges); + const index = sorted.findIndex(candidate => candidate === id); + + if (index === -1) { + return [id]; + } + + return sorted.slice(0, index + 1); + } + + /** + * Collect the IDs of the plugins to activate on startup. + */ + export function collectStartupPlugins( + plugins: Map, + options: PluginRegistry.IStartOptions + ): string[] { + // Create a set to hold the plugin IDs. + const collection = new Set(); + + // Collect the auto-start (non deferred) plugins. + for (const id of plugins.keys()) { + if (plugins.get(id)!.autoStart === true) { + collection.add(id); + } + } + + // Add the startup plugins. + if (options.startPlugins) { + for (const id of options.startPlugins) { + collection.add(id); + } + } + + // Remove the ignored plugins. + if (options.ignorePlugins) { + for (const id of options.ignorePlugins) { + collection.delete(id); + } + } + + // Return the collected startup plugins. + return Array.from(collection); + } +} diff --git a/packages/coreutils/src/typing.d.ts b/packages/coreutils/src/typing.d.ts new file mode 100644 index 000000000..a8214757b --- /dev/null +++ b/packages/coreutils/src/typing.d.ts @@ -0,0 +1,53 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +/* + * Define typing for the console to restrict the DOM API to the minimal + * set compatible with the browser and Node.js + */ + +interface Console { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/assert_static) */ + assert(condition?: boolean, ...data: any[]): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/clear_static) */ + clear(): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count_static) */ + count(label?: string): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countReset_static) */ + countReset(label?: string): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug_static) */ + debug(...data: any[]): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir_static) */ + dir(item?: any, options?: any): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dirxml_static) */ + dirxml(...data: any[]): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static) */ + error(...data: any[]): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group_static) */ + group(...data: any[]): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed_static) */ + groupCollapsed(...data: any[]): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd_static) */ + groupEnd(): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static) */ + info(...data: any[]): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static) */ + log(...data: any[]): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/table_static) */ + table(tabularData?: any, properties?: string[]): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time_static) */ + time(label?: string): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd_static) */ + timeEnd(label?: string): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog_static) */ + timeLog(label?: string, ...data: any[]): void; + timeStamp(label?: string): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/trace_static) */ + trace(...data: any[]): void; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static) */ + warn(...data: any[]): void; +} + +declare let console: Console; diff --git a/packages/coreutils/tests/src/index.spec.ts b/packages/coreutils/tests/src/index.spec.ts index fe508652b..c78c4543d 100644 --- a/packages/coreutils/tests/src/index.spec.ts +++ b/packages/coreutils/tests/src/index.spec.ts @@ -10,5 +10,6 @@ import './json.spec'; import './mime.spec'; +import './plugins.spec'; import './promise.spec'; import './token.spec'; diff --git a/packages/coreutils/tests/src/plugins.spec.ts b/packages/coreutils/tests/src/plugins.spec.ts new file mode 100644 index 000000000..ae8b8562e --- /dev/null +++ b/packages/coreutils/tests/src/plugins.spec.ts @@ -0,0 +1,650 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. +/*----------------------------------------------------------------------------- +| Copyright (c) 2014-2017, PhosphorJS Contributors +| +| Distributed under the terms of the BSD 3-Clause License. +| +| The full license is in the file LICENSE, distributed with this software. +|----------------------------------------------------------------------------*/ +import { expect } from 'chai'; + +import { type IPlugin, PluginRegistry, Token } from '@lumino/coreutils'; + +describe('@lumino/coreutils', () => { + describe('PluginRegistry', () => { + describe('#constructor', () => { + it('should instantiate an plugin registry without options', () => { + const plugins = new PluginRegistry(); + + expect(plugins).to.be.instanceOf(PluginRegistry); + }); + + it('should accept validation function', () => { + const plugins = new PluginRegistry({ + validatePlugin: (plugin: IPlugin) => + !['plugin1', 'plugin2'].includes(plugin.id) + }); + + expect(plugins).to.be.instanceOf(PluginRegistry); + }); + }); + + describe('#application', () => { + it('should be null by default', () => { + const plugins = new PluginRegistry(); + + expect(plugins.application).to.be.null; + }); + + it('should accept any object', () => { + const plugins = new PluginRegistry(); + + const app = Object.freeze({}); + plugins.application = app; + + expect(plugins.application).to.be.equal(app); + }); + + it('cannot be overridden', () => { + const plugins = new PluginRegistry(); + + const app = Object.freeze({}); + plugins.application = app; + + expect(plugins.application).to.be.equal(app); + + expect(function () { + plugins.application = Object.freeze({}); + }).to.throw(); + }); + }); + + describe('#getPluginDescription', () => { + it('should return the plugin description', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + const description = 'Plugin 1 description'; + plugins.registerPlugin({ + id, + description, + activate: () => { + // no-op + } + }); + + expect(plugins.getPluginDescription(id)).to.equal(description); + }); + + it('should return an empty string if plugin has no description', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + expect(plugins.getPluginDescription(id)).to.equal(''); + }); + + it('should return an empty string if plugin does not exist', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + + expect(plugins.getPluginDescription(id)).to.equal(''); + }); + }); + + describe('#hasPlugin', () => { + it('should be true for registered plugin', () => { + const pluginRegistry = new PluginRegistry(); + const id = 'plugin1'; + pluginRegistry.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + expect(pluginRegistry.hasPlugin(id)).to.be.true; + }); + + it('should be false for unregistered plugin', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + expect(plugins.hasPlugin('plugin2')).to.be.false; + }); + }); + + describe('#isPluginActivated', () => { + it('should be true for activated plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + await plugins.activatePlugin(id); + expect(plugins.isPluginActivated(id)).to.be.true; + }); + + it('should be true for an autoStart plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + }, + autoStart: true + }); + await plugins.activatePlugins('startUp'); + expect(plugins.isPluginActivated(id)).to.be.true; + }); + + it('should be false for not activated plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + expect(plugins.isPluginActivated(id)).to.be.false; + }); + + it('should be false for deferred plugin when application start', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + }, + autoStart: 'defer' + }); + await plugins.activatePlugins('startUp'); + expect(plugins.isPluginActivated(id)).to.be.false; + await plugins.activatePlugins('defer'); + expect(plugins.isPluginActivated(id)).to.be.true; + }); + + it('should be false for unregistered plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + await plugins.activatePlugin(id); + expect(plugins.isPluginActivated('no-registered')).to.be.false; + }); + }); + + describe('#listPlugins', () => { + it('should list the registered plugin', () => { + const plugins = new PluginRegistry(); + const ids = ['plugin1', 'plugin2']; + ids.forEach(id => { + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + }); + + expect(plugins.listPlugins()).to.deep.equal(ids); + }); + }); + + describe('#registerPlugin', () => { + it('should register a plugin', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + expect(plugins.hasPlugin(id)).to.be.true; + }); + + it('should not register an already registered plugin', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + expect(function () { + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + }).to.throw(); + }); + + it('should not register a plugin introducing a cycle', () => { + const plugins = new PluginRegistry(); + const id1 = 'plugin1'; + const token1 = new Token(id1); + const id2 = 'plugin2'; + const token2 = new Token(id2); + const id3 = 'plugin3'; + const token3 = new Token(id3); + plugins.registerPlugin({ + id: id1, + activate: () => { + // no-op + }, + requires: [token3], + provides: token1 + }); + plugins.registerPlugin({ + id: id2, + activate: () => { + // no-op + }, + requires: [token1], + provides: token2 + }); + + expect(function () { + plugins.registerPlugin({ + id: id3, + activate: () => { + // no-op + }, + requires: [token2], + provides: token3 + }); + }).to.throw(); + }); + + it('should register a plugin defined by a class', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + const plugin = new (class { + readonly id = id; + activate = () => { + // Check this.id is accessible as expected + // as we are tearing a part the plugin object. + expect(this.id).to.equal(id); + }; + })(); + plugins.registerPlugin(plugin); + + expect(plugins.hasPlugin(id)).to.be.true; + }); + + it('should refuse to register invalid plugins', async () => { + const plugins = new PluginRegistry({ + validatePlugin: (plugin: IPlugin) => + ['id1'].includes(plugin.id) + }); + expect(function () { + plugins.registerPlugin({ + id: 'id', + activate: () => { + /* no-op */ + } + }); + }).to.throw(); + plugins.registerPlugin({ + id: 'id1', + activate: () => { + /* no-op */ + } + }); + }); + }); + + describe('#deregisterPlugin', () => { + it('should deregister a deactivated registered plugin', () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + plugins.deregisterPlugin(id); + + expect(plugins.hasPlugin(id)).to.be.false; + }); + + it('should not deregister an activated registered plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + await plugins.activatePlugin(id); + + expect(() => { + plugins.deregisterPlugin(id); + }).to.throw(); + expect(plugins.hasPlugin(id)).to.be.true; + }); + + it('should force deregister an activated registered plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + await plugins.activatePlugin(id); + + plugins.deregisterPlugin(id, true); + expect(plugins.hasPlugin(id)).to.be.false; + }); + }); + + describe('#activatePlugin', () => { + it('should activate a registered plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + await plugins.activatePlugin(id); + expect(plugins.isPluginActivated(id)).to.be.true; + }); + + it('should throw an error when activating a unregistered plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + try { + await plugins.activatePlugin('other-id'); + } catch (reason) { + return; + } + + expect(false, 'app.activatePlugin did not throw').to.be.true; + }); + + it('should tolerate activating an activated plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + await plugins.activatePlugin(id); + + await plugins.activatePlugin(id); + + expect(plugins.isPluginActivated(id)).to.be.true; + }); + + it('should activate all required services', async () => { + const plugins = new PluginRegistry(); + const id1 = 'plugin1'; + const token1 = new Token(id1); + const id2 = 'plugin2'; + const token2 = new Token(id2); + const id3 = 'plugin3'; + const token3 = new Token(id3); + plugins.registerPlugin({ + id: id1, + activate: () => { + // no-op + }, + provides: token1 + }); + plugins.registerPlugin({ + id: id2, + activate: () => { + // no-op + }, + requires: [token1], + provides: token2 + }); + plugins.registerPlugin({ + id: id3, + activate: () => { + // no-op + }, + requires: [token2], + provides: token3 + }); + + await plugins.activatePlugin(id3); + + expect(plugins.isPluginActivated(id3)).to.be.true; + expect(plugins.isPluginActivated(id1)).to.be.true; + expect(plugins.isPluginActivated(id2)).to.be.true; + }); + + it('should try activating all optional services', async () => { + const plugins = new PluginRegistry(); + const id1 = 'plugin1'; + const token1 = new Token(id1); + const id2 = 'plugin2'; + const token2 = new Token(id2); + const id3 = 'plugin3'; + const token3 = new Token(id3); + plugins.registerPlugin({ + id: id1, + activate: () => { + // no-op + }, + provides: token1 + }); + plugins.registerPlugin({ + id: id2, + activate: () => { + throw new Error(`Force failure during '${id2}' activation`); + }, + provides: token2 + }); + plugins.registerPlugin({ + id: id3, + activate: () => { + // no-op + }, + optional: [token1, token2], + provides: token3 + }); + + await plugins.activatePlugin(id3); + + expect(plugins.isPluginActivated(id3)).to.be.true; + expect(plugins.isPluginActivated(id1)).to.be.true; + expect(plugins.isPluginActivated(id2)).to.be.false; + }); + }); + + describe('#deactivatePlugin', () => { + it('should call deactivate on the plugin', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + let deactivated: boolean | null = null; + plugins.registerPlugin({ + id, + activate: () => { + deactivated = false; + }, + deactivate: () => { + deactivated = true; + } + }); + + await plugins.activatePlugin(id); + + expect(deactivated).to.be.false; + + const others = await plugins.deactivatePlugin(id); + + expect(deactivated).to.be.true; + expect(others.length).to.equal(0); + }); + + it('should throw an error if the plugin does not support deactivation', async () => { + const plugins = new PluginRegistry(); + const id = 'plugin1'; + plugins.registerPlugin({ + id, + activate: () => { + // no-op + } + }); + + await plugins.activatePlugin(id); + + try { + await plugins.deactivatePlugin(id); + } catch (r) { + return; + } + + expect(true, 'app.deactivatePlugin did not throw').to.be.false; + }); + + it('should throw an error if the plugin has dependants not support deactivation', async () => { + const plugins = new PluginRegistry(); + const id1 = 'plugin1'; + const token1 = new Token(id1); + const id2 = 'plugin2'; + const token2 = new Token(id2); + const id3 = 'plugin3'; + const token3 = new Token(id3); + plugins.registerPlugin({ + id: id1, + activate: () => { + // no-op + }, + deactivate: () => { + // no-op + }, + provides: token1 + }); + plugins.registerPlugin({ + id: id2, + activate: () => { + // no-op + }, + deactivate: () => { + // no-op + }, + requires: [token1], + provides: token2 + }); + plugins.registerPlugin({ + id: id3, + activate: () => { + // no-op + }, + requires: [token2], + provides: token3 + }); + + await plugins.activatePlugin(id3); + + try { + await plugins.deactivatePlugin(id1); + } catch (r) { + return; + } + + expect(true, 'app.deactivatePlugin did not throw').to.be.false; + }); + + it('should deactivate all dependents (optional or not)', async () => { + const plugins = new PluginRegistry(); + let deactivated: boolean | null = null; + const id1 = 'plugin1'; + const token1 = new Token(id1); + const id2 = 'plugin2'; + const token2 = new Token(id2); + const id3 = 'plugin3'; + const token3 = new Token(id3); + plugins.registerPlugin({ + id: id1, + activate: () => { + deactivated = false; + }, + deactivate: () => { + deactivated = true; + }, + provides: token1 + }); + plugins.registerPlugin({ + id: id2, + activate: () => { + // no-op + }, + deactivate: () => { + // no-op + }, + requires: [token1], + provides: token2 + }); + plugins.registerPlugin({ + id: id3, + activate: () => { + // no-op + }, + deactivate: () => { + // no-op + }, + optional: [token2], + provides: token3 + }); + + await plugins.activatePlugin(id3); + + const others = await plugins.deactivatePlugin(id1); + + expect(deactivated).to.be.true; + expect(others).to.deep.equal([id3, id2]); + expect(plugins.isPluginActivated(id2)).to.be.false; + expect(plugins.isPluginActivated(id3)).to.be.false; + }); + }); + }); +}); diff --git a/review/api/application.api.md b/review/api/application.api.md index 7c884b252..cc30b57e9 100644 --- a/review/api/application.api.md +++ b/review/api/application.api.md @@ -6,7 +6,9 @@ import { CommandRegistry } from '@lumino/commands'; import { ContextMenu } from '@lumino/widgets'; +import { IPlugin } from '@lumino/coreutils'; import { Menu } from '@lumino/widgets'; +import { PluginRegistry } from '@lumino/coreutils'; import { Token } from '@lumino/coreutils'; import { Widget } from '@lumino/widgets'; @@ -31,6 +33,7 @@ export class Application { hasPlugin(id: string): boolean; isPluginActivated(id: string): boolean; listPlugins(): string[]; + protected pluginRegistry: PluginRegistry; registerPlugin(plugin: IPlugin): void; registerPlugins(plugins: IPlugin[]): void; resolveOptionalService(token: Token): Promise; @@ -42,8 +45,9 @@ export class Application { // @public export namespace Application { - export interface IOptions { + export interface IOptions extends PluginRegistry.IOptions { contextMenuRenderer?: Menu.IRenderer; + pluginRegistry?: PluginRegistry; shell: T; } export interface IStartOptions { @@ -54,18 +58,6 @@ export namespace Application { } } -// @public -export interface IPlugin { - activate: (app: T, ...args: any[]) => U | Promise; - autoStart?: boolean | 'defer'; - deactivate?: ((app: T, ...args: any[]) => void | Promise) | null; - description?: string; - id: string; - optional?: Token[]; - provides?: Token | null; - requires?: Token[]; -} - -// (No @packageDocumentation comment for this package) +export { IPlugin } ``` diff --git a/review/api/coreutils.api.md b/review/api/coreutils.api.md index 21b8893ae..09a897b2e 100644 --- a/review/api/coreutils.api.md +++ b/review/api/coreutils.api.md @@ -4,6 +4,18 @@ ```ts +// @public +export interface IPlugin { + activate: (app: T, ...args: any[]) => U | Promise; + autoStart?: boolean | 'defer'; + deactivate?: ((app: T, ...args: any[]) => void | Promise) | null; + description?: string; + id: string; + optional?: Token[]; + provides?: Token | null; + requires?: Token[]; +} + // @public export interface JSONArray extends Array { } @@ -66,6 +78,37 @@ export interface PartialJSONObject { // @public export type PartialJSONValue = JSONPrimitive | PartialJSONObject | PartialJSONArray; +// @public +export class PluginRegistry { + constructor(options?: PluginRegistry.IOptions); + activatePlugin(id: string): Promise; + activatePlugins(kind: 'startUp' | 'defer', options?: PluginRegistry.IStartOptions): Promise; + get application(): T; + set application(v: T); + deactivatePlugin(id: string): Promise; + get deferredPlugins(): string[]; + deregisterPlugin(id: string, force?: boolean): void; + getPluginDescription(id: string): string; + hasPlugin(id: string): boolean; + isPluginActivated(id: string): boolean; + listPlugins(): string[]; + registerPlugin(plugin: IPlugin): void; + registerPlugins(plugins: IPlugin[]): void; + resolveOptionalService(token: Token): Promise; + resolveRequiredService(token: Token): Promise; +} + +// @public +export namespace PluginRegistry { + export interface IOptions { + validatePlugin?: (plugin: IPlugin) => boolean; + } + export interface IStartOptions { + ignorePlugins?: string[]; + startPlugins?: string[]; + } +} + // @public export class PromiseDelegate { constructor(); diff --git a/yarn.lock b/yarn.lock index 6e7a79050..bc4be23eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -423,6 +423,7 @@ __metadata: version: 0.0.0-use.local resolution: "@lumino/coreutils@workspace:packages/coreutils" dependencies: + "@lumino/algorithm": ^2.0.1 "@lumino/buildutils": ^2.0.1 "@microsoft/api-extractor": ^7.36.0 "@rollup/plugin-commonjs": ^24.0.0