Skip to content

Commit eb89383

Browse files
author
Keegan Caruso
committed
Add vocabulary tracking and format assertion support for JSON Schema 2019-09
- Change Vocabularies type from Set to Map to track required/optional status - Add isFormatAssertionEnabled for format-annotation vs format-assertion - Handle $ref sibling keywords correctly per draft version - Only recognize $anchor in draft-2019-09 and later schemas - Fix dependencies keyword to only apply in draft-07 and earlier - Fix missing property error location to use object offset
1 parent ab8a346 commit eb89383

File tree

8 files changed

+700
-146
lines changed

8 files changed

+700
-146
lines changed

src/jsonLanguageTypes.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,13 @@ export {
3939
};
4040

4141
/**
42-
* Represents a set of active JSON Schema vocabularies.
43-
* Used to filter which keywords are processed during validation based on the metaschema's $vocabulary declaration.
42+
* Represents active JSON Schema vocabularies with their required/optional status.
43+
* Key = vocabulary URI, Value = true if required, false if optional.
44+
* Both required and optional vocabularies are active; the boolean indicates
45+
* whether the validator must understand it (true) or should understand it (false).
4446
* @since 2019-09
4547
*/
46-
export type Vocabularies = Set<string>;
48+
export type Vocabularies = Map<string, boolean>;
4749

