UNPKG

@jupyter-lsp/jupyterlab-lsp

Version:

Language Server Protocol integration for JupyterLab

509 lines 23.8 kB
import { showDialog, Dialog } from '@jupyterlab/apputils'; import { nullTranslator } from '@jupyterlab/translation'; import { JSONExt, PromiseDelegate } from '@lumino/coreutils'; import { Signal } from '@lumino/signaling'; import { renderLanguageServerSettings, renderCollapseConflicts } from './components/serverSettings'; import { collapseToDotted, expandDottedPaths } from './utils'; function isJSONProperty(obj) { return (typeof obj === 'object' && obj !== null && ('type' in obj || '$ref' in obj)); } /** * 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) { if (properties == null) { return {}; } // TODO: also get defaults from ref? const defaults = {}; const entries = Object.entries(properties) .map(([key, value]) => [key, value === null || value === void 0 ? void 0 : value.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, key, value) { if (properties.hasOwnProperty(key)) { return { property: properties[key], 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]; if (parent.type !== 'object') { return null; } const parentProperties = parent.properties; if (parentProperties.hasOwnProperty(suffix)) { return { property: parent, value: { [suffix]: value } }; } } return null; } function mergePropertyDefault(property, value) { if (property.type === 'object' && typeof property.default === 'object' && typeof value === 'object') { property.default = { ...property.default, ...value }; } else { property.default = value; } } export class SettingsUIManager { constructor(options) { this.options = options; options.schemaValidated.connect((_, errors) => { this._validationErrors = errors; }); this._validationErrors = []; } renderForm(props) { return renderLanguageServerSettings({ settingRegistry: this.options.settingRegistry, languageServerManager: this.options.languageServerManager, trans: this.options.trans, validationErrors: this._validationErrors, ...props }); } } /** * Harmonize settings from schema, defaults from specification, and values set by user. */ export class SettingsSchemaManager { constructor(options) { this.options = options; this._defaultsPopulated = new PromiseDelegate(); 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() { return this._schemaValidated; } get console() { 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) { 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. */ _populate(plugin, schema) { const languageServerManager = this.options.languageServerManager; const { properties, defaults } = SettingsSchemaManager.transformSchemas({ schema, // TODO: expose `specs` upstream and use `ILanguageServerManager` instead specs: 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) { var _a, _b; const { schema, sessions, specs } = options; const trans = (_a = options.trans) !== null && _a !== void 0 ? _a : nullTranslator.load('jupyterlab-lsp'); const console = (_b = options.console) !== null && _b !== void 0 ? _b : window.console; const baseServerSchema = schema.definitions['language-server']; const defaults = {}; const knownServersConfig = {}; // `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; for (let [serverKey, serverSpec] of specs.entries()) { if (serverKey === '') { 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; 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); 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) { 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(configSchema.properties)) { if (property.type === 'object') { let value = expanded; for (const part of path.split('.')) { value = value[part]; 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) { 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.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; } _wasPreviouslyValidated(plugin, schema) { 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`. */ async _validateSchemaLater(plugin, schema) { // 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); } } async _warnConflicts(conflicts, title) { // 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); } _collapseServerSettingsDotted(settings) { const conflicts = {}; const result = JSONExt.deepCopy(settings); for (let [serverKey, serverSettingsGroup] of Object.entries(settings)) { if (!serverSettingsGroup || !serverSettingsGroup.serverSettings) { continue; } const collapsed = collapseToDotted(serverSettingsGroup.serverSettings); if (Object.keys(collapsed.conflicts).length) { conflicts[serverKey] = collapsed.conflicts; } result[serverKey].serverSettings = collapsed.result; } return { settings: result, conflicts: conflicts }; } static mergeByServer(defaults, userSettings) { var _a; const result = JSONExt.deepCopy(defaults); 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 : (_a = result[serverKey].priority) !== null && _a !== void 0 ? _a : DEAULT_SERVER_PRIORITY; const merged = { priority, // `serverSettings` entries are expected to be flattened to dot notation here. serverSettings: { ...(result[serverKey].serverSettings || {}), ...(serverSettingsGroup.serverSettings || {}) } }; result[serverKey] = merged; } } return result; } } //# sourceMappingURL=settings.js.map