From dd7b941d68811691d32611953081626923a47dee Mon Sep 17 00:00:00 2001 From: Tobias Ortmayr Date: Wed, 26 May 2021 09:50:49 +0200 Subject: [PATCH] Provide API to filter unwanted contributions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the ContributionFilterRegistry which can be used to filter contributions of Theia extensions before they are bound. This mechanism can be used by application developers to specifically filter individual contributions from Theia extensions they have no control over, i.e. core or 3rd party extensions. Resolves #9069 Contributed on behalf of STMicroelectronics Signed-off-by: Tobias Ortmayr Co-Authored-By: Paul Maréchal squash me? Signed-off-by: Paul Maréchal --- CHANGELOG.md | 1 + .../browser/api-samples-frontend-module.ts | 2 + .../sample-filtered-command-contribution.ts | 80 +++++++++++++++++++ .../api-tests/src/contribution-filter.spec.js | 36 +++++++++ .../browser/frontend-application-module.ts | 3 + .../contribution-filter-registry.ts | 78 ++++++++++++++++++ .../contribution-filter.ts | 46 +++++++++++ .../src/common/contribution-filter/filter.ts | 43 ++++++++++ .../src/common/contribution-filter/index.ts | 20 +++++ .../contribution-filter/string-utils.ts | 60 ++++++++++++++ .../core/src/common/contribution-provider.ts | 25 +++++- packages/core/src/common/index.ts | 1 + .../src/node/backend-application-module.ts | 3 + 13 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 examples/api-samples/src/browser/contribution-filter/sample-filtered-command-contribution.ts create mode 100644 examples/api-tests/src/contribution-filter.spec.js create mode 100644 packages/core/src/common/contribution-filter/contribution-filter-registry.ts create mode 100644 packages/core/src/common/contribution-filter/contribution-filter.ts create mode 100644 packages/core/src/common/contribution-filter/filter.ts create mode 100644 packages/core/src/common/contribution-filter/index.ts create mode 100644 packages/core/src/common/contribution-filter/string-utils.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e3e6fbfae2371..d4251a28035ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ [1.14.0 Milestone](https://github.com/eclipse-theia/theia/milestone/20) +- [core] add API to filter contributions at runtime [#9317](https://github.com/eclipse-theia/theia/pull/9317) Contributed on behalf of STMicroelectronics - [debug] Fix behavior of `Add Configurations` command when empty `launch.json` present. [#9467](https://github.com/eclipse-theia/theia/pull/9467) [Breaking Changes:](#breaking_changes_1.14.0) diff --git a/examples/api-samples/src/browser/api-samples-frontend-module.ts b/examples/api-samples/src/browser/api-samples-frontend-module.ts index d06b8fb0ea21e..10a3ba15d7ecc 100644 --- a/examples/api-samples/src/browser/api-samples-frontend-module.ts +++ b/examples/api-samples/src/browser/api-samples-frontend-module.ts @@ -16,6 +16,7 @@ import { ContainerModule } from '@theia/core/shared/inversify'; import { bindDynamicLabelProvider } from './label/sample-dynamic-label-provider-command-contribution'; +import { bindSampleFilteredCommandContribution } from './contribution-filter/sample-filtered-command-contribution'; import { bindSampleUnclosableView } from './view/sample-unclosable-view-contribution'; import { bindSampleOutputChannelWithSeverity } from './output/sample-output-channel-with-severity'; import { bindSampleMenu } from './menu/sample-menu-contribution'; @@ -31,4 +32,5 @@ export default new ContainerModule(bind => { bindSampleMenu(bind); bindSampleFileWatching(bind); bindVSXCommand(bind); + bindSampleFilteredCommandContribution(bind); }); diff --git a/examples/api-samples/src/browser/contribution-filter/sample-filtered-command-contribution.ts b/examples/api-samples/src/browser/contribution-filter/sample-filtered-command-contribution.ts new file mode 100644 index 0000000000000..059384f9558d0 --- /dev/null +++ b/examples/api-samples/src/browser/contribution-filter/sample-filtered-command-contribution.ts @@ -0,0 +1,80 @@ +/******************************************************************************** + * Copyright (C) 2021 STMicroelectronics and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { Command, CommandContribution, CommandRegistry, FilterContribution, ContributionFilterRegistry, bindContribution, Filter } from '@theia/core/lib/common'; +import { injectable, interfaces } from '@theia/core/shared/inversify'; + +export namespace SampleFilteredCommand { + + const EXAMPLE_CATEGORY = 'Examples'; + + export const FILTERED: Command = { + id: 'example_command.filtered', + category: EXAMPLE_CATEGORY, + label: 'This command should be filtered out' + }; + + export const FILTERED2: Command = { + id: 'example_command.filtered2', + category: EXAMPLE_CATEGORY, + label: 'This command should be filtered out (2)' + }; +} + +/** + * This sample command is used to test the runtime filtering of already bound contributions. + */ +@injectable() +export class SampleFilteredCommandContribution implements CommandContribution { + + registerCommands(commands: CommandRegistry): void { + commands.registerCommand(SampleFilteredCommand.FILTERED, { execute: () => { } }); + } +} + +@injectable() +export class SampleFilterAndCommandContribution implements FilterContribution, CommandContribution { + + registerCommands(commands: CommandRegistry): void { + commands.registerCommand(SampleFilteredCommand.FILTERED2, { execute: () => { } }); + } + + registerContributionFilters(registry: ContributionFilterRegistry): void { + registry.addFilters([CommandContribution], [ + // filter ourselves out + contrib => contrib.constructor === this.constructor + ]); + registry.addFilters('*', [ + // filter a contribution based on its class name + filterClassName(name => name === 'SampleFilteredCommandContribution') + ]); + } +} + +export function bindSampleFilteredCommandContribution(bind: interfaces.Bind): void { + bind(CommandContribution).to(SampleFilteredCommandContribution).inSingletonScope(); + bind(SampleFilterAndCommandContribution).toSelf().inSingletonScope(); + bindContribution(bind, SampleFilterAndCommandContribution, [CommandContribution, FilterContribution]); +} + +function filterClassName(filter: Filter): Filter { + return object => { + const className = object?.constructor?.name; + return className + ? filter(className) + : false; + }; +} diff --git a/examples/api-tests/src/contribution-filter.spec.js b/examples/api-tests/src/contribution-filter.spec.js new file mode 100644 index 0000000000000..c5267ed4496e4 --- /dev/null +++ b/examples/api-tests/src/contribution-filter.spec.js @@ -0,0 +1,36 @@ +/******************************************************************************** + * Copyright (C) 2021 STMicroelectronics and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +// @ts-check +describe('Contribution filter', function () { + this.timeout(5000); + const { assert } = chai; + + const { CommandRegistry, CommandContribution } = require('@theia/core/lib/common/command'); + const { SampleFilteredCommandContribution, SampleFilteredCommand } = require('@theia/api-samples/lib/browser/contribution-filter/sample-filtered-command-contribution'); + + const container = window.theia.container; + const commands = container.get(CommandRegistry); + + it('filtered command in container but not in registry', async function () { + const allCommands = container.getAll(CommandContribution); + assert.isDefined(allCommands.find(contribution => contribution instanceof SampleFilteredCommandContribution), + 'SampleFilteredCommandContribution is not bound in container'); + const filteredCommand = commands.getCommand(SampleFilteredCommand.FILTERED.id); + assert.isUndefined(filteredCommand, 'SampleFilteredCommandContribution should be filtered out but is present in "CommandRegistry"'); + }); + +}); diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 9f041675a91b0..5f533fbe46c2d 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -98,6 +98,7 @@ import { EncodingRegistry } from './encoding-registry'; import { EncodingService } from '../common/encoding-service'; import { AuthenticationService, AuthenticationServiceImpl } from '../browser/authentication-service'; import { DecorationsService, DecorationsServiceImpl } from './decorations-service'; +import { ContributionFilterRegistryImpl } from '../common/contribution-filter'; export { bindResourceProvider, bindMessageService, bindPreferenceService }; @@ -340,4 +341,6 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo bind(AuthenticationService).to(AuthenticationServiceImpl).inSingletonScope(); bind(DecorationsService).to(DecorationsServiceImpl).inSingletonScope(); + + bind(ContributionFilterRegistryImpl).toSelf().inSingletonScope(); }); diff --git a/packages/core/src/common/contribution-filter/contribution-filter-registry.ts b/packages/core/src/common/contribution-filter/contribution-filter-registry.ts new file mode 100644 index 0000000000000..0ffb6a5d68b8e --- /dev/null +++ b/packages/core/src/common/contribution-filter/contribution-filter-registry.ts @@ -0,0 +1,78 @@ +/******************************************************************************** + * Copyright (C) 2021 STMicroelectronics and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable, multiInject, optional } from 'inversify'; +import { ContributionFilterRegistry, ContributionType, FilterContribution } from './contribution-filter'; +import { applyFilters, Filter } from './filter'; + +/** + * Registry of contribution filters. + * + * Implement/bind to the `FilterContribution` interface/symbol to register your contribution filters. + */ +@injectable() +export class ContributionFilterRegistryImpl implements ContributionFilterRegistry { + + protected initialized = false; + protected genericFilters: Filter[] = []; + protected typeToFilters = new Map[]>(); + + constructor( + @multiInject(FilterContribution) @optional() contributions: FilterContribution[] = [] + ) { + for (const contribution of contributions) { + contribution.registerContributionFilters(this); + } + this.initialized = true; + } + + addFilters(types: '*' | ContributionType[], filters: Filter[]): void { + if (this.initialized) { + throw new Error('cannot add filters after initialization is done.'); + } else if (types === '*') { + this.genericFilters.push(...filters); + } else { + for (const type of types) { + this.getOrCreate(type).push(...filters); + } + } + } + + /** + * Applies the filters for the given contribution type. Generic filters will be applied on any given type. + * @param toFilter the elements to filter + * @param type the contribution type for which potentially filters were registered + * @returns the filtered elements + */ + applyFilters(toFilter: T[], type: ContributionType): T[] { + return applyFilters(toFilter, this.getFilters(type)); + } + + protected getOrCreate(type: ContributionType): Filter[] { + let value = this.typeToFilters.get(type); + if (value === undefined) { + this.typeToFilters.set(type, value = []); + } + return value; + } + + protected getFilters(type: ContributionType): Filter[] { + return [ + ...this.typeToFilters.get(type) || [], + ...this.genericFilters + ]; + } +} diff --git a/packages/core/src/common/contribution-filter/contribution-filter.ts b/packages/core/src/common/contribution-filter/contribution-filter.ts new file mode 100644 index 0000000000000..cab10b8f77cac --- /dev/null +++ b/packages/core/src/common/contribution-filter/contribution-filter.ts @@ -0,0 +1,46 @@ +/******************************************************************************** + * Copyright (C) 2021 STMicroelectronics and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { interfaces } from 'inversify'; +import { Filter } from './filter'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ContributionType = interfaces.ServiceIdentifier; + +export interface ContributionFilterRegistry { + + /** + * Add filters to be applied for every type of contribution. + */ + addFilters(types: '*', filters: Filter[]): void; + + /** + * Given a list of contribution types, register filters to apply. + * @param types types for which to register the filters. + */ + addFilters(types: ContributionType[], filters: Filter[]): void; +} + +export const FilterContribution = Symbol('FilterContribution'); +/** + * Register filters to remove contributions. + */ +export interface FilterContribution { + /** + * Use the registry to register your contribution filters. + */ + registerContributionFilters(registry: ContributionFilterRegistry): void; +} diff --git a/packages/core/src/common/contribution-filter/filter.ts b/packages/core/src/common/contribution-filter/filter.ts new file mode 100644 index 0000000000000..4d336022217fc --- /dev/null +++ b/packages/core/src/common/contribution-filter/filter.ts @@ -0,0 +1,43 @@ +/******************************************************************************** + * Copyright (C) 2021 STMicroelectronics and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +export const Filter = Symbol('Filter'); + +/** + * @param toTest Object that should be tested + * @returns `true` if the object should be filtered out, `false` otherwise + */ +export type Filter = (toTest: T) => boolean; + +/** + * Applies a set of filters to a set of given objects and returns the set of filtered objects. + * @param toFilter Set of objects which should be filtered + * @param filters Set of filters that should be applied + * @param negate Negation flag. If set to true the result of all filters is negated + * @returns The set of filtered arguments + */ +export function applyFilters(toFilter: T[], filters: Filter[], negate: boolean = false): T[] { + if (filters.length === 0) { + return toFilter; + } + return toFilter.filter( + object => filters.every( + // By default we want to *keep* objects when false === filter.test(object) + // because filter.test(object) returns true to exclude items. + filter => negate === filter(object) + ) + ); +} diff --git a/packages/core/src/common/contribution-filter/index.ts b/packages/core/src/common/contribution-filter/index.ts new file mode 100644 index 0000000000000..05d83f024d5fa --- /dev/null +++ b/packages/core/src/common/contribution-filter/index.ts @@ -0,0 +1,20 @@ +/******************************************************************************** + * Copyright (C) 2021 STMicroelectronics and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +export * from './contribution-filter'; +export * from './contribution-filter-registry'; +export * from './filter'; +export * from './string-utils'; diff --git a/packages/core/src/common/contribution-filter/string-utils.ts b/packages/core/src/common/contribution-filter/string-utils.ts new file mode 100644 index 0000000000000..da90a9a06225e --- /dev/null +++ b/packages/core/src/common/contribution-filter/string-utils.ts @@ -0,0 +1,60 @@ +/******************************************************************************** + * Copyright (C) 2021 STMicroelectronics and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +/** + * Utility function to test wether any of the given string patterns is included in the + * tested string. + * @param testStr String that should be tested + * @param ignoreCase Flag, to indicate wether the test should be case-sensitive or not + * @param patterns The set of patterns that should be tested for inclusion + * @returns `true` if any of the given patterns is included in the test string, `false` otherwise + */ +export function includes(testStr: string, ignoreCase: boolean, ...patterns: string[]): boolean { + if (ignoreCase) { + testStr = testStr.toLowerCase(); + patterns = patterns.map(pattern => pattern.toLowerCase()); + } + return patterns.some(pattern => testStr.includes(pattern)); +} + +/** + * Utility function to test wether any of the given string patterns is equal to the + * tested string. + * @param testStr String that should be tested + * @param ignoreCase Flag, to indicate wether the test should be case-sensitive or not + * @param patterns The set of patterns that should be tested for equality + * @returns `true` if any of the given patterns is equal to the test string, `false` otherwise + */ +export function equals(testStr: string, ignoreCase: boolean, ...patterns: string[]): boolean { + if (ignoreCase) { + testStr = testStr.toLowerCase(); + patterns = patterns.map(pattern => pattern.toLowerCase()); + } + return patterns.some(pattern => testStr === pattern); + +} + +/** + * Utility function to test wether a string matches any of the given regular expression patterns. + * @param testStr String that should be tested + * @param ignoreCase Flag, to indicate wether the test should be case-sensitive or not + * @param patterns The set of regular expressions that should be matched + * @returns `true` if the test string matches any of the given regular expressions, `false` otherwise + */ +export function matches(testStr: string, ignoreCase: boolean, ...patterns: (RegExp | string)[]): boolean { + const flags = ignoreCase ? 'i' : undefined; + return patterns.some(pattern => new RegExp(pattern, flags).test(testStr)); +} diff --git a/packages/core/src/common/contribution-provider.ts b/packages/core/src/common/contribution-provider.ts index 3a4605e3e4376..953063d2e4aed 100644 --- a/packages/core/src/common/contribution-provider.ts +++ b/packages/core/src/common/contribution-provider.ts @@ -15,6 +15,7 @@ ********************************************************************************/ import { interfaces } from 'inversify'; +import { ContributionFilterRegistryImpl } from './contribution-filter'; export const ContributionProvider = Symbol('ContributionProvider'); @@ -38,6 +39,7 @@ class ContainerBasedContributionProvider implements Contributi getContributions(recursive?: boolean): T[] { if (this.services === undefined) { const currentServices: T[] = []; + let filterRegistry: ContributionFilterRegistryImpl | undefined; let currentContainer: interfaces.Container | null = this.container; // eslint-disable-next-line no-null/no-null while (currentContainer !== null) { @@ -48,10 +50,17 @@ class ContainerBasedContributionProvider implements Contributi console.error(error); } } + if (filterRegistry === undefined && currentContainer.isBound(ContributionFilterRegistryImpl)) { + filterRegistry = currentContainer.get(ContributionFilterRegistryImpl); + } // eslint-disable-next-line no-null/no-null currentContainer = recursive === true ? currentContainer.parent : null; } - this.services = currentServices; + if (filterRegistry === undefined) { + this.services = currentServices; + } else { + this.services = filterRegistry.applyFilters(currentServices, this.serviceIdentifier); + } } return this.services; } @@ -73,3 +82,17 @@ export function bindContributionProvider(bindable: Bindable, id: symbol): void { .toDynamicValue(ctx => new ContainerBasedContributionProvider(id, ctx.container)) .inSingletonScope().whenTargetNamed(id); } + +/** + * Helper function to bind a service to a list of contributions easily. + * @param bindable a Container or the bind function directly. + * @param service an already bound service to refer the contributions to. + * @param contributions array of contribution identifiers to bind the service to. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function bindContribution(bindable: Bindable, service: interfaces.ServiceIdentifier, contributions: interfaces.ServiceIdentifier[]): void { + const bind: interfaces.Bind = Bindable.isContainer(bindable) ? bindable.bind.bind(bindable) : bindable; + for (const contribution of contributions) { + bind(contribution).toService(service); + } +} diff --git a/packages/core/src/common/index.ts b/packages/core/src/common/index.ts index 25f12ec4385a4..7d6fecbd4a480 100644 --- a/packages/core/src/common/index.ts +++ b/packages/core/src/common/index.ts @@ -37,6 +37,7 @@ export * from './selection'; export * from './strings'; export * from './application-error'; export * from './lsp-types'; +export * from './contribution-filter'; import { environment } from '@theia/application-package/lib/environment'; export { environment }; diff --git a/packages/core/src/node/backend-application-module.ts b/packages/core/src/node/backend-application-module.ts index 41d43532205d6..ac47c5a6d24f4 100644 --- a/packages/core/src/node/backend-application-module.ts +++ b/packages/core/src/node/backend-application-module.ts @@ -30,6 +30,7 @@ import { EnvVariablesServerImpl } from './env-variables'; import { ConnectionContainerModule } from './messaging/connection-container-module'; import { QuickPickService, quickPickServicePath } from '../common/quick-pick-service'; import { WsRequestValidator, WsRequestValidatorContribution } from './ws-request-validators'; +import { ContributionFilterRegistryImpl } from '../common/contribution-filter'; decorate(injectable(), ApplicationPackage); @@ -85,4 +86,6 @@ export const backendApplicationModule = new ContainerModule(bind => { bind(WsRequestValidator).toSelf().inSingletonScope(); bindContributionProvider(bind, WsRequestValidatorContribution); + + bind(ContributionFilterRegistryImpl).toSelf().inSingletonScope(); });