UNPKG

@jupyter-lsp/jupyterlab-lsp

Version:

Language Server Protocol integration for JupyterLab

760 lines (700 loc) 26 kB
import { showDialog, Dialog } from '@jupyterlab/apputils'; import { ILanguageServerManager, LanguageServerManager } from '@jupyterlab/lsp'; import { ISettingRegistry, ISchemaValidator } from '@jupyterlab/settingregistry'; import { TranslationBundle, nullTranslator } from '@jupyterlab/translation'; import { JSONExt, ReadonlyPartialJSONObject, ReadonlyJSONObject, PartialJSONObject, PromiseDelegate } from '@lumino/coreutils'; import { Signal, ISignal } from '@lumino/signaling'; import { FieldProps } from '@rjsf/utils'; import { LanguageServers } from './_plugin'; import { renderLanguageServerSettings, renderCollapseConflicts } from './components/serverSettings'; import { ILSPLogConsole } from './tokens'; import { collapseToDotted, expandDottedPaths } from './utils'; type ValueOf<T> = T[keyof T]; type ServerSchemaWrapper = ValueOf< Required<LanguageServers>['language_servers'] >; /** * Only used for TypeScript type coercion, not meant to represent a property fully. */ interface IJSONProperty { type?: string | string[]; description?: string; $ref?: string; } function isJSONProperty(obj: unknown): obj is IJSONProperty { return ( typeof obj === 'object' && obj !== null && ('type' in obj || '$ref' in obj) ); } /** * Settings keyed by language server name with values including * multiple properties, such as priority or workspace configuration */ type LanguageServerSettings = Record<string, ServerSchemaWrapper>; /** * Default server priority; this should match the value defined in `plugin.json` schema. */ const DEAULT_SERVER_PRIORITY = 50; /** * Get default values from JSON Schema properties field. */ function getDefaults( properties: ReadonlyPartialJSONObject | undefined ): Record<string, any> { if (properties == null) { return {}; } // TODO: also get defaults from ref? const defaults: Record<string, any> = {}; const entries = Object.entries(properties) .map(([key, value]) => [key, (value as any)?.default]) .filter(([key, value]) => typeof value !== 'undefined'); // TODO: use Object.fromEntries once we switch target for (let [key, value] of entries) { defaults[key] = value; } return defaults; } /** * Get a mutable property matching a dotted key and a properly nested value. * * Most LSP server schema properties are flattened using dotted convention, * e.g. a key for {pylsp: {plugins: {flake8: {enabled: true}}}}` is stored * as `pylsp.plugins.flake8.enabled`. However, some servers (e.g. pyright) * define specific properties as only partially doted, for example * `python.analysis.diagnosticSeverityOverrides` is an object with * properties like `reportGeneralTypeIssues` or `reportPropertyTypeMismatch`. * Only one level of nesting (on the finale level) is supported. */ function nestInSchema( properties: PartialJSONObject, key: string, value: PartialJSONObject ): { property: PartialJSONObject; value: PartialJSONObject } | null { if (properties.hasOwnProperty(key)) { return { property: properties[key] as PartialJSONObject, value }; } const parts = key.split('.'); const prefix = parts.slice(0, -1).join('.'); const suffix = parts[parts.length - 1]; if (properties.hasOwnProperty(prefix)) { const parent = properties[prefix] as PartialJSONObject; if (parent.type !== 'object') { return null; } const parentProperties = parent.properties as PartialJSONObject; if (parentProperties.hasOwnProperty(suffix)) { return { property: parent, value: { [suffix]: value } }; } } return null; } function mergePropertyDefault( property: PartialJSONObject, value: PartialJSONObject ) { if ( property.type === 'object' && typeof property.default === 'object' && typeof value === 'object' ) { property.default = { ...property.default, ...value }; } else { property.default = value; } } /** * Schema and user data that for validation */ interface IValidationData { rawUserSettings: string; schema: ISettingRegistry.ISchema; } /** * Conflicts encountered when dot-collapsing settings * organised by server ID, and then as a mapping between * (dotted) setting ID and list of encountered values. * The last encountered values is preferred for use. */ type SettingsMergeConflicts = Record<string, Record<string, any[]>>; interface ISettingsCollapseResult { settings: LanguageServerSettings; conflicts: SettingsMergeConflicts; } export class SettingsUIManager { constructor( protected options: { settingRegistry: ISettingRegistry; languageServerManager: ILanguageServerManager; console: ILSPLogConsole; trans: TranslationBundle; schemaValidated: ISignal< SettingsSchemaManager, ISchemaValidator.IError[] >; } ) { options.schemaValidated.connect((_, errors) => { this._validationErrors = errors; }); this._validationErrors = []; } renderForm(props: FieldProps) { return renderLanguageServerSettings({ settingRegistry: this.options.settingRegistry, languageServerManager: this.options.languageServerManager, trans: this.options.trans, validationErrors: this._validationErrors, ...props }); } private _validationErrors: ISchemaValidator.IError[]; } /** * Harmonize settings from schema, defaults from specification, and values set by user. */ export class SettingsSchemaManager { constructor( protected options: { settingRegistry: ISettingRegistry; languageServerManager: ILanguageServerManager; console: ILSPLogConsole; trans: TranslationBundle; /** * Promise resolved when JupyterLab splash screen disappears. */ restored: Promise<void>; } ) { this._schemaValidated = new Signal(this); this._defaults = {}; this._canonical = null; this._original = null; this._validationAttempt = 0; this._lastValidation = null; this._lastUserServerSettings = null; this._lastUserServerSettingsDoted = null; this._validationErrors = []; } get schemaValidated(): ISignal< SettingsSchemaManager, ISchemaValidator.IError[] > { return this._schemaValidated; } protected get console(): ILSPLogConsole { return this.options.console; } /** * Add schema for individual language servers into JSON schema. * This method has to be called before any other action * is performed on settingRegistry with regard to pluginId. */ async setupSchemaTransform(pluginId: string): Promise<void> { const languageServerManager = this.options.languageServerManager; // To populate defaults we require specs to be available, so we need to // wait for until after the `languageServerManager` is ready. await languageServerManager.ready; // Transform the plugin object to return different schema than the default. this.options.settingRegistry.transform(pluginId, { fetch: plugin => { // Profiling data (earlier version): // Initial fetch: 61-64 ms // Subsequent without change: <1ms // Session change: 642 ms. // 91% spent on `validateData()` of which 10% in addSchema(). // 1.8% spent on `deepCopy()` // 1.79% spend on other tasks in `populate()` // There is a limit on the transformation time, and failing to transform // in the default 1 second means that no settings whatsoever are available. // Therefore validation in `populate()` was moved into an async function; // this means that we need to trigger re-load of settings // if there validation errors. // Only store the original schema the first time. if (!this._original) { this._original = JSONExt.deepCopy(plugin.schema); } // Only override the canonical schema the first time (or after reset). if (!this._canonical) { this._canonical = JSONExt.deepCopy(plugin.schema); this._populate(plugin, this._canonical); this._defaultsPopulated.resolve(void 0); } return { data: plugin.data, id: plugin.id, raw: plugin.raw, schema: this._validationErrors.length ? this._original : this._canonical, version: plugin.version }; } }); // note: has to be after transform is called for the first time to avoid // race condition, see https://github.com/jupyterlab/jupyterlab/issues/12978 languageServerManager.sessionsChanged.connect(async () => { this._canonical = null; this._defaultsPopulated = new PromiseDelegate(); await this.options.settingRegistry.reload(pluginId); }); } /** * Populate the plugin's schema defaults, transform descriptions. */ private _populate( plugin: ISettingRegistry.IPlugin, schema: ISettingRegistry.ISchema ) { const languageServerManager = this.options.languageServerManager; const { properties, defaults } = SettingsSchemaManager.transformSchemas({ schema, // TODO: expose `specs` upstream and use `ILanguageServerManager` instead specs: (languageServerManager as LanguageServerManager).specs, sessions: languageServerManager.sessions, console: this.console, trans: this.options.trans }); schema.properties!.language_servers.properties = properties; schema.properties!.language_servers.default = defaults; this._validateSchemaLater(plugin, schema).catch(this.console.warn); this._defaults = defaults; } /** * Transform the plugin schema defaults, properties and descriptions */ static transformSchemas(options: { schema: ISettingRegistry.ISchema; specs: LanguageServerManager['specs']; sessions: ILanguageServerManager['sessions']; console?: ILSPLogConsole; trans?: TranslationBundle; }) { const { schema, sessions, specs } = options; const trans = options.trans ?? nullTranslator.load('jupyterlab-lsp'); const console = options.console ?? window.console; const baseServerSchema = (schema.definitions as any)['language-server'] as { description: string; title: string; definitions: Record<string, any>; properties: ServerSchemaWrapper; }; const defaults: Record<string, any> = {}; const knownServersConfig: Record<string, any> = {}; // `sharedDefaults` may be empty as we do not define/receive custom // per-property defaults in schema as of the day of writing. const sharedDefaults = getDefaults( schema.properties!.language_servers.properties ); const defaultsOverrides = schema.properties!.language_servers.default as | Record<string, any> | undefined; for (let [serverKey, serverSpec] of specs.entries()) { if ((serverKey as string) === '') { console.warn( `Empty server key - skipping transformation for ${serverSpec}` ); continue; } const configSchema = serverSpec.config_schema; if (!configSchema) { console.warn( `No config schema - skipping transformation for ${serverKey}` ); continue; } if (!configSchema.properties) { console.warn( `No properties in config schema - skipping transformation for ${serverKey}` ); continue; } // let user know if server not available (installed, etc) if (!sessions.has(serverKey)) { configSchema.description = trans.__( 'Settings that would be passed to `%1` server (this server was not detected as installed during startup) in `workspace/didChangeConfiguration` notification.', serverSpec.display_name ); } else { configSchema.description = trans.__( 'Settings to be passed to %1 in `workspace/didChangeConfiguration` notification.', serverSpec.display_name ); } configSchema.title = trans.__('Workspace Configuration'); // resolve refs for (let [key, value] of Object.entries(configSchema.properties)) { if (!isJSONProperty(value)) { continue; } if (typeof value.$ref === 'undefined') { continue; } if (value.$ref.startsWith('#/definitions/')) { const definitionID = value['$ref'].substring(14); const definition = configSchema.definitions[definitionID]; if (definition == null) { console.warn('Definition not found'); } for (let [defKey, defValue] of Object.entries(definition)) { configSchema.properties[key][defKey] = defValue; } delete value.$ref; } else { console.warn('Unsupported $ref', value['$ref']); } } // add default overrides from server-side spec (such as defined in `jupyter_server_config.py`) const workspaceConfigurationDefaults = serverSpec.workspace_configuration as Record<string, any> | undefined; if (workspaceConfigurationDefaults) { for (const [key, value] of Object.entries( workspaceConfigurationDefaults )) { const nested = nestInSchema(configSchema.properties, key, value); if (!nested) { console.warn( `"workspace_configuration" includes an override for "${key}" key which was not found in ${serverKey} schema'` ); continue; } mergePropertyDefault(nested.property, nested.value); } } // add server-specific default overrides from `overrides.json` (and pre-defined in schema) const serverDefaultsOverrides = defaultsOverrides && defaultsOverrides.hasOwnProperty(serverKey) ? defaultsOverrides[serverKey] : {}; if (serverDefaultsOverrides.serverSettings) { for (const [key, value] of Object.entries( serverDefaultsOverrides.serverSettings )) { const nested = nestInSchema( configSchema.properties, key, value as any ); if (!nested) { console.warn( `"overrides.json" includes an override for "${key}" key which was not found in ${serverKey} schema` ); continue; } mergePropertyDefault(nested.property, nested.value); } } const defaultMap = getDefaults(configSchema.properties); const baseSchemaCopy = JSONExt.deepCopy(baseServerSchema); baseSchemaCopy.properties.serverSettings = configSchema; knownServersConfig[serverKey] = baseSchemaCopy; defaults[serverKey] = { ...sharedDefaults, ...serverDefaultsOverrides, serverSettings: defaultMap }; } return { properties: knownServersConfig, defaults }; } /** * Expands dotted values into nested properties when the server config schema * indicates that this is needed. The schema is passed within the specs. * * This is needed because some settings, specifically pright's * `python.analysis.diagnosticSeverityOverrides` are defined as nested. */ static expandDottedAsNeeded(options: { dottedSettings: LanguageServerSettings; specs: LanguageServerManager['specs']; }): LanguageServerSettings { const specs = options.specs; const partiallyUncollapsed = JSONExt.deepCopy(options.dottedSettings); for (let [serverKey, serverSpec] of specs.entries()) { const configSchema = serverSpec.config_schema; if (!partiallyUncollapsed.hasOwnProperty(serverKey)) { continue; } const settings = partiallyUncollapsed[serverKey].serverSettings; if (!configSchema || !settings) { continue; } const expanded = expandDottedPaths(settings); for (const [path, property] of Object.entries<PartialJSONObject>( configSchema.properties )) { if (property.type === 'object') { let value = expanded; for (const part of path.split('.')) { value = value[part] as ReadonlyJSONObject; if (typeof value === 'undefined') { break; } } if (typeof value === 'undefined') { continue; } // Add the uncollapsed value settings[path] = value; // Remove the collapsed values for (const k of Object.keys(value)) { const key = path + '.' + k; if (!settings.hasOwnProperty(key)) { throw Error( 'Internal inconsistency: collapsed settings state does not match expanded object' ); } delete settings[key]; } } } } return partiallyUncollapsed; } /** * Normalize settings by dotted and nested specs, and merging with defaults. */ async normalizeSettings( composite: Required<LanguageServers> ): Promise<Required<LanguageServers>> { await this._defaultsPopulated.promise; // Cache collapsed settings for speed and to only show dialog once. // Note that JupyterLab attempts to transform in "preload" step (before splash screen end) // and then again for deferred extensions if the initial transform in preload timed out. // We are hitting the timeout in preload step. if ( this._lastUserServerSettings === null || this._lastUserServerSettingsDoted === null || !JSONExt.deepEqual( this._lastUserServerSettings, composite.language_servers ) ) { this._lastUserServerSettings = composite.language_servers; const collapsedDefaults = this._collapseServerSettingsDotted( this._defaults ); const collapsedUser = this._collapseServerSettingsDotted( composite.language_servers ); const merged = SettingsSchemaManager.mergeByServer( collapsedDefaults.settings, collapsedUser.settings ); // Uncollapse settings which need to be in the expanded form const languageServerManager = this.options.languageServerManager; const uncollapsed = SettingsSchemaManager.expandDottedAsNeeded({ dottedSettings: merged, specs: (languageServerManager as LanguageServerManager).specs }); composite.language_servers = uncollapsed; this._lastUserServerSettingsDoted = uncollapsed; if (Object.keys(collapsedUser.conflicts).length > 0) { this._warnConflicts( collapsedUser.conflicts, 'Conflicts in user settings' ).catch(this.console.warn); } if (Object.keys(collapsedDefaults.conflicts).length > 0) { this._warnConflicts( collapsedDefaults.conflicts, 'Conflicts in defaults' ).catch(this.console.warn); } } else { composite.language_servers = this._lastUserServerSettingsDoted; } // We do not filter out defaults at this level, // as it does not provide an obvious benefit: // - we would need to explicitly save the updated settings // to get a clean version in JSON Setting Editor. // - if default changed on the LSP server side but schema did not get // updated, LSP server would be using a different value than communicated // to the user. It would be optimal to filter out defaults from // user data and always keep them in composite, // - making Jupyter server-side `workspace_configuration` work would // be more difficult // TODO: trigger update of settings to ensure that UI uses the same settings as collapsed? return composite; } private _wasPreviouslyValidated( plugin: ISettingRegistry.IPlugin, schema: ISettingRegistry.ISchema ) { return ( this._lastValidation !== null && this._lastValidation.rawUserSettings === plugin.raw && JSONExt.deepEqual(this._lastValidation.schema, schema) ); } /** * Validate user settings from plugin against provided schema, * asynchronously to avoid blocking the main thread. * Stores validation result in `this._validationErrors`. */ private async _validateSchemaLater( plugin: ISettingRegistry.IPlugin, schema: ISettingRegistry.ISchema ) { // Ensure the subsequent code runs asynchronously; also reduce the CPU load on startup. await this.options.restored; // Do not re-validate if neither schema, nor user settings changed if (this._wasPreviouslyValidated(plugin, schema)) { return; } // Test if we can apply the schema without causing validation error // (is the configuration held by the user compatible with the schema?) this._validationAttempt += 1; // the validator will parse raw plugin data into this object; // we do not do anything with those right now. const parsedData = { composite: {}, user: {} }; const validationErrors = this.options.settingRegistry.validator.validateData( { // The plugin schema is cached so we have to provide a dummy ID; // can be simplified once https://github.com/jupyterlab/jupyterlab/issues/12978 is fixed. id: `lsp-validation-attempt-${this._validationAttempt}`, raw: plugin.raw, data: parsedData, version: plugin.version, schema: schema }, true ); this._lastValidation = { rawUserSettings: plugin.raw, schema: schema }; if (validationErrors) { console.error( 'LSP server settings validation failed; graphical interface for settings will run in schema-free mode; errors:', validationErrors ); this._validationErrors = validationErrors; this._schemaValidated.emit(validationErrors); if (!this._original) { console.error( 'Original language servers schema not available to restore non-transformed values.' ); } else { if (!this._original.properties!.language_servers.properties) { delete schema.properties!.language_servers.properties; } if (!this._original.properties!.language_servers.default) { delete schema.properties!.language_servers.default; } } // Reload settings to use non-restrictive schema; this requires fixing // https://github.com/jupyterlab/jupyterlab/issues/12978 upstream to work. await this.options.settingRegistry.reload(plugin.id); } } private async _warnConflicts( conflicts: SettingsMergeConflicts, title: string ) { // Ensure the subsequent code runs asynchronously, and delay // showing the dialog until the splash screen disappeared. await this.options.restored; showDialog({ body: renderCollapseConflicts({ conflicts: conflicts, trans: this.options.trans }), title: title, buttons: [Dialog.okButton()] }).catch(console.warn); } private _collapseServerSettingsDotted( settings: LanguageServerSettings ): ISettingsCollapseResult { const conflicts: Record<string, Record<string, any[]>> = {}; const result = JSONExt.deepCopy(settings) as LanguageServerSettings; for (let [serverKey, serverSettingsGroup] of Object.entries(settings)) { if (!serverSettingsGroup || !serverSettingsGroup.serverSettings) { continue; } const collapsed = collapseToDotted( serverSettingsGroup.serverSettings as ReadonlyJSONObject ); if (Object.keys(collapsed.conflicts).length) { conflicts[serverKey] = collapsed.conflicts; } result[serverKey]!.serverSettings = collapsed.result; } return { settings: result, conflicts: conflicts }; } static mergeByServer( defaults: LanguageServerSettings, userSettings: LanguageServerSettings ): LanguageServerSettings { const result = JSONExt.deepCopy(defaults) as LanguageServerSettings; for (let [serverKey, serverSettingsGroup] of Object.entries(userSettings)) { if (!serverSettingsGroup || !serverSettingsGroup.serverSettings) { continue; } if (typeof result[serverKey] === 'undefined') { // nothing to merge with result[serverKey] = JSONExt.deepCopy(serverSettingsGroup); } else { // priority should come from (a) user (b) overrides (c) fallback default; // unfortunately the user and default values get merged in the form so we // cannot distinguish (a) from (c); as a workaround we can compare its value // with the default value. const userOrDefaultPriority = serverSettingsGroup.priority; const isPriorityUserSet = typeof userOrDefaultPriority !== 'undefined' && userOrDefaultPriority !== DEAULT_SERVER_PRIORITY; const priority = isPriorityUserSet ? userOrDefaultPriority : result[serverKey].priority ?? DEAULT_SERVER_PRIORITY; const merged: Required<ServerSchemaWrapper> = { priority, // `serverSettings` entries are expected to be flattened to dot notation here. serverSettings: { ...(result[serverKey].serverSettings || {}), ...(serverSettingsGroup.serverSettings || {}) } }; result[serverKey] = merged; } } return result; } private _defaults: LanguageServerSettings; private _defaultsPopulated = new PromiseDelegate(); private _validationErrors: ISchemaValidator.IError[]; private _schemaValidated: Signal< SettingsSchemaManager, ISchemaValidator.IError[] >; private _validationAttempt: number; private _lastValidation: IValidationData | null; private _lastUserServerSettings: LanguageServerSettings | null; private _lastUserServerSettingsDoted: LanguageServerSettings | null; private _canonical: ISettingRegistry.ISchema | null; private _original: ISettingRegistry.ISchema | null; }