diff --git a/packages/core/src/browser/frontend-application-bindings.ts b/packages/core/src/browser/frontend-application-bindings.ts index d226a6590b508..b18da7306f4c8 100644 --- a/packages/core/src/browser/frontend-application-bindings.ts +++ b/packages/core/src/browser/frontend-application-bindings.ts @@ -15,12 +15,17 @@ ********************************************************************************/ import { interfaces } from 'inversify'; -import { bindContributionProvider, DefaultResourceProvider, MessageClient, MessageService, ResourceProvider, ResourceResolver } from '../common'; +import { + bindContributionProvider, DefaultResourceProvider, MaybePromise, MessageClient, + MessageService, ResourceProvider, ResourceResolver +} from '../common'; import { bindPreferenceSchemaProvider, PreferenceProvider, - PreferenceProviderProvider, PreferenceSchemaProvider, PreferenceScope, - PreferenceService, PreferenceServiceImpl + PreferenceProviderProvider, PreferenceProxyOptions, PreferenceSchema, PreferenceSchemaProvider, PreferenceScope, + PreferenceService, PreferenceServiceImpl, PreferenceValidationService } from './preferences'; +import { InjectablePreferenceProxy, PreferenceProxyFactory, PreferenceProxySchema } from './preferences/injectable-preference-proxy'; +import { ValidatedPreferenceProxy } from './preferences/validated-preference-proxy'; export function bindMessageService(bind: interfaces.Bind): interfaces.BindingWhenOnSyntax { bind(MessageClient).toSelf().inSingletonScope(); @@ -40,6 +45,16 @@ export function bindPreferenceService(bind: interfaces.Bind): void { bind(PreferenceServiceImpl).toSelf().inSingletonScope(); bind(PreferenceService).toService(PreferenceServiceImpl); bindPreferenceSchemaProvider(bind); + bind(PreferenceValidationService).toSelf().inSingletonScope(); + bind(InjectablePreferenceProxy).toSelf(); + bind(ValidatedPreferenceProxy).toSelf(); + bind(PreferenceProxyFactory).toFactory(({ container }) => (schema: MaybePromise, options: PreferenceProxyOptions = {}) => { + const child = container.createChild(); + child.bind(PreferenceProxyOptions).toConstantValue(options ?? {}); + child.bind(PreferenceProxySchema).toConstantValue(schema); + const handler = options.validated ? child.get(ValidatedPreferenceProxy) : child.get(InjectablePreferenceProxy); + return new Proxy(Object.create(null), handler); // eslint-disable-line no-null/no-null + }); } export function bindResourceProvider(bind: interfaces.Bind): void { diff --git a/packages/core/src/browser/preferences/index.ts b/packages/core/src/browser/preferences/index.ts index c8173c98eaf6c..61f967d0f6e7f 100644 --- a/packages/core/src/browser/preferences/index.ts +++ b/packages/core/src/browser/preferences/index.ts @@ -20,3 +20,4 @@ export * from './preference-contribution'; export * from './preference-provider'; export * from './preference-scope'; export * from './preference-language-override-service'; +export * from './preference-validation-service'; diff --git a/packages/core/src/browser/preferences/injectable-preference-proxy.ts b/packages/core/src/browser/preferences/injectable-preference-proxy.ts new file mode 100644 index 0000000000000..8fbf4da7ae1ed --- /dev/null +++ b/packages/core/src/browser/preferences/injectable-preference-proxy.ts @@ -0,0 +1,279 @@ +/******************************************************************************** + * Copyright (C) 2022 Ericsson 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 { inject, injectable, postConstruct } from 'inversify'; +import { PreferenceSchema } from '../../common/preferences/preference-schema'; +import { Disposable, DisposableCollection, Emitter, Event, MaybePromise } from '../../common'; +import { PreferenceChangeEvent, PreferenceEventEmitter, PreferenceProxyOptions, PreferenceRetrieval } from './preference-proxy'; +import { PreferenceChange, PreferenceScope, PreferenceService } from './preference-service'; +import { OverridePreferenceName, PreferenceChangeImpl, PreferenceChanges, PreferenceProviderDataChange, PreferenceProxy } from '.'; +import { JSONValue } from '@phosphor/coreutils'; + +export const PreferenceProxySchema = Symbol('PreferenceProxySchema'); +export interface PreferenceProxyFactory { + (schema: MaybePromise, options?: PreferenceProxyOptions): PreferenceProxy; +} +export const PreferenceProxyFactory = Symbol('PreferenceProxyFactory'); + +export class PreferenceProxyChange extends PreferenceChangeImpl { + constructor(change: PreferenceProviderDataChange, protected readonly overrideIdentifier?: string) { + super(change); + } + + override affects(resourceUri?: string, overrideIdentifier?: string): boolean { + if (overrideIdentifier !== this.overrideIdentifier) { + return false; + } + return super.affects(resourceUri); + } +} + +@injectable() +export class InjectablePreferenceProxy> implements + ProxyHandler, ProxyHandler, ProxyHandler>, ProxyHandler> { + + @inject(PreferenceProxyOptions) protected readonly options: PreferenceProxyOptions; + @inject(PreferenceService) protected readonly preferences: PreferenceService; + @inject(PreferenceProxySchema) protected readonly promisedSchema: PreferenceSchema | Promise; + @inject(PreferenceProxyFactory) protected readonly factory: PreferenceProxyFactory; + protected toDispose = new DisposableCollection(); + protected _onPreferenceChangedEmitter: Emitter> | undefined; + protected schema: PreferenceSchema | undefined; + + protected get prefix(): string { + return this.options.prefix ?? ''; + } + + protected get style(): Required['style'] { + return this.options.style ?? 'flat'; + } + + protected get resourceUri(): PreferenceProxyOptions['resourceUri'] { + return this.options.resourceUri; + } + + protected get overrideIdentifier(): PreferenceProxyOptions['overrideIdentifier'] { + return this.options.overrideIdentifier; + } + + protected get isDeep(): boolean { + const { style } = this; + return style === 'deep' || style === 'both'; + } + + protected get isFlat(): boolean { + const { style } = this; + return style === 'flat' || style === 'both'; + } + + protected get onPreferenceChangedEmitter(): Emitter> { + if (!this._onPreferenceChangedEmitter) { + this._onPreferenceChangedEmitter = new Emitter(); + this.subscribeToChangeEvents(); + this.toDispose.push(this._onPreferenceChangedEmitter); + } + return this._onPreferenceChangedEmitter; + } + + get onPreferenceChanged(): Event> { + return this.onPreferenceChangedEmitter.event; + } + + @postConstruct() + protected init(): void { + if (this.promisedSchema instanceof Promise) { + this.promisedSchema.then(schema => this.schema = schema); + } else { + this.schema = this.promisedSchema; + } + } + + get(target: unknown, property: string, receiver: unknown): unknown { + if (typeof property !== 'string') { throw new Error(`Unexpected property: ${String(property)}`); } + const preferenceName = this.prefix + property; + if (this.schema && (this.isFlat || !property.includes('.')) && this.schema.properties[preferenceName]) { + const { overrideIdentifier } = this; + const toGet = overrideIdentifier ? this.preferences.overridePreferenceName({ overrideIdentifier, preferenceName }) : preferenceName; + return this.getValue(toGet as keyof T & string, undefined as any); // eslint-disable-line @typescript-eslint/no-explicit-any + } + switch (property) { + case 'onPreferenceChanged': + return this.onPreferenceChanged; + case 'dispose': + return this.dispose.bind(this); + case 'ready': + return Promise.all([this.preferences.ready, this.promisedSchema]).then(() => undefined); + case 'get': + return this.getValue.bind(this); + case 'toJSON': + return this.toJSON.bind(this); + case 'ownKeys': + return this.ownKeys.bind(this); + } + if (this.schema && this.isDeep) { + const prefix = `${preferenceName}.`; + if (Object.keys(this.schema.properties).some(key => key.startsWith(prefix))) { + const { style, resourceUri, overrideIdentifier } = this; + return this.factory(this.schema, { prefix, resourceUri, style, overrideIdentifier }); + } + let value: any; // eslint-disable-line @typescript-eslint/no-explicit-any + let parentSegment = preferenceName; + const segments = []; + do { + const index = parentSegment.lastIndexOf('.'); + segments.push(parentSegment.substring(index + 1)); + parentSegment = parentSegment.substring(0, index); + if (parentSegment in this.schema.properties) { + value = this.get(target, parentSegment, receiver); + } + } while (parentSegment && value === undefined); + + let segment; + while (typeof value === 'object' && (segment = segments.pop())) { + value = value[segment]; + } + return segments.length ? undefined : value; + } + } + + set(target: unknown, property: string, value: unknown, receiver: unknown): boolean { + if (typeof property !== 'string') { + throw new Error(`Unexpected property: ${String(property)}`); + } + const { style, schema, prefix, resourceUri, overrideIdentifier } = this; + if (style === 'deep' && property.indexOf('.') !== -1) { + return false; + } + if (schema) { + const fullProperty = prefix ? prefix + property : property; + if (schema.properties[fullProperty]) { + this.preferences.set(fullProperty, value, PreferenceScope.Default); + return true; + } + const newPrefix = fullProperty + '.'; + for (const p of Object.keys(schema.properties)) { + if (p.startsWith(newPrefix)) { + const subProxy = this.factory(schema, { + prefix: newPrefix, + resourceUri, + overrideIdentifier, + style + }) as any; // eslint-disable-line @typescript-eslint/no-explicit-any + const valueAsContainer = value as T; + for (const k of Object.keys(valueAsContainer)) { + subProxy[k as keyof T] = valueAsContainer[k as keyof T]; + } + } + } + } + return false; + } + + ownKeys(): string[] { + const properties = []; + if (this.schema) { + const { isDeep, isFlat, prefix } = this; + for (const property of Object.keys(this.schema.properties)) { + if (property.startsWith(prefix)) { + const idx = property.indexOf('.', prefix.length); + if (idx !== -1 && isDeep) { + const pre = property.substring(prefix.length, idx); + if (properties.indexOf(pre) === -1) { + properties.push(pre); + } + } + const prop = property.substring(prefix.length); + if (isFlat || prop.indexOf('.') === -1) { + properties.push(prop); + } + } + } + } + return properties; + } + + getOwnPropertyDescriptor(target: unknown, property: string): PropertyDescriptor { + if (this.ownKeys().includes(property)) { + return { + enumerable: true, + configurable: true + }; + } + return {}; + } + + deleteProperty(): never { + throw new Error('Unsupported operation'); + } + + defineProperty(): never { + throw new Error('Unsupported operation'); + } + + toJSON(): JSONValue { + const result: JSONValue = {}; + for (const key of this.ownKeys()) { + result[key] = this.get(undefined, key, undefined) as JSONValue; + } + return result; + }; + + protected subscribeToChangeEvents(): void { + this.toDispose.push(this.preferences.onPreferencesChanged(changes => this.handlePreferenceChanges(changes))); + } + + protected handlePreferenceChanges(changes: PreferenceChanges): void { + if (this.schema) { + for (const change of Object.values(changes)) { + const overrideInfo = this.preferences.overriddenPreferenceName(change.preferenceName); + if (this.isRelevantChange(change, overrideInfo)) { + this.fireChangeEvent(this.buildNewChangeEvent(change, overrideInfo)); + } + } + } + } + + protected isRelevantChange(change: PreferenceChange, overrideInfo?: OverridePreferenceName): boolean { + const preferenceName = overrideInfo?.preferenceName ?? change.preferenceName; + return preferenceName.startsWith(this.prefix) + && (!this.overrideIdentifier || overrideInfo?.overrideIdentifier === this.overrideIdentifier) + && Boolean(this.schema?.properties[preferenceName]); + } + + protected fireChangeEvent(change: PreferenceChangeEvent): void { + this.onPreferenceChangedEmitter.fire(change); + } + + protected buildNewChangeEvent(change: PreferenceProviderDataChange, overrideInfo?: OverridePreferenceName): PreferenceChangeEvent { + const preferenceName = (overrideInfo?.preferenceName ?? change.preferenceName) as keyof T & string; + const { newValue, oldValue, scope, domain } = change; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new PreferenceProxyChange({ newValue, oldValue, preferenceName, scope, domain }, overrideInfo?.overrideIdentifier) as any; + } + + protected getValue( + preferenceIdentifier: K | OverridePreferenceName & { preferenceName: K }, defaultValue: T[K], resourceUri = this.resourceUri + ): T[K] { + const preferenceName = OverridePreferenceName.is(preferenceIdentifier) ? this.preferences.overridePreferenceName(preferenceIdentifier) : preferenceIdentifier as string; + return this.preferences.get(preferenceName, defaultValue, resourceUri); + } + + dispose(): void { + if (this.options.isDisposable) { + this.toDispose.dispose(); + } + } +} diff --git a/packages/core/src/browser/preferences/preference-contribution.ts b/packages/core/src/browser/preferences/preference-contribution.ts index 4d1465311fb46..9aeb5edb5bda8 100644 --- a/packages/core/src/browser/preferences/preference-contribution.ts +++ b/packages/core/src/browser/preferences/preference-contribution.ts @@ -28,6 +28,7 @@ import { bindPreferenceConfigurations, PreferenceConfigurations } from './prefer export { PreferenceSchema, PreferenceSchemaProperties, PreferenceDataSchema, PreferenceItem, PreferenceSchemaProperty, PreferenceDataProperty, JsonType }; import { Mutable } from '../../common/types'; import { OverridePreferenceName, PreferenceLanguageOverrideService } from './preference-language-override-service'; +import { JSONValue } from '@phosphor/coreutils'; /** * @deprecated since 1.13.0 import from @theia/core/lib/browser/preferences/preference-language-override-service. @@ -218,10 +219,12 @@ export class PreferenceSchemaProvider extends PreferenceProvider { schemaProps.defaultValue = PreferenceSchemaProperties.is(configuredDefault) ? PreferenceProvider.merge(schemaDefault, configuredDefault) : schemaDefault; - for (const overriddenPreferenceName in schemaProps.defaultValue) { - const overrideValue = schemaDefault[overriddenPreferenceName]; - const overridePreferenceName = `${preferenceName}.${overriddenPreferenceName}`; - changes.push(this.doSetPreferenceValue(overridePreferenceName, overrideValue, { scope, domain })); + if (schemaProps.defaultValue && PreferenceSchemaProperties.is(schemaProps.defaultValue)) { + for (const overriddenPreferenceName in schemaProps.defaultValue) { + const overrideValue = schemaDefault[overriddenPreferenceName]; + const overridePreferenceName = `${preferenceName}.${overriddenPreferenceName}`; + changes.push(this.doSetPreferenceValue(overridePreferenceName, overrideValue, { scope, domain })); + } } } else { schemaProps.defaultValue = configuredDefault === undefined ? schemaDefault : configuredDefault; @@ -241,7 +244,7 @@ export class PreferenceSchemaProvider extends PreferenceProvider { return { preferenceName, oldValue, newValue, scope, domain }; } - protected getDefaultValue(property: PreferenceItem): any { + getDefaultValue(property: PreferenceItem): JSONValue { if (property.defaultValue !== undefined) { return property.defaultValue; } diff --git a/packages/core/src/browser/preferences/preference-provider.ts b/packages/core/src/browser/preferences/preference-provider.ts index cb17aa29015df..df019c0ac2119 100644 --- a/packages/core/src/browser/preferences/preference-provider.ts +++ b/packages/core/src/browser/preferences/preference-provider.ts @@ -26,10 +26,25 @@ import { PreferenceScope } from './preference-scope'; import { PreferenceLanguageOverrideService } from './preference-language-override-service'; export interface PreferenceProviderDataChange { + /** + * The name of the changed preference. + */ readonly preferenceName: string; + /** + * The new value of the changed preference. + */ readonly newValue?: any; + /** + * The old value of the changed preference. + */ readonly oldValue?: any; + /** + * The {@link PreferenceScope} of the changed preference. + */ readonly scope: PreferenceScope; + /** + * URIs of the scopes in which this change applies. + */ readonly domain?: string[]; } diff --git a/packages/core/src/browser/preferences/preference-proxy.spec.ts b/packages/core/src/browser/preferences/preference-proxy.spec.ts index 66435831078d0..072b82a4b3aad 100644 --- a/packages/core/src/browser/preferences/preference-proxy.spec.ts +++ b/packages/core/src/browser/preferences/preference-proxy.spec.ts @@ -30,8 +30,10 @@ import { PreferenceSchemaProvider, PreferenceSchema } from './preference-contrib import { PreferenceScope } from './preference-scope'; import { PreferenceProvider } from './preference-provider'; import { FrontendApplicationConfigProvider } from '../frontend-application-config-provider'; -import { createPreferenceProxy, PreferenceProxyOptions, PreferenceProxy, PreferenceChangeEvent } from './preference-proxy'; +import { PreferenceProxyOptions, PreferenceProxy, PreferenceChangeEvent, createPreferenceProxy } from './preference-proxy'; import { ApplicationProps } from '@theia/application-package/lib/application-props'; +import { PreferenceProxyFactory } from './injectable-preference-proxy'; +import { waitForEvent } from '../../common/promise-util'; disableJSDOM(); @@ -40,7 +42,9 @@ process.on('unhandledRejection', (reason, promise) => { throw reason; }); -const { expect } = require('chai'); +import { expect } from 'chai'; +import { PreferenceValidationService } from '.'; +import { JSONValue } from '@phosphor/coreutils'; let testContainer: Container; function createTestContainer(): Container { @@ -53,6 +57,7 @@ function createTestContainer(): Container { describe('Preference Proxy', () => { let prefService: PreferenceServiceImpl; let prefSchema: PreferenceSchemaProvider; + let validator: PreferenceValidationService; before(() => { disableJSDOM = enableJSDOM(); @@ -70,6 +75,7 @@ describe('Preference Proxy', () => { testContainer = createTestContainer(); prefSchema = testContainer.get(PreferenceSchemaProvider); prefService = testContainer.get(PreferenceService) as PreferenceServiceImpl; + validator = testContainer.get(PreferenceValidationService); getProvider(PreferenceScope.User).markReady(); getProvider(PreferenceScope.Workspace).markReady(); getProvider(PreferenceScope.Folder).markReady(); @@ -84,14 +90,16 @@ describe('Preference Proxy', () => { }); // Actually run the test suite with different parameters: - testPreferenceProxy('Synchronous Schema Definition', { asyncSchema: false }); - testPreferenceProxy('Asynchronous Schema Definition (1s delay)', { asyncSchema: true }); + testPreferenceProxy('Synchronous Schema Definition + createPreferenceProxy', { asyncSchema: false }); + testPreferenceProxy('Asynchronous Schema Definition (1s delay) + createPreferenceProxy', { asyncSchema: true }); + testPreferenceProxy('Synchronous Schema Definition + Injectable Preference Proxy', { asyncSchema: false, useFactory: true }); + testPreferenceProxy('Asynchronous Schema Definition (1s delay) + Injectable Preference Proxy', { asyncSchema: true, useFactory: true }); function getProvider(scope: PreferenceScope): MockPreferenceProvider { return testContainer.getNamed(PreferenceProvider, scope) as MockPreferenceProvider; } - function testPreferenceProxy(testDescription: string, testOptions: { asyncSchema: boolean }): void { + function testPreferenceProxy(testDescription: string, testOptions: { asyncSchema: boolean, useFactory?: boolean }): void { describe(testDescription, () => { @@ -113,11 +121,15 @@ describe('Preference Proxy', () => { prefSchema.setSchema(s); resolve(s); }, 1000)); - const proxy = createPreferenceProxy(prefService, promisedSchema, options); + const proxy = (testOptions.useFactory || options?.validated) + ? testContainer.get(PreferenceProxyFactory)(promisedSchema, options) + : createPreferenceProxy(prefService, promisedSchema, options); return { proxy, promisedSchema }; } else { prefSchema.setSchema(s); - const proxy = createPreferenceProxy(prefService, s, options); + const proxy = (testOptions.useFactory || options?.validated) + ? testContainer.get(PreferenceProxyFactory)(s, options) + : createPreferenceProxy(prefService, s, options); return { proxy }; } } @@ -147,7 +159,7 @@ describe('Preference Proxy', () => { }); } - it('by default, it should get provide access in flat style but not deep', async () => { + it('by default, it should provide access in flat style but not deep', async () => { const { proxy, promisedSchema } = getProxy(); if (promisedSchema) { await promisedSchema; @@ -157,17 +169,17 @@ describe('Preference Proxy', () => { expect(Object.keys(proxy).join()).to.equal(['my.pref'].join()); }); - it('it should get provide access in deep style but not flat', async () => { + it('it should provide access in deep style but not flat', async () => { const { proxy, promisedSchema } = getProxy(undefined, { style: 'deep' }); if (promisedSchema) { await promisedSchema; } expect(proxy['my.pref']).to.equal(undefined); expect(proxy.my.pref).to.equal('foo'); - expect(Object.keys(proxy).join()).to.equal(['my'].join()); + expect(Object.keys(proxy).join()).equal('my'); }); - it('it should get provide access in to both styles', async () => { + it('it should provide access in to both styles', async () => { const { proxy, promisedSchema } = getProxy(undefined, { style: 'both' }); if (promisedSchema) { await promisedSchema; @@ -203,6 +215,126 @@ describe('Preference Proxy', () => { expect(theSecondChange!.preferenceName).to.equal('my.pref'); }); + it("should not forward changes that don't match the proxy's language override", async () => { + const { proxy, promisedSchema } = getProxy({ + properties: { + 'my.pref': { + type: 'string', + defaultValue: 'foo', + overridable: true, + } + } + }, { style: 'both', overrideIdentifier: 'typescript' }); + await promisedSchema; + let changeEventsEmittedByProxy = 0; + let changeEventsEmittedByService = 0; + prefSchema.registerOverrideIdentifier('swift'); + prefSchema.registerOverrideIdentifier('typescript'); + // The service will emit events related to updating the overrides - those are irrelevant + await waitForEvent(prefService.onPreferencesChanged, 500); + prefService.onPreferencesChanged(() => changeEventsEmittedByService++); + proxy.onPreferenceChanged(() => changeEventsEmittedByProxy++); + await prefService.set(prefService.overridePreferenceName({ overrideIdentifier: 'swift', preferenceName: 'my.pref' }), 'boo', PreferenceScope.User); + expect(changeEventsEmittedByService, 'The service should have emitted an event for the non-matching override.').to.equal(1); + expect(changeEventsEmittedByProxy, 'The proxy should not have emitted an event for the non-matching override.').to.equal(0); + await prefService.set('my.pref', 'far', PreferenceScope.User); + expect(changeEventsEmittedByService, 'The service should have emitted an event for the base name.').to.equal(2); + expect(changeEventsEmittedByProxy, 'The proxy should have emitted for an event for the base name.').to.equal(1); + await prefService.set(prefService.overridePreferenceName({ preferenceName: 'my.pref', overrideIdentifier: 'typescript' }), 'faz', PreferenceScope.User); + expect(changeEventsEmittedByService, 'The service should have emitted an event for the matching override.').to.equal(3); + expect(changeEventsEmittedByProxy, 'The proxy should have emitted an event for the matching override.').to.equal(2); + await prefService.set('my.pref', 'yet another value', PreferenceScope.User); + expect(changeEventsEmittedByService, 'The service should have emitted another event for the base name.').to.equal(4); + expect(changeEventsEmittedByProxy, 'The proxy should not have emitted an event, because the value for TS has been overridden.').to.equal(2); + }); + + it('`affects` should only return `true` if the language overrides match', async () => { + const { proxy, promisedSchema } = getProxy({ + properties: { + 'my.pref': { + type: 'string', + defaultValue: 'foo', + overridable: true, + } + } + }, { style: 'both' }); + await promisedSchema; + prefSchema.registerOverrideIdentifier('swift'); + prefSchema.registerOverrideIdentifier('typescript'); + let changesNotAffectingTypescript = 0; + let changesAffectingTypescript = 0; + proxy.onPreferenceChanged(change => { + if (change.affects(undefined, 'typescript')) { + changesAffectingTypescript++; + } else { + changesNotAffectingTypescript++; + } + }); + await prefService.set('my.pref', 'bog', PreferenceScope.User); + expect(changesNotAffectingTypescript, 'Two events (one for `my.pref` and one for `[swift].my.pref`) should not have affected TS').to.equal(2); + expect(changesAffectingTypescript, 'One event should have been fired that does affect typescript.').to.equal(1); + }); + + if (testOptions.useFactory) { + async function prepareValidationTest(): Promise<{ proxy: PreferenceProxy<{ [key: string]: unknown }>, validationCallCounter: { calls: number } }> { + const validationCallCounter = { calls: 0 }; + const originalValidateByName = validator.validateByName.bind(validator); + function newValidateByName(...args: unknown[]): JSONValue { + validationCallCounter.calls++; + return originalValidateByName(...args); + }; + validator.validateByName = newValidateByName; + const { proxy, promisedSchema } = getProxy({ + properties: { + 'my.pref': { + type: 'string', + defaultValue: 'foo', + overridable: true, + } + } + }, { style: 'both', validated: true }); + await promisedSchema; + return { proxy, validationCallCounter }; + } + + it('Validated proxies always return good values.', async () => { + const { proxy, validationCallCounter } = await prepareValidationTest(); + let event: PreferenceChangeEvent<{ [key: string]: unknown }> | undefined = undefined; + proxy.onPreferenceChanged(change => event = change); + expect(proxy['my.pref']).to.equal('foo', 'Should start with default value.'); + expect(validationCallCounter.calls).to.equal(1, 'Should have validated preference retrieval.'); + expect(proxy.get('my.pref')).to.equal('foo', 'Should have default value for `get`.'); + expect(validationCallCounter.calls).to.equal(1, 'Should have cached first validation.'); + const newValue = 'Also a string'; + await prefService.set('my.pref', newValue, PreferenceScope.User); + expect(event !== undefined); + expect(event!.newValue).to.equal(newValue, 'Should accept good value'); + expect(validationCallCounter.calls).to.equal(2, 'Should have validated event value'); + expect(proxy['my.pref']).to.equal(newValue, 'Should return default value on access.'); + expect(proxy.get('my.pref')).to.equal(newValue); + expect(validationCallCounter.calls).to.equal(2, 'Should have used cached value for retrievals'); + await prefService.set('my.pref', { complete: 'garbage' }, PreferenceScope.User); + expect(event !== undefined); + expect(event!.newValue).to.equal('foo', 'Should have fallen back to default.'); + expect(validationCallCounter.calls).to.equal(3, 'Should have validated event'); + expect(proxy['my.pref']).to.equal('foo', 'Should return default value on access.'); + expect(proxy.get('my.pref')).to.equal('foo'); + expect(validationCallCounter.calls).to.equal(3, 'Should have used cached value for retrievals'); + }); + + it('Validated proxies only validate one value if multiple language-override events are emitted for the same change', async () => { + const { proxy, validationCallCounter } = await prepareValidationTest(); + prefSchema.registerOverrideIdentifier('swift'); + prefSchema.registerOverrideIdentifier('typescript'); + const events: Array> = []; + proxy.onPreferenceChanged(event => events.push(event)); + await prefService.set('my.pref', { complete: 'garbage' }, PreferenceScope.User); + expect(validationCallCounter.calls, 'Validation should have been performed once.').to.equal(1); + expect(events).to.have.length(3, 'One event for base, one for each override'); + expect(events.every(event => event.newValue === 'foo'), 'Should have returned the default in case of garbage.'); + }); + } + it('toJSON with deep', async () => { const { proxy, promisedSchema } = getProxy({ properties: { diff --git a/packages/core/src/browser/preferences/preference-proxy.ts b/packages/core/src/browser/preferences/preference-proxy.ts index bcbe2827b96a9..bbeb13eca716b 100644 --- a/packages/core/src/browser/preferences/preference-proxy.ts +++ b/packages/core/src/browser/preferences/preference-proxy.ts @@ -119,6 +119,7 @@ export interface PreferenceRetrieval { * ``` */ export type PreferenceProxy = Readonly & Disposable & PreferenceEventEmitter & PreferenceRetrieval; +export const PreferenceProxyOptions = Symbol('PreferenceProxyOptions'); /** * Proxy configuration parameters. */ @@ -146,6 +147,14 @@ export interface PreferenceProxyOptions { * When 'deep' or 'both' is given, nested preference proxies can be retrieved. */ style?: 'flat' | 'deep' | 'both'; + /** + * Indicates whether the proxy should be disposable. Proxies that are shared between multiple callers should not be disposable. + */ + isDisposable?: boolean; + /** + * Indicates whether the proxy will validate values before returning them to clients. + */ + validated?: boolean; } /** @@ -166,6 +175,8 @@ export interface PreferenceProxyOptions { * See {@link CorePreferences} for an example. * * Note that if `schema` is a Promise, most actions will be no-ops until the promise is resolved. + * + * @deprecated @since 1.23.0 use `PreferenceProxyFactory` instead. */ export function createPreferenceProxy(preferences: PreferenceService, promisedSchema: MaybePromise, options?: PreferenceProxyOptions): PreferenceProxy { const opts = options || {}; @@ -185,16 +196,14 @@ export function createPreferenceProxy(preferences: PreferenceService, promise const e = changes[key]; const overridden = preferences.overriddenPreferenceName(e.preferenceName); const preferenceName: any = overridden ? overridden.preferenceName : e.preferenceName; - if (preferenceName.startsWith(prefix) && (!overridden || !opts.overrideIdentifier || overridden.overrideIdentifier === opts.overrideIdentifier)) { + if (preferenceName.startsWith(prefix) && (!opts.overrideIdentifier || overridden?.overrideIdentifier === opts.overrideIdentifier)) { if (schema.properties[preferenceName]) { const { newValue, oldValue } = e; listener({ newValue, oldValue, preferenceName, affects: (resourceUri, overrideIdentifier) => { - if (overrideIdentifier !== undefined) { - if (overridden && overridden.overrideIdentifier !== overrideIdentifier) { - return false; - } + if (overrideIdentifier !== overridden?.overrideIdentifier) { + return false; } return e.affects(resourceUri); } diff --git a/packages/core/src/browser/preferences/preference-service.ts b/packages/core/src/browser/preferences/preference-service.ts index df9929f1275c2..6688daa30446f 100644 --- a/packages/core/src/browser/preferences/preference-service.ts +++ b/packages/core/src/browser/preferences/preference-service.ts @@ -33,23 +33,7 @@ export { PreferenceScope }; * Representation of a preference change. A preference value can be set to `undefined` for a specific scope. * This means that the value from a more general scope will be used. */ -export interface PreferenceChange { - /** - * The name of the changed preference. - */ - readonly preferenceName: string; - /** - * The new value of the changed preference. - */ - readonly newValue?: any; - /** - * The old value of the changed preference. - */ - readonly oldValue?: any; - /** - * The {@link PreferenceScope} of the changed preference. - */ - readonly scope: PreferenceScope; +export interface PreferenceChange extends PreferenceProviderDataChange { /** * Tests wether the given resource is affected by the preference change. * @param resourceUri the uri of the resource to test. @@ -58,9 +42,10 @@ export interface PreferenceChange { } export class PreferenceChangeImpl implements PreferenceChange { - constructor( - private change: PreferenceProviderDataChange - ) { } + protected readonly change: PreferenceProviderDataChange; + constructor(change: PreferenceProviderDataChange) { + this.change = deepFreeze(change); + } get preferenceName(): string { return this.change.preferenceName; @@ -74,6 +59,9 @@ export class PreferenceChangeImpl implements PreferenceChange { get scope(): PreferenceScope { return this.change.scope; } + get domain(): string[] | undefined { + return this.change.domain; + } // TODO add tests affects(resourceUri?: string): boolean { diff --git a/packages/core/src/browser/preferences/preference-validation-service.spec.ts b/packages/core/src/browser/preferences/preference-validation-service.spec.ts new file mode 100644 index 0000000000000..b4d629b7ef745 --- /dev/null +++ b/packages/core/src/browser/preferences/preference-validation-service.spec.ts @@ -0,0 +1,275 @@ +/******************************************************************************** + * Copyright (C) 2022 Ericsson 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 { Container } from 'inversify'; +import { PreferenceValidationService } from './preference-validation-service'; +import { JsonType, PreferenceItem, PreferenceSchemaProvider } from './preference-contribution'; +import { PreferenceLanguageOverrideService } from './preference-language-override-service'; +import * as assert from 'assert'; +import { JSONValue } from '@phosphor/coreutils'; + +/* eslint-disable no-unused-expressions,no-null/no-null */ + +describe('Preference Validation Service', () => { + const container = new Container(); + container.bind(PreferenceSchemaProvider).toConstantValue({ getDefaultValue: PreferenceSchemaProvider.prototype.getDefaultValue } as PreferenceSchemaProvider); + container.bind(PreferenceLanguageOverrideService).toSelf().inSingletonScope(); + const validator = container.resolve(PreferenceValidationService); + const validateBySchema: (value: JSONValue, schema: PreferenceItem) => JSONValue = validator.validateBySchema.bind(validator, 'dummy'); + + describe('should validate strings', () => { + const expected = 'expected'; + it('good input -> should return the same string', () => { + const actual = validateBySchema(expected, { type: 'string' }); + assert.strictEqual(actual, expected); + }); + it('bad input -> should return the default', () => { + const actual = validateBySchema(3, { type: 'string', default: expected }); + assert.strictEqual(actual, expected); + }); + it('bad input -> should return string even if default is not a string', () => { + const actual = validateBySchema(3, { type: 'string', default: 3 }); + assert.strictEqual(typeof actual, 'string'); + assert.strictEqual(actual, '3'); + }); + it('bad input -> should return an empty string if no default', () => { + const actual = validateBySchema(3, { type: 'string' }); + assert.strictEqual(actual, ''); + }); + }); + describe('should validate numbers', () => { + const expected = 1.23; + it('good input -> should return the same number', () => { + const actual = validateBySchema(expected, { type: 'number' }); + assert.strictEqual(actual, expected); + }); + it('bad input -> should return the default', () => { + const actual = validateBySchema('zxy', { type: 'number', default: expected }); + assert.strictEqual(actual, expected); + }); + it('bad input -> should return a number even if the default is not a number', () => { + const actual = validateBySchema('zxy', { type: 'number', default: ['fun array'] }); + assert.strictEqual(actual, 0); + }); + it('bad input -> should return 0 if no default', () => { + const actual = validateBySchema('zxy', { type: 'number' }); + assert.strictEqual(actual, 0); + }); + it('should do its best to make a number of a string', () => { + const actual = validateBySchema(expected.toString(), { type: 'number' }); + assert.strictEqual(actual, expected); + }); + it('should return the max if input is greater than max', () => { + const maximum = 50; + const actual = validateBySchema(100, { type: 'number', maximum }); + assert.strictEqual(actual, maximum); + }); + it('should return the minimum if input is less than minimum', () => { + const minimum = 30; + const actual = validateBySchema(15, { type: 'number', minimum }); + assert.strictEqual(actual, minimum); + }); + }); + describe('should validate integers', () => { + const expected = 2; + it('good input -> should return the same number', () => { + const actual = validateBySchema(expected, { type: 'integer' }); + assert.strictEqual(actual, expected); + }); + it('bad input -> should return the default', () => { + const actual = validateBySchema('zxy', { type: 'integer', default: expected }); + assert.strictEqual(actual, expected); + }); + it('bad input -> should return 0 if no default', () => { + const actual = validateBySchema('zxy', { type: 'integer' }); + assert.strictEqual(actual, 0); + }); + it('should round a non-integer', () => { + const actual = validateBySchema(1.75, { type: 'integer' }); + assert.strictEqual(actual, expected); + }); + }); + describe('should validate booleans', () => { + it('good input -> should return the same value', () => { + assert.strictEqual(validateBySchema(true, { type: 'boolean' }), true); + assert.strictEqual(validateBySchema(false, { type: 'boolean' }), false); + }); + it('bad input -> should return the default', () => { + const actual = validateBySchema(['not a boolean!'], { type: 'boolean', default: true }); + assert.strictEqual(actual, true); + }); + it('bad input -> should return false if no default', () => { + const actual = validateBySchema({ isBoolean: 'no' }, { type: 'boolean' }); + assert.strictEqual(actual, false); + }); + it('should treat string "true" and "false" as equivalent to booleans', () => { + assert.strictEqual(validateBySchema('true', { type: 'boolean' }), true); + assert.strictEqual(validateBySchema('false', { type: 'boolean' }), false); + }); + }); + describe('should validate null', () => { + it('should always just return null', () => { + assert.strictEqual(validateBySchema({ whatever: ['anything'] }, { type: 'null' }), null); + assert.strictEqual(validateBySchema('not null', { type: 'null' }), null); + assert.strictEqual(validateBySchema(123, { type: 'null', default: 123 }), null); + }); + }); + describe('should validate enums', () => { + const expected = 'expected'; + const defaultValue = 'default'; + const options = [expected, defaultValue, 'other-value']; + it('good value -> should return same value', () => { + const actual = validateBySchema(expected, { enum: options }); + assert.strictEqual(actual, expected); + }); + it('bad value -> should return default value', () => { + const actual = validateBySchema('not-in-enum', { enum: options, defaultValue }); + assert.strictEqual(actual, defaultValue); + }); + it('bad value -> should return first value if no default or bad default', () => { + const noDefault = validateBySchema(['not-in-enum'], { enum: options }); + assert.strictEqual(noDefault, expected); + const badDefault = validateBySchema({ inEnum: false }, { enum: options, default: 'not-in-enum' }); + assert.strictEqual(badDefault, expected); + }); + }); + describe('should validate objects', () => { + it('should reject non object types', () => { + const schema = { type: 'object' } as const; + assert.deepStrictEqual(validateBySchema(null, schema), {}); + assert.deepStrictEqual(validateBySchema('null', schema), {}); + assert.deepStrictEqual(validateBySchema(3, schema), {}); + }); + it('should reject objects that are missing required fields', () => { + const schema: PreferenceItem = { type: 'object', properties: { 'required': { type: 'string' }, 'not-required': { type: 'number' } }, required: ['required'] }; + assert.deepStrictEqual(validateBySchema({ 'not-required': 3 }, schema), {}); + const defaultValue = { required: 'present' }; + assert.deepStrictEqual(validateBySchema({ 'not-required': 3 }, { ...schema, defaultValue }), defaultValue); + }); + it('should reject objects that have impermissible extra properties', () => { + const schema: PreferenceItem = { type: 'object', properties: { 'required': { type: 'string' } }, additionalProperties: false }; + assert.deepStrictEqual(validateBySchema({ 'required': 'hello', 'not-required': 3 }, schema), {}); + }); + it('should accept objects with extra properties if extra properties are not forbidden', () => { + const input = { 'required': 'hello', 'not-forbidden': 3 }; + const schema: PreferenceItem = { type: 'object', properties: { 'required': { type: 'string' } }, additionalProperties: true }; + assert.deepStrictEqual(validateBySchema(input, schema), input); + assert.deepStrictEqual(validateBySchema(input, { ...schema, additionalProperties: undefined }), input); + }); + it("should reject objects with properties that violate the property's rules", () => { + const input = { required: 'not-a-number!' }; + const schema: PreferenceItem = { type: 'object', properties: { required: { type: 'number' } } }; + assert.deepStrictEqual(validateBySchema(input, schema), {}); + }); + it('should reject objects with extra properties that violate the extra property rules', () => { + const input = { required: 3, 'not-required': 'not-a-number!' }; + const schema: PreferenceItem = { type: 'object', properties: { required: { type: 'number' } }, additionalProperties: { type: 'number' } }; + assert.deepStrictEqual(validateBySchema(input, schema), {}); + }); + }); + describe('should validate arrays', () => { + const expected = ['one-string', 'two-string']; + it('good input -> should return same value', () => { + const actual = validateBySchema(expected, { type: 'array', items: { type: 'string' } }); + assert.deepStrictEqual(actual, expected); + const augmentedExpected = [3, ...expected, 4]; + const augmentedActual = validateBySchema(augmentedExpected, { type: 'array', items: { type: ['number', 'string'] } }); + assert.deepStrictEqual(augmentedActual, augmentedExpected); + }); + it('bad input -> should filter out impermissible items', () => { + const actual = validateBySchema([3, ...expected, []], { type: 'array', items: { type: 'string' } }); + assert.deepStrictEqual(actual, expected); + }); + }); + describe('should validate type arrays', () => { + const type: JsonType[] = ['boolean', 'string', 'number']; + it('good input -> returns same value', () => { + const goodBoolean = validateBySchema(true, { type }); + assert.strictEqual(goodBoolean, true); + const goodString = validateBySchema('string', { type }); + assert.strictEqual(goodString, 'string'); + const goodNumber = validateBySchema(1.23, { type }); + assert.strictEqual(goodNumber, 1.23); + }); + it('bad input -> returns default if default valid', () => { + const stringDefault = 'default'; + const booleanDefault = true; + const numberDefault = 100; + assert.strictEqual(validateBySchema([], { type, default: stringDefault }), stringDefault); + assert.strictEqual(validateBySchema([], { type, default: booleanDefault }), booleanDefault); + assert.strictEqual(validateBySchema([], { type, default: numberDefault }), numberDefault); + }); + it("bad input -> returns first validator's result if no default or bad default", () => { + assert.strictEqual(validateBySchema([], { type }), false); + assert.strictEqual(validateBySchema([], { type, default: {} }), false); + }); + }); + describe('should validate anyOfs', () => { + const schema: PreferenceItem = { anyOf: [{ type: 'number', minimum: 1 }, { type: 'array', items: { type: 'string' } }], default: 5 }; + it('good input -> returns same value', () => { + assert.strictEqual(validateBySchema(3, schema), 3); + const goodArray = ['a string', 'here too']; + assert.strictEqual(validateBySchema(goodArray, schema), goodArray); + }); + it('bad input -> returns default if present and valid', () => { + assert.strictEqual(validateBySchema({}, schema), 5); + }); + it('bad input -> first validator, if default absent or default ill-formed', () => { + assert.strictEqual(validateBySchema({}, { ...schema, default: 0 }), 1); + assert.strictEqual(validateBySchema({}, { ...schema, default: undefined }), 1); + }); + }); + describe('should maintain triple equality for valid object types', () => { + const arraySchema: PreferenceItem = { type: 'array', items: { type: 'string' } }; + it('maintains triple equality for arrays', () => { + const input = ['one-string', 'two-string']; + assert(validateBySchema(input, arraySchema) === input); + }); + it('does not maintain triple equality if the array is only partially correct', () => { + const input = ['one-string', 'two-string', 3]; + assert.notStrictEqual(validateBySchema(input, arraySchema), input); + }); + it('maintains triple equality for objects', () => { + const schema: PreferenceItem = { + 'type': 'object', + properties: { + primitive: { type: 'string' }, + complex: { type: 'object', properties: { nested: { type: 'number' } } } + } + }; + const input = { primitive: 'is a string', complex: { nested: 3 } }; + assert(validateBySchema(input, schema) === input); + }); + }); + it('should return the value if any error occurs', () => { + let wasCalled = false; + const originalValidator = validator['validateString']; + validator['validateString'] = () => { + wasCalled = true; + throw new Error('Only a test!'); + }; + const input = { shouldBeValid: false }; + const output = validateBySchema(input, { type: 'string' }); + assert(wasCalled); + assert(input === output); + validator['validateString'] = originalValidator; + }); + it('should return the same object if no validation possible', () => { + for (const input of ['whatever', { valid: 'hard to say' }, 234, ["no one knows if I'm not", 'so I am']]) { + assert(validateBySchema(input, {}) === input); + } + }); +}); diff --git a/packages/core/src/browser/preferences/preference-validation-service.ts b/packages/core/src/browser/preferences/preference-validation-service.ts new file mode 100644 index 0000000000000..b7e6711d1060a --- /dev/null +++ b/packages/core/src/browser/preferences/preference-validation-service.ts @@ -0,0 +1,280 @@ +/******************************************************************************** + * Copyright (C) 2022 Ericsson 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 { JsonType, PreferenceItem } from '../../common/preferences/preference-schema'; +import { JSONObject, JSONValue } from '../../../shared/@phosphor/coreutils'; +import { PreferenceSchemaProvider } from './preference-contribution'; +import { PreferenceLanguageOverrideService } from './preference-language-override-service'; +import { inject, injectable } from '../../../shared/inversify'; +import { IJSONSchema } from '../../common/json-schema'; +import { deepClone, unreachable } from '../../common'; +import { PreferenceProvider } from './preference-provider'; + +export interface PreferenceValidator { + name: string; + validate(value: unknown): T; +} + +export interface ValidatablePreference extends IJSONSchema, Pick { } +export type ValueValidator = (value: JSONValue) => JSONValue; + +export interface PreferenceValidationResult { + original: JSONValue | undefined; + valid: T; + messages: string[]; +} + +@injectable() +export class PreferenceValidationService { + @inject(PreferenceSchemaProvider) protected readonly schemaProvider: PreferenceSchemaProvider; + @inject(PreferenceLanguageOverrideService) protected readonly languageOverrideService: PreferenceLanguageOverrideService; + + validateOptions(options: Record): Record { + const valid: Record = {}; + let problemsDetected = false; + for (const [preferenceName, value] of Object.entries(options)) { + const validValue = this.validateByName(preferenceName, value); + if (validValue !== value) { + problemsDetected = true; + } + valid[preferenceName] = validValue; + } + return problemsDetected ? valid : options; + } + + validateByName(preferenceName: string, value: JSONValue): JSONValue { + const validValue = this.doValidateByName(preferenceName, value); + if (validValue !== value) { + console.warn(`While validating options, found impermissible value for ${preferenceName}. Using valid value`, validValue, 'instead of configured value', value); + } + return validValue; + } + + protected doValidateByName(preferenceName: string, value: JSONValue): JSONValue { + const schema = this.getSchema(preferenceName); + return this.validateBySchema(preferenceName, value, schema); + } + + validateBySchema(key: string, value: JSONValue, schema: ValidatablePreference | undefined): JSONValue { + try { + if (!schema) { + console.warn('Request to validate preference with no schema registered:', key); + return value; + } + if (Array.isArray(schema.enum)) { + return this.validateEnum(key, value, schema as ValidatablePreference & { enum: JSONValue[] }); + } + if (Array.isArray(schema.anyOf)) { + return this.validateAnyOf(key, value, schema as ValidatablePreference & { anyOf: ValidatablePreference[] }); + } + if (schema.type === undefined) { + console.warn('Request to validate preference with no type information:', key); + return value; + } + if (Array.isArray(schema.type)) { + return this.validateMultiple(key, value, schema as ValidatablePreference & { type: JsonType[] }); + } + switch (schema.type) { + case 'array': + return this.validateArray(key, value, schema); + case 'boolean': + return this.validateBoolean(key, value, schema); + case 'integer': + return this.validateInteger(key, value, schema); + case 'null': + return null; // eslint-disable-line no-null/no-null + case 'number': + return this.validateNumber(key, value, schema); + case 'object': + return this.validateObject(key, value, schema); + case 'string': + return this.validateString(key, value, schema); + default: + unreachable(schema.type, `Request to validate preference with unknown type in schema: ${key}`); + } + } catch (e) { + console.error('Encountered an error while validating', key, 'with value', value, 'against schema', schema, e); + return value; + } + } + + protected getSchema(name: string): ValidatablePreference | undefined { + const combinedSchema = this.schemaProvider.getCombinedSchema().properties; + if (combinedSchema[name]) { + return combinedSchema[name]; + } + const baseName = this.languageOverrideService.overriddenPreferenceName(name)?.preferenceName; + return baseName !== undefined ? combinedSchema[baseName] : undefined; + } + + protected validateMultiple(key: string, value: JSONValue, schema: ValidatablePreference & { type: JsonType[] }): JSONValue { + const validation: ValidatablePreference = deepClone(schema); + const candidate = this.mapValidators(key, value, (function* (this: PreferenceValidationService): Iterable { + for (const type of schema.type) { + validation.type = type as JsonType; + yield toValidate => this.validateBySchema(key, toValidate, validation); + } + }).bind(this)()); + if (candidate !== value && (schema.default !== undefined || schema.defaultValue !== undefined)) { + const configuredDefault = this.getDefaultFromSchema(schema); + return this.validateMultiple(key, configuredDefault, { ...schema, default: undefined, defaultValue: undefined }); + } + return candidate; + } + + protected validateAnyOf(key: string, value: JSONValue, schema: ValidatablePreference & { anyOf: ValidatablePreference[] }): JSONValue { + const candidate = this.mapValidators(key, value, (function* (this: PreferenceValidationService): Iterable { + for (const option of schema.anyOf) { + yield toValidate => this.validateBySchema(key, toValidate, option); + } + }).bind(this)()); + if (candidate !== value && (schema.default !== undefined || schema.defaultValue !== undefined)) { + const configuredDefault = this.getDefaultFromSchema(schema); + return this.validateAnyOf(key, configuredDefault, { ...schema, default: undefined, defaultValue: undefined }); + } + return candidate; + } + + protected mapValidators(key: string, value: JSONValue, validators: Iterable<(value: JSONValue) => JSONValue>): JSONValue { + const candidates = []; + for (const validator of validators) { + const candidate = validator(value); + if (candidate === value) { + return candidate; + } + candidates.push(candidate); + } + return candidates[0]; + } + + protected validateArray(key: string, value: JSONValue, schema: ValidatablePreference): JSONValue[] { + const candidate = Array.isArray(value) ? value : this.getDefaultFromSchema(schema); + if (!Array.isArray(candidate)) { + return []; + } + if (!schema.items || Array.isArray(schema.items)) { // Arrays were allowed in some draft of JSON schema, but never officially supported. + console.warn('Requested validation of array without item specification:', key); + return candidate; + } + const valid = []; + for (const item of candidate) { + const validated = this.validateBySchema(key, item, schema.items); + if (validated === item) { + valid.push(item); + } + } + return valid.length === candidate.length ? candidate : valid; + } + + protected validateEnum(key: string, value: JSONValue, schema: ValidatablePreference & { enum: JSONValue[] }): JSONValue { + const options = schema.enum; + if (options.some(option => PreferenceProvider.deepEqual(option, value))) { + return value; + } + const configuredDefault = this.getDefaultFromSchema(schema); + if (options.some(option => PreferenceProvider.deepEqual(option, configuredDefault))) { + return configuredDefault; + } + return options[0]; + } + + protected validateBoolean(key: string, value: JSONValue, schema: ValidatablePreference): boolean { + if (value === true || value === false) { + return value; + } + if (value === 'true') { + return true; + } + if (value === 'false') { + return false; + } + return Boolean(this.getDefaultFromSchema(schema)); + } + + protected validateInteger(key: string, value: JSONValue, schema: ValidatablePreference): number { + return Math.round(this.validateNumber(key, value, schema)); + } + + protected validateNumber(key: string, value: JSONValue, schema: ValidatablePreference): number { + let validated = Number(value); + if (isNaN(validated)) { + const configuredDefault = Number(this.getDefaultFromSchema(schema)); + validated = isNaN(configuredDefault) ? 0 : configuredDefault; + } + if (schema.minimum !== undefined) { + validated = Math.max(validated, schema.minimum); + } + if (schema.maximum !== undefined) { + validated = Math.min(validated, schema.maximum); + } + return validated; + } + + protected validateObject(key: string, value: JSONValue, schema: ValidatablePreference): JSONObject { + if (this.objectMatchesSchema(key, value, schema)) { + return value; + } + const configuredDefault = this.getDefaultFromSchema(schema); + if (this.objectMatchesSchema(key, configuredDefault, schema)) { + return configuredDefault; + } + return {}; + } + + // This evaluates most of the fields that commonly appear on PreferenceItem, but it could be improved to evaluate all possible JSON schema specifications. + protected objectMatchesSchema(key: string, value: JSONValue, schema: ValidatablePreference): value is JSONObject { + if (!value || typeof value !== 'object') { + return false; + } + if (schema.required && schema.required.some(requiredField => !(requiredField in value))) { + return false; + } + if (schema.additionalProperties === false && schema.properties && Object.keys(value).some(fieldKey => !(fieldKey in schema.properties!))) { + return false; + } + const additionalPropertyValidator = schema.additionalProperties !== true && !!schema.additionalProperties && schema.additionalProperties as IJSONSchema; + for (const [fieldKey, fieldValue] of Object.entries(value)) { + const fieldLabel = `${key}#${fieldKey}`; + if (schema.properties && fieldKey in schema.properties) { + const valid = this.validateBySchema(fieldLabel, fieldValue, schema.properties[fieldKey]); + if (valid !== fieldValue) { + return false; + } + } else if (additionalPropertyValidator) { + const valid = this.validateBySchema(fieldLabel, fieldValue, additionalPropertyValidator); + if (valid !== fieldValue) { + return false; + } + } + } + return true; + } + + protected validateString(key: string, value: JSONValue, schema: ValidatablePreference): string { + if (typeof value === 'string') { + return value; + } + if (value instanceof String) { + return value.toString(); + } + const configuredDefault = this.getDefaultFromSchema(schema); + return (configuredDefault ?? '').toString(); + } + + protected getDefaultFromSchema(schema: ValidatablePreference): JSONValue { + return this.schemaProvider.getDefaultValue(schema as PreferenceItem); + } +} diff --git a/packages/core/src/browser/preferences/validated-preference-proxy.ts b/packages/core/src/browser/preferences/validated-preference-proxy.ts new file mode 100644 index 0000000000000..20db64b66bfde --- /dev/null +++ b/packages/core/src/browser/preferences/validated-preference-proxy.ts @@ -0,0 +1,90 @@ +/******************************************************************************** + * Copyright (C) 2022 Ericsson 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 { JSONValue } from '@phosphor/coreutils'; +import { inject, injectable } from 'inversify'; +import { PreferenceValidationService } from '.'; +import { InjectablePreferenceProxy } from './injectable-preference-proxy'; +import { OverridePreferenceName } from './preference-language-override-service'; +import { PreferenceChanges } from './preference-service'; + +@injectable() +export class ValidatedPreferenceProxy> extends InjectablePreferenceProxy { + @inject(PreferenceValidationService) protected readonly validator: PreferenceValidationService; + + protected validPreferences: Map = new Map(); + + protected override handlePreferenceChanges(changes: PreferenceChanges): void { + if (this.schema) { + interface TrackedOverrides { value?: JSONValue, overrides: OverridePreferenceName[] }; + const overrideTracker: Map = new Map(); + for (const change of Object.values(changes)) { + const overridden = this.preferences.overriddenPreferenceName(change.preferenceName); + if (this.isRelevantChange(change, overridden)) { + let doSet = false; + const baseName = overridden?.preferenceName ?? change.preferenceName; + const tracker: TrackedOverrides = overrideTracker.get(baseName) ?? (doSet = true, { value: undefined, overrides: [] }); + if (overridden) { + tracker.overrides.push(overridden); + } else { + tracker.value = change.newValue; + } + if (doSet) { + overrideTracker.set(baseName, tracker); + } + } + } + for (const [baseName, tracker] of overrideTracker.entries()) { + const configuredValue = tracker.value as T[typeof baseName]; + const validatedValue = this.ensureValid(baseName, () => configuredValue, true); + if (baseName in changes && this.isRelevantChange(changes[baseName])) { + const { domain, oldValue, preferenceName, scope } = changes[baseName]; + this.fireChangeEvent(this.buildNewChangeEvent({ domain, oldValue, preferenceName, scope, newValue: validatedValue })); + } + for (const override of tracker.overrides) { + const name = this.preferences.overridePreferenceName(override); + const { domain, oldValue, preferenceName, scope } = changes[name]; + const newValue = changes[name].newValue === configuredValue ? validatedValue : this.ensureValid(name, () => changes[name].newValue, true); + this.fireChangeEvent(this.buildNewChangeEvent({ domain, oldValue, preferenceName, scope, newValue }, override)); + } + } + } + } + + override getValue( + preferenceIdentifier: K | (OverridePreferenceName & { preferenceName: K; }), defaultValue: T[K], resourceUri = this.resourceUri + ): T[K] { + const preferenceName = OverridePreferenceName.is(preferenceIdentifier) ? this.preferences.overridePreferenceName(preferenceIdentifier) : preferenceIdentifier as string; + return this.ensureValid(preferenceName, () => (super.getValue(preferenceIdentifier, defaultValue, resourceUri) ?? defaultValue), false) as T[K]; + } + + protected ensureValid(preferenceName: K, getCandidate: () => T[K], isChange?: boolean): T[K] { + if (!isChange && this.validPreferences.has(preferenceName)) { + return this.validPreferences.get(preferenceName) as T[K]; + } + const candidate = getCandidate(); + const valid = this.validator.validateByName(preferenceName, candidate) as T[K]; + this.validPreferences.set(preferenceName, valid); + return valid; + } + + override dispose(): void { + super.dispose(); + if (this.options.isDisposable) { + this.validPreferences.clear(); + } + } +} diff --git a/packages/core/src/common/json-schema.ts b/packages/core/src/common/json-schema.ts index 071803235ebfc..3ec7dfd3f7da5 100644 --- a/packages/core/src/common/json-schema.ts +++ b/packages/core/src/common/json-schema.ts @@ -14,6 +14,10 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import { JSONValue } from '@phosphor/coreutils'; + +export type JsonType = 'string' | 'array' | 'number' | 'integer' | 'object' | 'boolean' | 'null'; + /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. @@ -31,10 +35,9 @@ export interface IJSONSchema { id?: string; $id?: string; $schema?: string; - type?: string | string[]; + type?: JsonType | JsonType[]; title?: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - default?: any; + default?: JSONValue; definitions?: IJSONSchemaMap; description?: string; properties?: IJSONSchemaMap; @@ -63,7 +66,7 @@ export interface IJSONSchema { oneOf?: IJSONSchema[]; not?: IJSONSchema; // eslint-disable-next-line @typescript-eslint/no-explicit-any - enum?: any[]; + enum?: JSONValue[]; format?: string; // schema draft 06 diff --git a/packages/core/src/common/preferences/preference-schema.ts b/packages/core/src/common/preferences/preference-schema.ts index 0737a5a6e3230..c9c542a7eeedf 100644 --- a/packages/core/src/common/preferences/preference-schema.ts +++ b/packages/core/src/common/preferences/preference-schema.ts @@ -16,8 +16,15 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { JSONValue } from '@phosphor/coreutils'; +import { IJSONSchema, JsonType } from '../json-schema'; import { PreferenceScope } from './preference-scope'; +/** + * @deprecated since 1.22.0. Import from @theia/core/common/json-schema.ts instead. + */ +export { JsonType }; + export interface PreferenceSchema { [name: string]: any, scope?: 'application' | 'window' | 'resource' | PreferenceScope, @@ -59,25 +66,14 @@ export interface PreferenceDataSchema { }; } -export interface PreferenceItem { - type?: JsonType | JsonType[]; - minimum?: number; - /** - * content assist (UI) default value - */ - default?: any; +export interface PreferenceItem extends IJSONSchema { /** * preference default value, if `undefined` then `default` */ - defaultValue?: any; - enum?: string[]; - items?: PreferenceItem; - properties?: { [name: string]: PreferenceItem }; - additionalProperties?: object | boolean; - [name: string]: any; + defaultValue?: JSONValue; overridable?: boolean; + [key: string]: any; } - export interface PreferenceSchemaProperty extends PreferenceItem { description?: string; markdownDescription?: string; @@ -99,5 +95,3 @@ export namespace PreferenceDataProperty { return schemaProps; } } - -export type JsonType = 'string' | 'array' | 'number' | 'integer' | 'object' | 'boolean' | 'null'; diff --git a/packages/editor/src/browser/editor-preferences.ts b/packages/editor/src/browser/editor-preferences.ts index 2bb9072534be5..d0c77cd2de449 100644 --- a/packages/editor/src/browser/editor-preferences.ts +++ b/packages/editor/src/browser/editor-preferences.ts @@ -24,6 +24,7 @@ import { PreferenceChangeEvent, PreferenceSchemaProperties } from '@theia/core/lib/browser/preferences'; +import { PreferenceProxyFactory } from '@theia/core/lib/browser/preferences/injectable-preference-proxy'; import { isWindows, isOSX, OS } from '@theia/core/lib/common/os'; import { nls } from '@theia/core/lib/common/nls'; @@ -81,7 +82,7 @@ const codeEditorPreferenceProperties = { 'markdownDescription': nls.localizeByDefault('The number of spaces a tab is equal to. This setting is overridden based on the file contents when `#editor.detectIndentation#` is on.') }, 'editor.defaultFormatter': { - 'type': 'string', + 'type': ['string', 'null'], 'default': null, 'description': 'Default formatter.' }, @@ -320,7 +321,7 @@ const codeEditorPreferenceProperties = { 'editor.codeLensFontFamily': { 'description': nls.localizeByDefault('Controls the font family for CodeLens.'), 'type': 'string', - 'default': true + 'default': '' }, 'editor.codeLensFontSize': { 'type': 'integer', @@ -1588,9 +1589,8 @@ export function createEditorPreferences(preferences: PreferenceService, schema: export function bindEditorPreferences(bind: interfaces.Bind): void { bind(EditorPreferences).toDynamicValue(ctx => { - const preferences = ctx.container.get(PreferenceService); - const contribution = ctx.container.get(EditorPreferenceContribution); - return createEditorPreferences(preferences, contribution.schema); + const factory = ctx.container.get(PreferenceProxyFactory); + return factory(editorPreferenceSchema, { validated: true }); }).inSingletonScope(); bind(EditorPreferenceContribution).toConstantValue({ schema: editorPreferenceSchema }); bind(PreferenceContribution).toService(EditorPreferenceContribution); diff --git a/packages/monaco/src/browser/monaco-editor-provider.ts b/packages/monaco/src/browser/monaco-editor-provider.ts index b6e9dd6e5b58b..359027af13d72 100644 --- a/packages/monaco/src/browser/monaco-editor-provider.ts +++ b/packages/monaco/src/browser/monaco-editor-provider.ts @@ -35,7 +35,7 @@ import { MonacoBulkEditService } from './monaco-bulk-edit-service'; import IEditorOverrideServices = monaco.editor.IEditorOverrideServices; import { ApplicationServer } from '@theia/core/lib/common/application-protocol'; import { ContributionProvider } from '@theia/core'; -import { KeybindingRegistry, OpenerService, open, WidgetOpenerOptions, FormatType } from '@theia/core/lib/browser'; +import { KeybindingRegistry, OpenerService, open, WidgetOpenerOptions, FormatType, PreferenceValidationService } from '@theia/core/lib/browser'; import { MonacoResolvedKeybinding } from './monaco-resolved-keybinding'; import { HttpOpenHandlerOptions } from '@theia/core/lib/browser/http-open-handler'; import { MonacoToProtocolConverter } from './monaco-to-protocol-converter'; @@ -75,6 +75,9 @@ export class MonacoEditorProvider { @inject(MonacoQuickInputImplementation) protected readonly quickInputService: MonacoQuickInputImplementation; + @inject(PreferenceValidationService) + protected readonly preferenceValidator: PreferenceValidationService; + protected _current: MonacoEditor | undefined; /** * Returns the last focused MonacoEditor. @@ -272,7 +275,9 @@ export class MonacoEditorProvider { if (event) { const preferenceName = event.preferenceName; const overrideIdentifier = editor.document.languageId; - const newValue = this.editorPreferences.get({ preferenceName, overrideIdentifier }, undefined, editor.uri.toString()); + const newValue = this.preferenceValidator.validateByName(preferenceName, + this.editorPreferences.get({ preferenceName, overrideIdentifier }, undefined, editor.uri.toString()) + ); editor.getControl().updateOptions(this.setOption(preferenceName, newValue, this.preferencePrefixes)); } else { const options = this.createMonacoEditorOptions(editor.document); @@ -301,7 +306,9 @@ export class MonacoEditorProvider { } const overrideIdentifier = editor.document.languageId; const uri = editor.uri.toString(); - const formatOnSave = this.editorPreferences.get({ preferenceName: 'editor.formatOnSave', overrideIdentifier }, undefined, uri)!; + const formatOnSave = this.preferenceValidator.validateByName('editor.formatOnSave', + this.editorPreferences.get({ preferenceName: 'editor.formatOnSave', overrideIdentifier }, undefined, uri)! + ); if (formatOnSave) { const formatOnSaveTimeout = this.editorPreferences.get({ preferenceName: 'editor.formatOnSaveTimeout', overrideIdentifier }, undefined, uri)!; await Promise.race([ @@ -352,7 +359,9 @@ export class MonacoEditorProvider { if (event) { const preferenceName = event.preferenceName; const overrideIdentifier = editor.document.languageId; - const newValue = this.editorPreferences.get({ preferenceName, overrideIdentifier }, undefined, resourceUri); + const newValue = this.preferenceValidator.validateByName(preferenceName, + this.editorPreferences.get({ preferenceName, overrideIdentifier }, undefined, resourceUri) + ); editor.diffEditor.updateOptions(this.setOption(preferenceName, newValue, this.diffPreferencePrefixes)); } else { const options = this.createMonacoDiffEditorOptions(editor.originalModel, editor.modifiedModel); @@ -364,10 +373,12 @@ export class MonacoEditorProvider { protected createOptions(prefixes: string[], uri: string): { [name: string]: any }; protected createOptions(prefixes: string[], uri: string, overrideIdentifier: string): { [name: string]: any }; protected createOptions(prefixes: string[], uri: string, overrideIdentifier?: string): { [name: string]: any } { - return Object.keys(this.editorPreferences).reduce((options, preferenceName) => { - const value = (this.editorPreferences).get({ preferenceName, overrideIdentifier }, undefined, uri); - return this.setOption(preferenceName, deepClone(value), prefixes, options); - }, {}); + const flat: Record = {}; + for (const preferenceName of Object.keys(this.editorPreferences)) { + flat[preferenceName] = (this.editorPreferences).get({ preferenceName, overrideIdentifier }, undefined, uri); + } + const valid = this.preferenceValidator.validateOptions(flat); + return Object.entries(valid).reduce((tree, [preferenceName, value]) => this.setOption(preferenceName, deepClone(value), prefixes, tree), {}); } protected setOption(preferenceName: string, value: any, prefixes: string[], options: { [name: string]: any } = {}): { diff --git a/packages/plugin-ext/src/common/plugin-protocol.ts b/packages/plugin-ext/src/common/plugin-protocol.ts index 9927fa156e2b5..af51fe25572aa 100644 --- a/packages/plugin-ext/src/common/plugin-protocol.ts +++ b/packages/plugin-ext/src/common/plugin-protocol.ts @@ -276,14 +276,7 @@ export interface PluginPackageLanguageContributionConfiguration { export interface PluginTaskDefinitionContribution { type: string; required: string[]; - properties?: { - [name: string]: { - type: string; - description?: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [additionalProperty: string]: any; - } - } + properties?: IJSONSchema['properties']; } export interface PluginProblemMatcherContribution extends ProblemMatcherContribution { diff --git a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts index 03f776ef19a70..d569b949624cb 100644 --- a/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts +++ b/packages/plugin-ext/src/hosted/node/scanners/scanner-theia.ts @@ -701,17 +701,29 @@ export class TheiaPluginScanner implements PluginScanner { private readTaskDefinition(pluginName: string, definitionContribution: PluginTaskDefinitionContribution): TaskDefinition { const propertyKeys = definitionContribution.properties ? Object.keys(definitionContribution.properties) : []; + const schema = this.toSchema(definitionContribution); return { taskType: definitionContribution.type, source: pluginName, properties: { required: definitionContribution.required || [], all: propertyKeys, - schema: definitionContribution + schema } }; } + protected toSchema(definition: PluginTaskDefinitionContribution): IJSONSchema { + const reconciliation: IJSONSchema = { ...definition, type: 'object' }; + const schema = deepClone(reconciliation); + if (schema.properties === undefined) { + schema.properties = Object.create(null); + } + schema.type = 'object'; + schema.properties!.type = { type: 'string', const: definition.type }; + return schema; + } + protected resolveSchemaAttributes(type: string, configurationAttributes: { [request: string]: IJSONSchema }): IJSONSchema[] { const taskSchema = {}; return Object.keys(configurationAttributes).map(request => { diff --git a/packages/preferences/src/browser/views/components/preference-select-input.ts b/packages/preferences/src/browser/views/components/preference-select-input.ts index 0f7bd4b2d14f2..a22b626140326 100644 --- a/packages/preferences/src/browser/views/components/preference-select-input.ts +++ b/packages/preferences/src/browser/views/components/preference-select-input.ts @@ -41,7 +41,7 @@ export class PreferenceSelectInputRenderer extends PreferenceLeafNodeRenderer { let registry: TaskDefinitionRegistry; - const definitionContributionA = { + const definitionContributionA: TaskDefinition = { taskType: 'extA', source: 'extA', - required: ['extensionType'], properties: { required: ['extensionType'], all: ['extensionType', 'taskLabel'], schema: { - type: 'extA', + type: 'object', required: ['extensionType'], properties: { + type: { const: 'extA' }, extensionType: {}, taskLabel: {} } } } }; - const definitionContributionB = { + const definitionContributionB: TaskDefinition = { taskType: 'extA', source: 'extA', properties: { required: ['extensionType', 'taskLabel', 'taskDetailedLabel'], all: ['extensionType', 'taskLabel', 'taskDetailedLabel'], schema: { - type: 'extA', + type: 'object', required: ['extensionType', 'taskLabel', 'taskDetailedLabel'], properties: { + type: { const: 'extA' }, extensionType: {}, taskLabel: {}, taskDetailedLabel: {} @@ -67,40 +68,38 @@ describe('TaskDefinitionRegistry', () => { reveal: RevealKind.Always, showReuseMessage: true, }; - const fakeTaskContrib = { - def: { - taskType: FAKE_TASK_META.TYPE, - source: FAKE_TASK_META.SRC, + const fakeTaskDefinition: TaskDefinition = { + taskType: FAKE_TASK_META.TYPE, + source: FAKE_TASK_META.SRC, + properties: { required: ['strArg'], - properties: { + all: ['strArg', 'arrArgs'], + schema: { + type: 'object', required: ['strArg'], - all: ['strArg', 'arrArgs'], - schema: { - type: FAKE_TASK_META.TYPE, - required: ['strArg'], - properties: { - strArg: {}, - arrArgs: {} - } + properties: { + type: { const: FAKE_TASK_META.TYPE }, + strArg: {}, + arrArgs: {} } } - }, - conf: ( - executionId = 'foobar', - type = FAKE_TASK_META.TYPE, - _source = FAKE_TASK_META.SRC, - arrArgs: unknown[] = [], - strArg = '', - label = 'foobar', - presentation = defaultPresentation, - problemMatcher = undefined, - taskType = 'customExecution', - _scope = TaskScope.Workspace, - ) => ({ - executionId, arrArgs, strArg, label, presentation, - problemMatcher, taskType, type, _scope, _source, - }) + } }; + const configureFakeTask = ( + executionId = 'foobar', + type = FAKE_TASK_META.TYPE, + _source = FAKE_TASK_META.SRC, + arrArgs: unknown[] = [], + strArg = '', + label = 'foobar', + presentation = defaultPresentation, + problemMatcher = undefined, + taskType = 'customExecution', + _scope = TaskScope.Workspace, + ) => ({ + executionId, arrArgs, strArg, label, presentation, + problemMatcher, taskType, type, _scope, _source, + }); beforeEach(() => { registry = new TaskDefinitionRegistry(); @@ -155,28 +154,28 @@ describe('TaskDefinitionRegistry', () => { describe('compareTasks function', () => { - beforeEach(() => registry.register(fakeTaskContrib.def)); + beforeEach(() => registry.register(fakeTaskDefinition)); it('should return false if given 2 task configurations with different type', () => { const areSameTasks = registry.compareTasks( - fakeTaskContrib.conf('id_1', 'type_1'), - fakeTaskContrib.conf('id_2', 'type_2'), + configureFakeTask('id_1', 'type_1'), + configureFakeTask('id_2', 'type_2'), ); expect(areSameTasks).to.be.false; }); it('should return true if given 2 same task configurations with empty arrays (different by reference) as custom property', () => { const areSameTasks = registry.compareTasks( - fakeTaskContrib.conf('id_1'), - fakeTaskContrib.conf('id_2'), + configureFakeTask('id_1'), + configureFakeTask('id_2'), ); expect(areSameTasks).to.be.true; }); it('should return true if given 2 same task configurations with deep properties (different by reference)', () => { const areSameTasks = registry.compareTasks( - fakeTaskContrib.conf('id_1', undefined, undefined, [1, '2', { '3': { a: true, b: 'string' } }]), - fakeTaskContrib.conf('id_2', undefined, undefined, [1, '2', { '3': { a: true, b: 'string' } }]), + configureFakeTask('id_1', undefined, undefined, [1, '2', { '3': { a: true, b: 'string' } }]), + configureFakeTask('id_2', undefined, undefined, [1, '2', { '3': { a: true, b: 'string' } }]), ); expect(areSameTasks).to.be.true; }); @@ -185,17 +184,17 @@ describe('TaskDefinitionRegistry', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const inputs: [any, any][] = [ [ - fakeTaskContrib.conf('id_1', undefined, undefined, [1, '2', { '3': { a: true, b: 'b' } }]), - fakeTaskContrib.conf('id_2', undefined, undefined, [1, '2', { '3': { a: true } }]), + configureFakeTask('id_1', undefined, undefined, [1, '2', { '3': { a: true, b: 'b' } }]), + configureFakeTask('id_2', undefined, undefined, [1, '2', { '3': { a: true } }]), ], [ - fakeTaskContrib.conf('id_1', undefined, undefined, [1, '2']), - fakeTaskContrib.conf('id_2', undefined, undefined, [1, 2]), + configureFakeTask('id_1', undefined, undefined, [1, '2']), + configureFakeTask('id_2', undefined, undefined, [1, 2]), ], [ // eslint-disable-next-line no-null/no-null - fakeTaskContrib.conf('id_1', undefined, undefined, [1, '2', { c: null }]), - fakeTaskContrib.conf('id_2', undefined, undefined, [1, '2', { c: undefined }]), + configureFakeTask('id_1', undefined, undefined, [1, '2', { c: null }]), + configureFakeTask('id_2', undefined, undefined, [1, '2', { c: undefined }]), ], ]; const allAreFalse = inputs.map(args => registry.compareTasks(...args)).every(areSameTasks => areSameTasks === false); diff --git a/packages/task/src/browser/task-schema-updater.ts b/packages/task/src/browser/task-schema-updater.ts index d9698d8d21870..27f2964cadd86 100644 --- a/packages/task/src/browser/task-schema-updater.ts +++ b/packages/task/src/browser/task-schema-updater.ts @@ -33,6 +33,7 @@ import { TaskDefinitionRegistry } from './task-definition-registry'; import { TaskServer } from '../common'; import { UserStorageUri } from '@theia/userstorage/lib/browser'; import { WorkspaceService } from '@theia/workspace/lib/browser'; +import { JSONObject } from '@theia/core/shared/@phosphor/coreutils'; export const taskSchemaId = 'vscode://schemas/tasks'; @@ -156,11 +157,11 @@ export class TaskSchemaUpdater implements JsonSchemaContribution { customizedDetectedTasks.length = 0; const definitions = this.taskDefinitionRegistry.getAll(); definitions.forEach(def => { - const customizedDetectedTask = { + const customizedDetectedTask: IJSONSchema = { type: 'object', required: ['type'], properties: {} - } as IJSONSchema; + }; const taskType = { ...defaultTaskType, enum: [def.taskType], @@ -187,7 +188,7 @@ export class TaskSchemaUpdater implements JsonSchemaContribution { } /** Returns the task's JSON schema */ - getTaskSchema(): IJSONSchema { + getTaskSchema(): IJSONSchema & { default: JSONObject } { return { type: 'object', default: { version: '2.0.0', tasks: [] }, @@ -274,23 +275,23 @@ const commandOptionsSchema: IJSONSchema = { const problemMatcherNames: string[] = []; const defaultTaskTypes = ['shell', 'process']; const supportedTaskTypes = [...defaultTaskTypes]; -const taskLabel = { +const taskLabel: IJSONSchema = { type: 'string', description: 'A unique string that identifies the task that is also used as task\'s user interface label' }; -const defaultTaskType = { +const defaultTaskType: IJSONSchema = { type: 'string', enum: supportedTaskTypes, default: defaultTaskTypes[0], description: 'Determines what type of process will be used to execute the task. Only shell types will have output shown on the user interface' -}; +} as const; const commandAndArgs = { command: commandSchema, args: commandArgSchema, options: commandOptionsSchema }; -const group = { +const group: IJSONSchema = { oneOf: [ { type: 'string' @@ -527,7 +528,7 @@ const problemMatcherObject: IJSONSchema = { } }; -const problemMatcher = { +const problemMatcher: IJSONSchema = { anyOf: [ { type: 'string',