4850
/**
4951
* Error codes used by diagnostics

src/parser/jsonParser.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { isNumber, equals, isBoolean, isString, isDefined, isObject } from '../u
99
import { extendedRegExp, stringLength } from '../utils/strings';
1010
import { TextDocument, ASTNode, ObjectASTNode, ArrayASTNode, BooleanASTNode, NumberASTNode, StringASTNode, NullASTNode, PropertyASTNode, JSONPath, ErrorCode, Diagnostic, DiagnosticSeverity, Range, SchemaDraft, Vocabularies } from '../jsonLanguageTypes';
1111
import { URI } from 'vscode-uri';
12-
import { isKeywordEnabled } from '../services/vocabularies';
12+
import { isKeywordEnabled, isFormatAssertionEnabled } from '../services/vocabularies';
1313

1414
import * as l10n from '@vscode/l10n';
1515

@@ -454,7 +454,16 @@ function validate(n: ASTNode | undefined, schema: JSONSchema, validationResult:
454454
// Push current schema to stack for $recursiveRef resolution
455455
schemaStack.push(schema);
456456

457-
const enabled = (keyword: string) => isKeywordEnabled(keyword, context.activeVocabularies);
457+
const enabled = (keyword: string) => {
458+
// Special case for 'dependencies' which is a core keyword in draft-04 through draft-07 but moved to
459+
// dependentSchemas and dependentRequired in later versions.
460+
if (keyword === 'dependencies')
461+
{
462+
return context.schemaDraft <= SchemaDraft.v7;
463+
}
464+
465+
return isKeywordEnabled(keyword, context.activeVocabularies);
466+
};
458467

459468
_validateNode();
460469

@@ -849,7 +858,8 @@ function validate(n: ASTNode | undefined, schema: JSONSchema, validationResult:
849858
}
850859
}
851860

852-
if (schema.format && enabled('format')) {
861+
// Only validate format if format-assertion vocabulary is active (not annotation-only)
862+
if (schema.format && enabled('format') && isFormatAssertionEnabled(context.activeVocabularies)) {
853863
switch (schema.format) {
854864
case 'uri':
855865
case 'uri-reference': {
@@ -1044,10 +1054,8 @@ function validate(n: ASTNode | undefined, schema: JSONSchema, validationResult:
10441054
if (Array.isArray(schema.required) && enabled('required')) {
10451055
for (const propertyName of schema.required) {
10461056
if (!seenKeys[propertyName]) {
1047-
const keyNode = node.parent && node.parent.type === 'property' && node.parent.keyNode;
1048-
const location = keyNode ? { offset: keyNode.offset, length: keyNode.length } : { offset: node.offset, length: 1 };
10491057
validationResult.problems.push({
1050-
location: location,
1058+
location: { offset: node.offset, length: 1 },
10511059
message: l10n.t('Missing property "{0}".', propertyName)
10521060
});
10531061
}

src/services/jsonSchemaService.ts

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,12 @@ class SchemaHandle implements ISchemaHandle {
177177
this.anchors = undefined;
178178
return hasChanges;
179179
}
180+
181+
public setSchemaContent(schemaContent: JSONSchema): void {
182+
this.unresolvedSchema = this.service.promise.resolve(new UnresolvedSchema(schemaContent));
183+
this.resolvedSchema = undefined;
184+
this.anchors = undefined;
185+
}
180186
}
181187

182188

@@ -503,11 +509,12 @@ export class JSONSchemaService implements IJSONSchemaService {
503509
if (!metaschema.$vocabulary || typeof metaschema.$vocabulary !== 'object') {
504510
return undefined;
505511
}
506-
const vocabs = new Set<string>();
512+
// Both true and false values indicate the vocabulary is active.
513+
// The boolean indicates whether the vocabulary is required (true) or optional (false),
514+
// not whether it's in use. All listed vocabularies should be included.
515+
const vocabs = new Map<string, boolean>();
507516
for (const [uri, required] of Object.entries(metaschema.$vocabulary)) {
508-
if (required === true) {
509-
vocabs.add(uri);
510-
}
517+
vocabs.set(uri, required);
511518
}
512519
return vocabs.size > 0 ? vocabs : undefined;
513520
};
@@ -587,13 +594,25 @@ export class JSONSchemaService implements IJSONSchemaService {
587594
section = findSchemaById(sourceRoot, sourceHandle, refSegment);
588595
}
589596
if (section) {
590-
// In JSON Schema 2019-09 or greater, $ref creates a new scope when it has sibling keywords.
591-
// When the $ref'd schema has unevaluatedProperties/unevaluatedItems, it should not
592-
// see properties/items evaluated by sibling keywords.
593-
// To achieve this, we wrap the $ref in an allOf when needed.
594-
if (needsScopeIsolation(section, target)) {
595-
// Extract sibling keywords into a separate schema
596-
const reservedKeys = new Set(['$ref', '$defs', 'definitions', '$schema', '$id', 'id']);
597+
const reservedKeys = new Set(['$ref', '$defs', 'definitions', '$schema', '$id', 'id']);
598+
599+
// In JSON Schema draft-04 through draft-07, $ref completely overrides any sibling keywords.
600+
// Starting in 2019-09, sibling keywords are processed alongside $ref.
601+
// Only strip siblings when schema explicitly declares a pre-2019-09 draft via $schema.
602+
const isPreDraft201909 = schemaDraft !== undefined && schemaDraft < SchemaDraft.v2019_09;
603+
if (isPreDraft201909) {
604+
// Clear all sibling keywords from target - $ref takes precedence
605+
for (const key in target) {
606+
if (target.hasOwnProperty(key) && !reservedKeys.has(key)) {
607+
delete (target as any)[key];
608+
}
609+
}
610+
merge(target, section);
611+
} else if (needsScopeIsolation(section, target)) {
612+
// In JSON Schema 2019-09 or greater, $ref creates a new scope when it has sibling keywords.
613+
// When the $ref'd schema has unevaluatedProperties/unevaluatedItems, it should not
614+
// see properties/items evaluated by sibling keywords.
615+
// To achieve this, we wrap the $ref in an allOf when needed.
597616
const siblingSchema: JSONSchema = {};
598617
const refSchema = { ...section };
599618

@@ -728,7 +747,11 @@ export class JSONSchemaService implements IJSONSchemaService {
728747
}
729748

730749
// Collect anchor from this node
731-
const anchor = isString(id) && id.charAt(0) === '#' ? id.substring(1) : node.$anchor;
750+
// In draft-04/06/07, anchors are defined via $id/#fragment (e.g., "$id": "#myanchor")
751+
// $anchor was introduced in draft-2019-09, so only use it for 2019-09 and later
752+
const fragmentAnchor = isString(id) && id.charAt(0) === '#' ? id.substring(1) : undefined;
753+
const dollarAnchor = (schemaDraft === undefined || schemaDraft >= SchemaDraft.v2019_09) ? node.$anchor : undefined;
754+
const anchor = fragmentAnchor ?? dollarAnchor;
732755
if (anchor) {
733756
if (result.has(anchor)) {
734757
resolveErrors.push(toDiagnostic(l10n.t('Duplicate anchor declaration: \'{0}\'', anchor), ErrorCode.SchemaResolveError));
@@ -771,8 +794,13 @@ export class JSONSchemaService implements IJSONSchemaService {
771794
let newBaseUri = currentBaseUri;
772795
if (isString(id) && id.charAt(0) !== '#') {
773796
const resolvedUri = resolveId(id, currentBaseUri);
774-
if (!this.schemasById[resolvedUri]) {
797+
const existingHandle = this.schemasById[resolvedUri];
798+
if (!existingHandle) {
775799
this.addSchemaHandle(resolvedUri, node);
800+
} else {
801+
// Update existing handle with embedded schema content
802+
// This ensures embedded schemas take precedence over external schemas
803+
existingHandle.setSchemaContent(node);
776804
}
777805
newBaseUri = resolvedUri;
778806
}
@@ -803,7 +831,7 @@ export class JSONSchemaService implements IJSONSchemaService {
803831
// Only extract vocabularies if the meta-schema has a $vocabulary property
804832
// or if it's draft 2019-09 or later which support vocabularies.
805833
const metaschemaDraft = unresolvedMetaschema.schema.$schema ? getSchemaDraftFromId(unresolvedMetaschema.schema.$schema) : undefined;
806-
const isDraft2019OrLater = metaschemaDraft === SchemaDraft.v2019_09 || metaschemaDraft === SchemaDraft.v2020_12;
834+
const isDraft2019OrLater = metaschemaDraft && metaschemaDraft >= SchemaDraft.v2019_09;
807835
const hasVocabulary = unresolvedMetaschema.schema.$vocabulary && typeof unresolvedMetaschema.schema.$vocabulary === 'object';
808836

809837
if (hasVocabulary || isDraft2019OrLater) {

src/services/vocabularies.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
/**
6+
/*
77
* Checks if a keyword is enabled based on the active vocabularies.
88
* If no vocabulary constraints are present, all keywords are enabled.
99
* Core keywords are always enabled regardless of vocabulary settings.
@@ -14,7 +14,7 @@
1414
*/
1515
export function isKeywordEnabled(
1616
keyword: string,
17-
activeVocabularies?: Set<string>
17+
activeVocabularies?: Map<string, boolean>
1818
): boolean {
1919
const vocabularyKeywords: { [uri: string]: string[] } = {
2020
'https://json-schema.org/draft/2019-09/vocab/core': [
@@ -75,7 +75,6 @@ export function isKeywordEnabled(
7575
]
7676
};
7777

78-
7978
// If no vocabulary constraints, treat all keywords as enabled
8079
if (!activeVocabularies) {
8180
return true;
@@ -100,3 +99,44 @@ export function isKeywordEnabled(
10099
// Keyword not found in any vocabulary - disable it
101100
return false;
102101
}
102+
103+
/*
104+
* Checks if format validation should produce assertion errors.
105+
*
106+
* According to JSON Schema 2020-12:
107+
* - format-annotation: format is purely informational, no validation errors
108+
* - format-assertion: format must be validated and can produce errors
109+
*
110+
* For backwards compatibility:
111+
* - If no vocabularies are specified, format asserts (pre-2019-09 behavior)
112+
* - 2019-09 format vocabulary is annotation-only
113+
* - 2020-12 format-assertion vocabulary asserts
114+
* - 2020-12 format-annotation vocabulary does not assert
115+
*
116+
* @param activeVocabularies Map of active vocabulary URIs to required flag, or undefined if no constraints
117+
* @returns true if format validation should produce errors, false if annotation-only
118+
*/
119+
export function isFormatAssertionEnabled(activeVocabularies?: Map<string, boolean>): boolean {
120+
// If no vocabulary constraints or empty vocabulary map, assert for backward compatibility with older schemas
121+
if (!activeVocabularies || activeVocabularies.size === 0) {
122+
return true;
123+
}
124+
125+
// 2020-12 format-assertion explicitly enables format validation errors
126+
if (activeVocabularies.has('https://json-schema.org/draft/2020-12/vocab/format-assertion')) {
127+
return true;
128+
}
129+
130+
// 2019-09 format vocabulary is annotation-only per spec
131+
if (activeVocabularies.has('https://json-schema.org/draft/2019-09/vocab/format')) {
132+
return false;
133+
}
134+
135+
// 2020-12 format-annotation is annotation-only, no assertion
136+
if (activeVocabularies.has('https://json-schema.org/draft/2020-12/vocab/format-annotation')) {
137+
return false;
138+
}
139+
140+
// No format vocabulary active - no assertion
141+
return false;
142+
}

0 commit comments

Comments
 (0)