Skip to content

Commit 24c3800

Browse files
keegan-carusoKeegan CarusoCopilotaeschli
authored
JSON Schema 2019-09 support (#308)
* JSON Schema 2019-09 support - Add $vocabulary support for vocabulary-based keyword filtering - Implement $recursiveRef and $recursiveAnchor resolution - Add duration and uuid format validators - Fix $ref resolution with embedded $id schemas - Improve unevaluatedProperties handling with dependentSchemas - Support scope isolation for $ref with sibling keywords * recursiveAnchor should always be a bool * 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 * fix: correct vocabulary and draft-based keyword gating for JSON Schema 2019-09+ - Gate dependentRequired, dependentSchemas, unevaluatedProperties, unevaluatedItems, minContains, maxContains to 2019-09+ - Gate prefixItems to 2020-12+ - Gate dependencies to draft-07 and earlier - Apply vocabulary filtering only for 2019-09+ schemas - Make format annotation-only by default for 2019-09+ per spec - Support enabling format assertion via $vocabulary - Stop treating $id fragments as anchors in 2019-09+ - Fix relative $id resolution when reached via JSON pointer $ref - Highlight full object range for required property errors * JSONSchemaService: Prevent duplicate anchor errors and ensure correct anchor resolution in schemas * JSONSchemaService: Enhance sibling scope isolation for referenced schemas with additional tests * Fix Goto Definition for anchors/embedded schemas, fix relative $schema vocabulary resolution Address PR review feedback for JSON Schema 2019-09 support: - Fix Goto Definition (findLinks) to support plain-name anchor fragments (#foo via $anchor or legacy $id), and embedded schema references by $id URI - Fix vocabulary extraction for custom dialects referenced via relative $schema URI (e.g. "./dialect.jsonc") by resolving against the schema's base URI before fetching - Extract repeated absolute URI scheme regex into hasSchemeRegex constant - Add tests for: anchor/embedded Goto Definition, $ref siblings ignored in draft-07, unevaluatedProperties/unevaluatedItems ignored in draft-07, nested embedded schemas, and vocabulary disable with relative URI * fix: honor 2019-09 format vocabulary assertion flag Treat the JSON Schema draft 2019-09 format vocabulary value as the assertion switch: true enables supported format validation errors, while false keeps format annotation-only. Update schema and vocabulary tests to cover both required and optional 2019-09 format vocabulary behavior. * refactor: address review feedback on $ref merge internals and vocabulary table Addresses reviewer feedback on the 2019-09 support PR: - Introduce a `MergedJSONSchema` interface (extends `JSONSchema` with a typed `$originalId`) to replace the untyped `_originalId` hidden property used to preserve a referenced schema's `$id` after a `$ref` merge. `$originalId` is still defined non-enumerable so it stays out of `for..in` iteration and JSON serialization, but consumers no longer need `<any>` casts. - Hoist the `vocabularyKeywords` table in vocabularies.ts to module scope so it is not rebuilt on every `isKeywordEnabled` call. - Fix base-URI tracking so a schema's own `$id` rebases its subtree even at the root of a traversal. The second resolution pass (recursing from resolveExternalLink) previously inherited the external schema's URI as its base, so nested `$id` values were resolved against the wrong base. Re-deriving the base from the root's own `$id` corrects this. - With base tracking fixed, stop mutating the cached source schema in mergeRef: clone the section before rewriting an inner relative `$ref` to an absolute URI, instead of rewriting it in place. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Keegan Caruso <keegancaruso@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Martin Aeschlimann <martinae@microsoft.com>
1 parent 5d87d16 commit 24c3800

12 files changed

Lines changed: 3422 additions & 399 deletions

src/jsonLanguageTypes.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ export {
3838
TextDocumentEdit, VersionedTextDocumentIdentifier, DocumentHighlightKind
3939
};
4040

41+
/**
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).
46+
* @since 2019-09
47+
*/
48+
export type Vocabularies = Map<string, boolean>;
49+
4150
/**
4251
* Error codes used by diagnostics
4352
*/

src/jsonSchema.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ export interface JSONSchema {
6464
$defs?: { [name: string]: JSONSchema };
6565
$anchor?: string;
6666
$recursiveRef?: string;
67-
$recursiveAnchor?: string;
68-
$vocabulary?: any;
67+
$recursiveAnchor?: boolean;
68+
$vocabulary?: { [uri: string]: boolean };
6969

7070
// schema 2020-12
7171
prefixItems?: JSONSchemaRef[];
@@ -93,3 +93,16 @@ export interface JSONSchema {
9393
export interface JSONSchemaMap {
9494
[name: string]: JSONSchemaRef;
9595
}
96+
97+
/**
98+
* Internal extension of {@link JSONSchema} used when one schema is merged into
99+
* another (e.g. when resolving `$ref`). The `$originalId` field preserves the
100+
* `$id`/`id` of the referenced schema so features like `$recursiveRef` can
101+
* still identify schema resource roots after the merge.
102+
*
103+
* `$originalId` is set as a non-enumerable property to keep it out of
104+
* `for..in` iteration and JSON serialization.
105+
*/
106+
export interface MergedJSONSchema extends JSONSchema {
107+
$originalId?: string;
108+
}

src/parser/jsonParser.ts

Lines changed: 196 additions & 93 deletions
Large diffs are not rendered by default.

src/services/jsonLinks.ts

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,41 @@ function createRange(document: TextDocument, node: ASTNode): Range {
3232

3333
function findTargetNode(doc: JSONDocument, path: string): ASTNode | null {
3434
const tokens = parseJSONPointer(path);
35-
if (!tokens) {
35+
if (tokens) {
36+
return findNode(tokens, doc.root);
37+
}
38+
39+
if (path.charAt(0) === '#') {
40+
// Plain-name fragment: anchor reference (e.g. #foo)
41+
const anchor = path.substring(1);
42+
if (anchor.length > 0) {
43+
return findAnchorNode(doc, anchor);
44+
}
3645
return null;
3746
}
38-
return findNode(tokens, doc.root);
47+
48+
// Check for references to embedded schemas by $id (e.g. "https://example.com/embedded")
49+
const hashIndex = path.indexOf('#');
50+
const uri = hashIndex >= 0 ? path.substring(0, hashIndex) : path;
51+
const fragment = hashIndex >= 0 ? path.substring(hashIndex + 1) : undefined;
52+
53+
if (uri.length > 0) {
54+
const embeddedNode = findEmbeddedSchemaNode(doc, uri);
55+
if (embeddedNode) {
56+
if (!fragment || fragment.length === 0) {
57+
return embeddedNode;
58+
}
59+
if (fragment.charAt(0) === '/') {
60+
// JSON Pointer within the embedded schema
61+
const pointerTokens = fragment.substring(1).split(/\//).map(unescape);
62+
return findNode(pointerTokens, embeddedNode);
63+
}
64+
// Anchor within the embedded schema
65+
return findAnchorInSubtree(embeddedNode, fragment);
66+
}
67+
}
68+
69+
return null;
3970
}
4071

4172
function findNode(pointer: string[], node: ASTNode | null | undefined): ASTNode | null {
@@ -66,6 +97,73 @@ function findNode(pointer: string[], node: ASTNode | null | undefined): ASTNode
6697
return null;
6798
}
6899

100+
function findAnchorNode(doc: JSONDocument, anchor: string): ASTNode | null {
101+
return findAnchorInSubtree(doc.root, anchor);
102+
}
103+
104+
function findAnchorInSubtree(root: ASTNode | null | undefined, anchor: string): ASTNode | null {
105+
if (!root) {
106+
return null;
107+
}
108+
let result: ASTNode | null = null;
109+
const visit = (node: ASTNode): boolean => {
110+
if (node.type === 'object') {
111+
for (const prop of node.properties) {
112+
// $anchor: "foo" (2019-09+)
113+
if (prop.keyNode.value === '$anchor' && prop.valueNode?.type === 'string' && prop.valueNode.value === anchor) {
114+
result = node;
115+
return false;
116+
}
117+
// $id: "#foo" (draft-06/07 legacy anchors)
118+
if (prop.keyNode.value === '$id' && prop.valueNode?.type === 'string' && prop.valueNode.value === '#' + anchor) {
119+
result = node;
120+
return false;
121+
}
122+
}
123+
}
124+
const children = node.children;
125+
if (children) {
126+
for (const child of children) {
127+
if (!visit(child)) {
128+
return false;
129+
}
130+
}
131+
}
132+
return true;
133+
};
134+
visit(root);
135+
return result;
136+
}
137+
138+
function findEmbeddedSchemaNode(doc: JSONDocument, uri: string): ASTNode | null {
139+
if (!doc.root) {
140+
return null;
141+
}
142+
let result: ASTNode | null = null;
143+
const visit = (node: ASTNode, isRoot: boolean): boolean => {
144+
if (node.type === 'object' && !isRoot) {
145+
for (const prop of node.properties) {
146+
if ((prop.keyNode.value === '$id' || prop.keyNode.value === 'id') &&
147+
prop.valueNode?.type === 'string' && prop.valueNode.value === uri) {
148+
result = node;
149+
return false;
150+
}
151+
}
152+
}
153+
const children = node.children;
154+
if (children) {
155+
for (const child of children) {
156+
if (!visit(child, false)) {
157+
return false;
158+
}
159+
}
160+
}
161+
return true;
162+
};
163+
visit(doc.root, true);
164+
return result;
165+
}
166+
69167
function parseJSONPointer(path: string): string[] | null {
70168
if (path === "#") {
71169
return [];

0 commit comments

Comments
 (0)