diff --git a/extensions/json-language-features/client/src/jsonClient.ts b/extensions/json-language-features/client/src/jsonClient.ts index 06dfa8efe48db9..18a5304d0c77a2 100644 --- a/extensions/json-language-features/client/src/jsonClient.ts +++ b/extensions/json-language-features/client/src/jsonClient.ts @@ -20,7 +20,7 @@ import { import { hash } from './utils/hash'; -import { createDocumentSymbolsLimitItem, createLanguageStatusItem, createLimitStatusItem, createSchemaLoadIssueItem, createSchemaLoadStatusItem } from './languageStatus'; +import { createDocumentSymbolsLimitItem, createLanguageStatusItem, createLimitStatusItem, createSchemaLoadIssueItem, createSchemaLoadStatusItem, LanguageStatusItem } from './languageStatus'; import { getLanguageParticipants, LanguageParticipants } from './languageParticipants'; import { matchesUrlPattern } from './utils/urlMatch'; @@ -76,6 +76,7 @@ export interface ISchemaAssociations { export interface ISchemaAssociation { fileMatch: string[]; uri: string; + source?: 'schemaStore'; } namespace SchemaAssociationNotification { @@ -107,11 +108,29 @@ export type JSONSchemaSettings = { folderUri?: string; }; +const defaultSchemaStoreCatalogUrl = 'https://www.schemastore.org/api/json/catalog.json'; + +interface SchemaStoreCatalogEntry { + name: string; + description: string; + fileMatch?: string[]; + url: string; + versions?: { [version: string]: string }; +} + +interface SchemaStoreCatalog { + $schema?: string; + version: number; + schemas: SchemaStoreCatalogEntry[]; +} + export namespace SettingIds { export const enableFormatter = 'json.format.enable'; export const enableKeepLines = 'json.format.keepLines'; export const enableValidation = 'json.validate.enable'; export const enableSchemaDownload = 'json.schemaDownload.enable'; + export const enableSchemaStore = 'json.schemaStore.enable'; + export const schemaStoreExclude = 'json.schemaStore.exclude'; export const trustedDomains = 'json.schemaDownload.trustedDomains'; export const maxItemsComputed = 'json.maxItemsComputed'; export const editorFoldingMaximumRegions = 'editor.foldingMaximumRegions'; @@ -236,6 +255,7 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP const schemaLoadStatusItem = createSchemaLoadStatusItem((diagnostic: Diagnostic) => createSchemaLoadIssueItem(documentSelector, schemaDownloadEnabled, diagnostic)); toDispose.push(schemaLoadStatusItem); + let languageStatusItem: LanguageStatusItem | undefined; toDispose.push(commands.registerCommand(CommandIds.clearCacheCommandId, async () => { if (isClientReady && runtime.schemaRequests.clearCache) { @@ -271,6 +291,9 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP function handleSchemaErrorDiagnostics(uri: Uri, diagnostics: Diagnostic[]): Diagnostic[] { schemaLoadStatusItem.update(uri, diagnostics); + if (window.activeTextEditor?.document.uri.toString() === uri.toString()) { + languageStatusItem?.update(); + } if (!schemaDownloadEnabled) { return diagnostics.filter(d => !isSchemaResolveError(d)); } @@ -391,8 +414,7 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP const schemaDocuments: { [uri: string]: boolean } = {}; - // handle content request - client.onRequest(VSCodeContentRequest.type, async (uriPath: string) => { + async function getSchemaContent(uriPath: string, allowKnownSchemaAssociations = true): Promise { const uri = Uri.parse(uriPath); const uriString = uri.toString(true); if (uri.scheme === 'untitled') { @@ -419,7 +441,7 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP if (!workspace.isTrusted) { throw new ResponseError(SchemaRequestServiceErrors.UntrustedWorkspaceError, l10n.t('Downloading schemas is disabled in untrusted workspaces')); } - if (!await isTrusted(uri)) { + if (!await isTrusted(uri, allowKnownSchemaAssociations)) { throw new ResponseError(SchemaRequestServiceErrors.UntrustedSchemaError, l10n.t('Location {0} is untrusted', uriString)); } if (runtime.telemetry && uri.authority === 'schema.management.azure.com') { @@ -440,7 +462,10 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP } else { throw new ResponseError(SchemaRequestServiceErrors.HTTPDisabledError, l10n.t('Downloading schemas is disabled through setting \'{0}\'', SettingIds.enableSchemaDownload)); } - }); + } + + // handle content request + client.onRequest(VSCodeContentRequest.type, uriPath => getSchemaContent(uriPath)); await client.start(); @@ -558,22 +583,31 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP updateFormatterRegistration(); toDispose.push({ dispose: () => rangeFormatting && rangeFormatting.dispose() }); - toDispose.push(workspace.onDidChangeConfiguration(e => { + toDispose.push(workspace.onDidChangeConfiguration(async e => { if (e.affectsConfiguration(SettingIds.enableFormatter)) { updateFormatterRegistration(); } else if (e.affectsConfiguration(SettingIds.enableSchemaDownload)) { schemaDownloadEnabled = !!workspace.getConfiguration().get(SettingIds.enableSchemaDownload); + await refreshSchemaAssociations(); + triggerValidation(); + } else if (e.affectsConfiguration(SettingIds.enableSchemaStore)) { + await refreshSchemaAssociations(); + triggerValidation(); + } else if (e.affectsConfiguration(SettingIds.schemaStoreExclude)) { + await refreshSchemaAssociations(); triggerValidation(); } else if (e.affectsConfiguration(SettingIds.editorFoldingMaximumRegions) || e.affectsConfiguration(SettingIds.editorColorDecoratorsLimit)) { client.sendNotification(DidChangeConfigurationNotification.type, { settings: getSettings(true) }); } else if (e.affectsConfiguration(SettingIds.trustedDomains)) { trustedDomains = workspace.getConfiguration().get>(SettingIds.trustedDomains, {}); + await refreshSchemaAssociations(); triggerValidation(); } })); toDispose.push(workspace.onDidGrantWorkspaceTrust(() => triggerValidation())); - toDispose.push(createLanguageStatusItem(documentSelector, (uri: string) => client.sendRequest(LanguageStatusRequest.type, uri))); + languageStatusItem = createLanguageStatusItem(documentSelector, (uri: string) => client.sendRequest(LanguageStatusRequest.type, uri)); + toDispose.push(languageStatusItem); function updateFormatterRegistration() { const formatEnabled = workspace.getConfiguration().get(SettingIds.enableFormatter); @@ -649,12 +683,50 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP async function getSchemaAssociations(forceRefresh: boolean): Promise { if (!schemaAssociationsCache || forceRefresh) { - schemaAssociationsCache = computeSchemaAssociations(); + schemaAssociationsCache = computeSchemaAssociations(getSchemaStoreAssociations); } return schemaAssociationsCache; } - async function isTrusted(uri: Uri): Promise { + async function refreshSchemaAssociations(): Promise { + await client.sendNotification(SchemaAssociationNotification.type, await getSchemaAssociations(true)); + } + + async function getSchemaStoreAssociations(): Promise { + const configuration = workspace.getConfiguration(); + if (configuration.get(SettingIds.enableSchemaStore) === false) { + return []; + } + + let catalog: SchemaStoreCatalog; + try { + const content = await getSchemaContent(defaultSchemaStoreCatalogUrl, false); + catalog = JSON.parse(content); + } catch (error) { + runtime.logOutputChannel.warn(l10n.t('Unable to load SchemaStore catalog from \'{0}\': {1}.', defaultSchemaStoreCatalogUrl, getErrorMessage(error))); + return []; + } + + if (!catalog || typeof catalog !== 'object' || !Array.isArray(catalog.schemas)) { + runtime.logOutputChannel.warn(l10n.t('Unable to parse SchemaStore catalog from \'{0}\': Expected a catalog object with a schemas array.', defaultSchemaStoreCatalogUrl)); + return []; + } + + const exclusions = configuration.get(SettingIds.schemaStoreExclude, []).map(sanitizeSchemaStoreExclusion).filter((exclusion): exclusion is string => !!exclusion).map(exclusion => `!${exclusion}`); + const associations: ISchemaAssociation[] = []; + for (const schema of catalog.schemas) { + if (!schema || typeof schema !== 'object' || typeof schema.url !== 'string' || !Array.isArray(schema.fileMatch)) { + continue; + } + const fileMatch = schema.fileMatch.filter((pattern): pattern is string => typeof pattern === 'string'); + if (fileMatch.length) { + associations.push({ uri: schema.url, fileMatch: fileMatch.concat(exclusions), source: 'schemaStore' }); + } + } + return associations; + } + + async function isTrusted(uri: Uri, allowKnownSchemaAssociations = true): Promise { if (uri.scheme !== 'http' && uri.scheme !== 'https') { return true; } @@ -665,10 +737,12 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP return true; } - const knownAssociations = await getSchemaAssociations(false); - for (const association of knownAssociations) { - if (association.uri === uriString) { - return true; + if (allowKnownSchemaAssociations) { + const knownAssociations = await getSchemaAssociations(false); + for (const association of knownAssociations) { + if (association.uri === uriString) { + return true; + } } } const settingsCache = getSettings(false); @@ -765,9 +839,31 @@ async function startClientWithParticipants(_context: ExtensionContext, languageP }; } -async function computeSchemaAssociations(): Promise { +async function computeSchemaAssociations(getSchemaStoreAssociations: () => Promise): Promise { const extensionAssociations = getSchemaExtensionAssociations(); - return extensionAssociations.concat(await getDynamicSchemaAssociations()); + return extensionAssociations.concat(await getDynamicSchemaAssociations(), await getSchemaStoreAssociations()); +} + +function sanitizeSchemaStoreExclusion(exclusion: string): string | undefined { + if (typeof exclusion !== 'string' || !exclusion || exclusion[0] === '!' || exclusion[0] === '/' || exclusion.indexOf('\\') !== -1 || exclusion.indexOf(':') !== -1 || exclusion.indexOf('..') !== -1) { + return undefined; + } + return exclusion; +} + +function getErrorMessage(error: any): string { + if (error && typeof error.message === 'string') { + return error.message; + } + let errorMessage = error?.toString ? error.toString() as string : String(error); + const errorSplit = errorMessage.split('Error: '); + if (errorSplit.length > 1) { + errorMessage = errorSplit[1]; + } + if (errorMessage.endsWith('.')) { + errorMessage = errorMessage.substring(0, errorMessage.length - 1); + } + return errorMessage; } function getSchemaExtensionAssociations(): ISchemaAssociation[] { @@ -936,4 +1032,3 @@ export namespace ErrorCodes { export function isSchemaResolveError(d: Diagnostic) { return typeof d.code === 'number' && d.code >= ErrorCodes.SchemaResolveError; } - diff --git a/extensions/json-language-features/client/src/languageStatus.ts b/extensions/json-language-features/client/src/languageStatus.ts index a608b4be7ca337..698bb2cfc36a45 100644 --- a/extensions/json-language-features/client/src/languageStatus.ts +++ b/extensions/json-language-features/client/src/languageStatus.ts @@ -163,7 +163,11 @@ function showSchemaList(input: ShowSchemasInput) { }); } -export function createLanguageStatusItem(documentSelector: DocumentSelector, statusRequest: (uri: string) => Promise): Disposable { +export interface LanguageStatusItem extends Disposable { + update(): void; +} + +export function createLanguageStatusItem(documentSelector: DocumentSelector, statusRequest: (uri: string) => Promise): LanguageStatusItem { const statusItem = languages.createLanguageStatusItem('json.projectStatus', documentSelector); statusItem.name = l10n.t('JSON Validation Status'); statusItem.severity = LanguageStatusSeverity.Information; @@ -213,7 +217,11 @@ export function createLanguageStatusItem(documentSelector: DocumentSelector, sta updateLanguageStatus(); - return Disposable.from(statusItem, activeEditorListener, showSchemasCommand); + const disposable = Disposable.from(statusItem, activeEditorListener, showSchemasCommand); + return { + update: updateLanguageStatus, + dispose: () => disposable.dispose() + }; } export function createLimitStatusItem(newItem: (limit: number) => Disposable) { @@ -361,4 +369,3 @@ export function createSchemaLoadIssueItem(documentSelector: DocumentSelector, sc return Disposable.from(statusItem); } - diff --git a/extensions/json-language-features/package.json b/extensions/json-language-features/package.json index c616bf19b19ecf..1fc51378e2ddf6 100644 --- a/extensions/json-language-features/package.json +++ b/extensions/json-language-features/package.json @@ -127,6 +127,27 @@ "usesOnlineServices" ] }, + "json.schemaStore.enable": { + "type": "boolean", + "default": true, + "description": "%json.enableSchemaStore.desc%", + "tags": [ + "usesOnlineServices" + ] + }, + "json.schemaStore.exclude": { + "type": "array", + "default": [], + "description": "%json.schemaStore.exclude.desc%", + "items": { + "type": "string", + "default": "**/openapi.json", + "description": "%json.schemaStore.exclude.item.desc%" + }, + "tags": [ + "usesOnlineServices" + ] + }, "json.schemaDownload.trustedDomains": { "type": "object", "default": { diff --git a/extensions/json-language-features/package.nls.json b/extensions/json-language-features/package.nls.json index 30199b2bb33f35..1f36d73c8b942d 100644 --- a/extensions/json-language-features/package.nls.json +++ b/extensions/json-language-features/package.nls.json @@ -17,6 +17,9 @@ "json.maxItemsComputed.desc": "The maximum number of outline symbols and folding regions computed (limited for performance reasons).", "json.maxItemsExceededInformation.desc": "Show notification when exceeding the maximum number of outline symbols and folding regions.", "json.enableSchemaDownload.desc": "When enabled, JSON schemas can be fetched from http and https locations.", + "json.enableSchemaStore.desc": "When enabled, JSON schema associations are loaded from the SchemaStore catalog.", + "json.schemaStore.exclude.desc": "Relative path selectors for files that should not receive JSON schema associations from the SchemaStore catalog. Other schema association mechanisms are unaffected. Absolute paths and paths containing `..` are ignored.", + "json.schemaStore.exclude.item.desc": "A relative path selector, such as `foo/bar/openapi.json` or `**/openapi.json`. Absolute paths and paths containing `..` are ignored.", "json.command.clearCache": "Clear Schema Cache", "json.command.sort": "Sort Document", "json.workspaceTrust": "The extension requires workspace trust to load schemas from http and https.", diff --git a/extensions/json-language-features/server/src/jsonServer.ts b/extensions/json-language-features/server/src/jsonServer.ts index 1e109fac5a3ef4..9d644e501c6e43 100644 --- a/extensions/json-language-features/server/src/jsonServer.ts +++ b/extensions/json-language-features/server/src/jsonServer.ts @@ -11,7 +11,7 @@ import { import { runSafe, runSafeAsync } from './utils/runner.js'; import { DiagnosticsSupport, registerDiagnosticsPullSupport, registerDiagnosticsPushSupport } from './utils/validation.js'; -import { TextDocument, JSONDocument, JSONSchema, getLanguageService, DocumentLanguageSettings, SchemaConfiguration, ClientCapabilities, Range, Position, SortOptions, SeverityLevel } from 'vscode-json-languageservice'; +import { TextDocument, JSONDocument, JSONSchema, getLanguageService, DocumentLanguageSettings, SchemaConfiguration, ClientCapabilities, Range, Position, SortOptions, SeverityLevel, LanguageSettings } from 'vscode-json-languageservice'; import { getLanguageModelCache } from './languageModelCache.js'; import { Utils, URI } from 'vscode-uri'; import * as l10n from '@vscode/l10n'; @@ -358,20 +358,21 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) }); function updateConfiguration(extraSchemas?: SchemaConfiguration[]) { - const languageSettings = { + const schemas = new Array(); + const languageSettings: LanguageSettings = { validate: validateEnabled, allowComments: true, - schemas: new Array() + schemas }; if (schemaAssociations) { if (Array.isArray(schemaAssociations)) { - Array.prototype.push.apply(languageSettings.schemas, schemaAssociations); + Array.prototype.push.apply(schemas, schemaAssociations); } else { for (const pattern in schemaAssociations) { const association = schemaAssociations[pattern]; if (Array.isArray(association)) { association.forEach(uri => { - languageSettings.schemas.push({ uri, fileMatch: [pattern] }); + schemas.push({ uri, fileMatch: [pattern] }); }); } } @@ -384,12 +385,12 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) uri = schema.schema.id || `vscode://schemas/custom/${index}`; } if (uri) { - languageSettings.schemas.push({ uri, fileMatch: schema.fileMatch, schema: schema.schema, folderUri: schema.folderUri }); + schemas.push({ uri, fileMatch: schema.fileMatch, schema: schema.schema, folderUri: schema.folderUri }); } }); } if (extraSchemas) { - languageSettings.schemas.push(...extraSchemas); + schemas.push(...extraSchemas); } languageService.configure(languageSettings); @@ -576,7 +577,3 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) function getFullRange(document: TextDocument): Range { return Range.create(Position.create(0, 0), document.positionAt(document.getText().length)); } - - - -