diff --git a/src/jsonLanguageTypes.ts b/src/jsonLanguageTypes.ts index af765ac5..c8bb7bb4 100644 --- a/src/jsonLanguageTypes.ts +++ b/src/jsonLanguageTypes.ts @@ -130,6 +130,14 @@ export interface MatchingSchema { export interface JSONLanguageStatus { schemas: string[]; + schemaDiagnostics?: JSONLanguageStatusDiagnostic[]; +} + +export interface JSONLanguageStatusDiagnostic { + message: string; + code: ErrorCode; + severity: SeverityLevel; + uri?: string; } export interface LanguageSettings { @@ -209,6 +217,10 @@ export interface SchemaConfiguration { * if the document that is validated has the folderUri as parent */ folderUri?: string; + /** + * The origin of this schema association. + */ + source?: 'schemaStore'; } export interface WorkspaceContextService { diff --git a/src/services/jsonSchemaService.ts b/src/services/jsonSchemaService.ts index d8683c6a..1c7717e0 100644 --- a/src/services/jsonSchemaService.ts +++ b/src/services/jsonSchemaService.ts @@ -8,7 +8,7 @@ import { JSONSchema, JSONSchemaRef, MergedJSONSchema, DynamicRefInfo, AnchorMaps import { URI } from 'vscode-uri'; import * as Strings from '../utils/strings.js'; import { asSchema, getSchemaDraftFromId, JSONDocument, normalizeId } from '../parser/jsonParser.js'; -import { SchemaRequestService, WorkspaceContextService, PromiseConstructor, MatchingSchema, TextDocument, SchemaConfiguration, SchemaDraft, ErrorCode, Vocabularies } from '../jsonLanguageTypes.js'; +import { SchemaRequestService, WorkspaceContextService, PromiseConstructor, MatchingSchema, TextDocument, SchemaConfiguration, SchemaDraft, ErrorCode, Vocabularies, JSONLanguageStatusDiagnostic } from '../jsonLanguageTypes.js'; import * as l10n from '@vscode/l10n'; import { createRegex } from '../utils/glob.js'; @@ -277,6 +277,8 @@ export class JSONSchemaService implements IJSONSchemaService { private callOnDispose: Function[]; private requestService: SchemaRequestService | undefined; private promiseConstructor: PromiseConstructor; + private schemaStoreSchemaIds: { [id: string]: boolean }; + private schemaDiagnosticsByResource: { [resource: string]: JSONLanguageStatusDiagnostic[] }; private static traverseSchemaProperties(node: JSONSchema, callback: (schema: JSONSchema) => void): void { // `$defs`/`definitions` are visited first so that a reusable resource they @@ -336,6 +338,8 @@ export class JSONSchemaService implements IJSONSchemaService { this.schemasById = {}; this.filePatternAssociations = []; this.registeredSchemasIds = {}; + this.schemaStoreSchemaIds = {}; + this.schemaDiagnosticsByResource = {}; } public getRegisteredSchemaIds(filter?: (scheme: string) => boolean): string[] { @@ -425,6 +429,9 @@ export class JSONSchemaService implements IJSONSchemaService { if (config.fileMatch && config.fileMatch.length) { this.addFilePatternAssociation(config.fileMatch, config.folderUri, [id]); } + if (config.source === 'schemaStore') { + this.schemaStoreSchemaIds[id] = true; + } return config.schema ? this.addSchemaHandle(id, config.schema) : this.getOrAddSchemaHandle(id); } @@ -433,6 +440,8 @@ export class JSONSchemaService implements IJSONSchemaService { this.filePatternAssociations = []; this.registeredSchemasIds = {}; this.cachedSchemaForResource = undefined; + this.schemaStoreSchemaIds = {}; + this.schemaDiagnosticsByResource = {}; for (const id in this.contributionSchemas) { this.schemasById[id] = this.contributionSchemas[id]; @@ -443,6 +452,24 @@ export class JSONSchemaService implements IJSONSchemaService { } } + public getSchemaDiagnosticsForResource(resource: string): JSONLanguageStatusDiagnostic[] | undefined { + const diagnostics: JSONLanguageStatusDiagnostic[] = []; + const resourceDiagnostics = this.schemaDiagnosticsByResource[resource]; + if (resourceDiagnostics) { + diagnostics.push(...resourceDiagnostics); + } + return diagnostics.length ? diagnostics : undefined; + } + + private toStatusDiagnostics(diagnostics: readonly SchemaDiagnostic[], uri: string | undefined): JSONLanguageStatusDiagnostic[] { + return diagnostics.map(diagnostic => ({ + message: diagnostic.message, + code: diagnostic.code, + severity: 'warning', + uri + })); + } + public getResolvedSchema(schemaId: string): PromiseLike { const id = normalizeId(schemaId); const schemaHandle = this.schemasById[id]; @@ -1285,7 +1312,8 @@ export class JSONSchemaService implements IJSONSchemaService { } } } - return schemas; + const nonSchemaStoreSchemas = schemas.filter(schemaId => !this.schemaStoreSchemaIds[schemaId]); + return nonSchemaStoreSchemas.length ? nonSchemaStoreSchemas : schemas; } public getSchemaURIsForResource(resource: string, document?: JSONDocument): string[] { @@ -1308,10 +1336,28 @@ export class JSONSchemaService implements IJSONSchemaService { if (this.cachedSchemaForResource && this.cachedSchemaForResource.resource === resource) { return this.cachedSchemaForResource.resolvedSchema; } - const schemas = this.getAssociatedSchemas(resource); - const resolvedSchema = schemas.length > 0 ? this.createCombinedSchema(resource, schemas).getResolvedSchema() : this.promise.resolve(undefined); - this.cachedSchemaForResource = { resource, resolvedSchema }; - return resolvedSchema; + const resolveSchema = () => { + const schemas = this.getAssociatedSchemas(resource); + const resolvedSchema = schemas.length > 0 ? this.createCombinedSchema(resource, schemas).getResolvedSchema() : this.promise.resolve(undefined); + const usesSchemaStore = schemas.some(schemaId => this.schemaStoreSchemaIds[schemaId]); + if (usesSchemaStore) { + const resolvedSchemaWithDiagnostics = resolvedSchema.then(schema => { + const diagnostics = schema ? this.toStatusDiagnostics([...schema.errors, ...schema.warnings], schemas.length === 1 ? schemas[0] : undefined) : []; + if (diagnostics.length) { + this.schemaDiagnosticsByResource[resource] = diagnostics; + } else { + delete this.schemaDiagnosticsByResource[resource]; + } + return schema; + }); + this.cachedSchemaForResource = { resource, resolvedSchema: resolvedSchemaWithDiagnostics }; + return resolvedSchemaWithDiagnostics; + } + delete this.schemaDiagnosticsByResource[resource]; + this.cachedSchemaForResource = { resource, resolvedSchema }; + return resolvedSchema; + }; + return resolveSchema(); } private createCombinedSchema(resource: string, schemaIds: string[]): ISchemaHandle { diff --git a/src/services/jsonValidation.ts b/src/services/jsonValidation.ts index 70c35270..17bfb977 100644 --- a/src/services/jsonValidation.ts +++ b/src/services/jsonValidation.ts @@ -121,7 +121,8 @@ export class JSONValidation { } public getLanguageStatus(textDocument: TextDocument, jsonDocument: JSONDocument): JSONLanguageStatus { - return { schemas: this.jsonSchemaService.getSchemaURIsForResource(textDocument.uri, jsonDocument) }; + const schemaDiagnostics = this.jsonSchemaService.getSchemaDiagnosticsForResource(textDocument.uri); + return { schemas: this.jsonSchemaService.getSchemaURIsForResource(textDocument.uri, jsonDocument), schemaDiagnostics }; } } diff --git a/src/test/schema.test.ts b/src/test/schema.test.ts index 6d016811..e8ada0a7 100644 --- a/src/test/schema.test.ts +++ b/src/test/schema.test.ts @@ -1180,6 +1180,213 @@ suite('JSON Schema', () => { assert.strictEqual(section?.description, 'Meaning of Life'); }); + test('SchemaStore source associations validate file matches', async function () { + const schemaUri = 'https://www.schemastore.org/package.json'; + const accesses: string[] = []; + const service = getLanguageService({ + schemaRequestService: newMockRequestService({ + [schemaUri]: { + type: 'object', + properties: { + name: { type: 'string' } + } + } + }, accesses), + workspaceContext + }); + service.configure({ + schemas: [{ + uri: schemaUri, + fileMatch: ['package.json'], + source: 'schemaStore' + }] + }); + const { textDoc, jsonDoc } = toDocument('{ "name": 1 }', undefined, 'file:///workspace/package.json'); + + const diagnostics = await service.doValidation(textDoc, jsonDoc); + + assert.strictEqual(diagnostics.length, 1); + assert.strictEqual(diagnostics[0].message, 'Incorrect type. Expected "string".'); + assert.deepStrictEqual(accesses, [schemaUri]); + }); + + test('$schema takes precedence over SchemaStore file matches', async function () { + const schemaStoreUri = 'https://www.schemastore.org/package.json'; + const explicitSchemaUri = 'https://example.com/custom-package.schema.json'; + const accesses: string[] = []; + const service = getLanguageService({ + schemaRequestService: newMockRequestService({ + [schemaStoreUri]: { + type: 'object', + properties: { + name: { type: 'string' } + } + }, + [explicitSchemaUri]: { + type: 'object', + properties: { + name: { type: 'number' } + } + } + }, accesses), + workspaceContext + }); + service.configure({ + schemas: [{ + uri: schemaStoreUri, + fileMatch: ['package.json'], + source: 'schemaStore' + }] + }); + const { textDoc, jsonDoc } = toDocument(`{ "$schema": "${explicitSchemaUri}", "name": 1 }`, undefined, 'file:///workspace/package.json'); + + const diagnostics = await service.doValidation(textDoc, jsonDoc); + const status = service.getLanguageStatus(textDoc, jsonDoc); + + assert.deepStrictEqual(diagnostics, []); + assert.deepStrictEqual(status.schemas, [explicitSchemaUri]); + assert.deepStrictEqual(accesses, [explicitSchemaUri]); + }); + + test('Configured schemas take precedence over SchemaStore file matches', async function () { + const schemaStoreUri = 'https://www.schemastore.org/package.json'; + const configuredSchemaUri = 'https://example.com/configured-package.schema.json'; + const accesses: string[] = []; + const service = getLanguageService({ + schemaRequestService: newMockRequestService({ + [schemaStoreUri]: { + type: 'object', + properties: { + name: { type: 'string' } + } + } + }, accesses), + workspaceContext + }); + service.configure({ + schemas: [{ + uri: schemaStoreUri, + fileMatch: ['package.json'], + source: 'schemaStore' + }, { + uri: configuredSchemaUri, + fileMatch: ['package.json'], + schema: { + type: 'object', + properties: { + name: { type: 'number' } + } + } + }] + }); + const { textDoc, jsonDoc } = toDocument('{ "name": 1 }', undefined, 'file:///workspace/package.json'); + + const diagnostics = await service.doValidation(textDoc, jsonDoc); + const status = service.getLanguageStatus(textDoc, jsonDoc); + + assert.deepStrictEqual(diagnostics, []); + assert.deepStrictEqual(status.schemas, [configuredSchemaUri]); + assert.deepStrictEqual(accesses, []); + }); + + test('SchemaStore source associations support exclusion file matches', async function () { + const schemaStoreUri = 'https://www.schemastore.org/package.json'; + const accesses: string[] = []; + const service = getLanguageService({ + schemaRequestService: newMockRequestService({ + [schemaStoreUri]: { + type: 'object', + properties: { + name: { type: 'string' } + } + } + }, accesses), + workspaceContext + }); + service.configure({ + schemas: [{ + uri: schemaStoreUri, + fileMatch: ['package.json', '!**/package.json'], + source: 'schemaStore' + }] + }); + const { textDoc, jsonDoc } = toDocument('{ "name": 1 }', undefined, 'file:///workspace/package.json'); + + const diagnostics = await service.doValidation(textDoc, jsonDoc); + const status = service.getLanguageStatus(textDoc, jsonDoc); + + assert.deepStrictEqual(diagnostics, []); + assert.deepStrictEqual(status.schemas, []); + assert.deepStrictEqual(accesses, []); + }); + + test('SchemaStore exclusions do not prevent configured schemas', async function () { + const schemaStoreUri = 'https://www.schemastore.org/package.json'; + const configuredSchemaUri = 'https://example.com/configured-package.schema.json'; + const accesses: string[] = []; + const service = getLanguageService({ + schemaRequestService: newMockRequestService({ + [schemaStoreUri]: { + type: 'object', + properties: { + name: { type: 'string' } + } + } + }, accesses), + workspaceContext + }); + service.configure({ + schemas: [{ + uri: schemaStoreUri, + fileMatch: ['package.json', '!package.json'], + source: 'schemaStore' + }, { + uri: configuredSchemaUri, + fileMatch: ['package.json'], + schema: { + type: 'object', + properties: { + name: { type: 'number' } + } + } + }] + }); + const { textDoc, jsonDoc } = toDocument('{ "name": 1 }', undefined, 'file:///workspace/package.json'); + + const diagnostics = await service.doValidation(textDoc, jsonDoc); + const status = service.getLanguageStatus(textDoc, jsonDoc); + + assert.deepStrictEqual(diagnostics, []); + assert.deepStrictEqual(status.schemas, [configuredSchemaUri]); + assert.deepStrictEqual(accesses, []); + }); + + test('SchemaStore schema request failures are reported in diagnostics and language status', async function () { + const schemaUri = 'https://thirdparty.example/package.schema.json'; + const schemaRequestService: SchemaRequestService = async (): Promise => Promise.reject({ message: 'Blocked by schema allow-list', code: 1 }); + const service = getLanguageService({ schemaRequestService, workspaceContext }); + service.configure({ + schemas: [{ + uri: schemaUri, + fileMatch: ['package.json'], + source: 'schemaStore' + }] + }); + const { textDoc, jsonDoc } = toDocument('{ "name": 1 }', undefined, 'file:///workspace/package.json'); + + const diagnostics = await service.doValidation(textDoc, jsonDoc, { schemaRequest: 'error' }); + const status = service.getLanguageStatus(textDoc, jsonDoc); + + assert.strictEqual(diagnostics.length, 1); + assert.strictEqual(diagnostics[0].code, ErrorCode.SchemaResolveError + 1); + assertInMessage(diagnostics[0].message, 'Blocked by schema allow-list'); + assert.strictEqual(status.schemaDiagnostics?.length, 1); + assert.strictEqual(status.schemaDiagnostics?.[0].code, ErrorCode.SchemaResolveError + 1); + assertInMessage(status.schemaDiagnostics?.[0].message, 'Blocked by schema allow-list'); + assert.strictEqual(status.schemaDiagnostics?.[0].uri, schemaUri); + assert.strictEqual(status.schemaDiagnostics?.[0].severity, 'warning'); + }); + test('Resolving in-line $refs', async function () { const service = new SchemaService.JSONSchemaService(newMockRequestService(), workspaceContext);