Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/jsonLanguageTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
58 changes: 52 additions & 6 deletions src/services/jsonSchemaService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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[] {
Expand Down Expand Up @@ -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);
}

Expand All @@ -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];
Expand All @@ -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<ResolvedSchema | undefined> {
const id = normalizeId(schemaId);
const schemaHandle = this.schemasById[id];
Expand Down Expand Up @@ -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[] {
Expand All @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion src/services/jsonValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
}

Expand Down
207 changes: 207 additions & 0 deletions src/test/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> => 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);
Expand Down
Loading