Skip to content

Commit 5c8674a

Browse files
author
Keegan Caruso
committed
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
1 parent f822189 commit 5c8674a

File tree

4 files changed

+407
-8
lines changed

4 files changed

+407
-8
lines changed

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 [];

src/services/jsonSchemaService.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import { createRegex } from '../utils/glob.js';
1515
import { isString } from '../utils/objects.js';
1616
import { DiagnosticRelatedInformation, Range } from 'vscode-languageserver-types';
1717

18+
const hasSchemePattern = /^[A-Za-z][A-Za-z0-9+\-.+]*:\/.*/.source;
19+
const hasSchemeRegex = new RegExp(hasSchemePattern);
20+
1821
export interface IJSONSchemaService {
1922

2023
/**
@@ -555,7 +558,7 @@ export class JSONSchemaService implements IJSONSchemaService {
555558
const id = getSchemaId(current);
556559
if (isString(id) && id.charAt(0) !== '#') {
557560
let resolvedUri = id;
558-
if (contextService && !/^[A-Za-z][A-Za-z0-9+\-.+]*:\/.*/.test(id)) {
561+
if (contextService && !hasSchemeRegex.test(id)) {
559562
resolvedUri = contextService.resolveRelativePath(id, currentBaseHandle.uri);
560563
}
561564
resolvedUri = normalizeId(resolvedUri);
@@ -702,7 +705,7 @@ export class JSONSchemaService implements IJSONSchemaService {
702705
if (section.$ref && sectionBaseHandle !== sourceHandle) {
703706
const innerRef = section.$ref;
704707
const innerSegments = innerRef.split('#', 2);
705-
if (innerSegments[0].length > 0 && contextService && !/^[A-Za-z][A-Za-z0-9+\-.+]*:\/.*/.test(innerSegments[0])) {
708+
if (innerSegments[0].length > 0 && contextService && !hasSchemeRegex.test(innerSegments[0])) {
706709
section.$ref = contextService.resolveRelativePath(innerSegments[0], sectionBaseHandle.uri) +
707710
(innerSegments[1] !== undefined ? '#' + innerSegments[1] : '');
708711
}
@@ -750,7 +753,7 @@ export class JSONSchemaService implements IJSONSchemaService {
750753
};
751754

752755
const resolveExternalLink = (node: JSONSchema, uri: string, refSegment: string | undefined, parentHandle: SchemaHandle): PromiseLike<any> => {
753-
if (contextService && !/^[A-Za-z][A-Za-z0-9+\-.+]*:\/.*/.test(uri)) {
756+
if (contextService && !hasSchemeRegex.test(uri)) {
754757
uri = contextService.resolveRelativePath(uri, parentHandle.uri);
755758
}
756759
uri = normalizeId(uri);
@@ -790,7 +793,7 @@ export class JSONSchemaService implements IJSONSchemaService {
790793
newBase = schema;
791794
// Get or create a handle for this embedded schema
792795
let resolvedUri = id;
793-
if (contextService && !/^[A-Za-z][A-Za-z0-9+\-.+]*:\/.*/.test(id)) {
796+
if (contextService && !hasSchemeRegex.test(id)) {
794797
resolvedUri = contextService.resolveRelativePath(id, currentBaseHandle.uri);
795798
}
796799
resolvedUri = normalizeId(resolvedUri);
@@ -897,7 +900,7 @@ export class JSONSchemaService implements IJSONSchemaService {
897900
const seen = new Set<JSONSchema>();
898901

899902
const resolveId = (id: string, currentBaseUri: string): string => {
900-
if (contextService && !/^[A-Za-z][A-Za-z0-9+\-.+]*:\/.*/.test(id)) {
903+
if (contextService && !hasSchemeRegex.test(id)) {
901904
return normalizeId(contextService.resolveRelativePath(id, currentBaseUri));
902905
}
903906
return normalizeId(id);
@@ -948,7 +951,10 @@ export class JSONSchemaService implements IJSONSchemaService {
948951
return this.promise.resolve(undefined);
949952
}
950953

951-
const metaschemaUri = schema.$schema;
954+
let metaschemaUri = schema.$schema;
955+
if (contextService && !hasSchemeRegex.test(metaschemaUri)) {
956+
metaschemaUri = contextService.resolveRelativePath(metaschemaUri, handle.uri);
957+
}
952958
const normalizedMetaschemaUri = normalizeId(metaschemaUri);
953959
const metaschemaHandle = this.getOrAddSchemaHandle(normalizedMetaschemaUri);
954960

src/test/links.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,36 @@ suite('JSON Find Links', () => {
5353
await testFindLinksFor(doc('#/ '), {target: 81, offset: 102, length: 3});
5454
await testFindLinksFor(doc('#/m~0n'), {target: 90, offset: 102, length: 6});
5555
});
56+
57+
test('FindDefinition anchor reference ($anchor)', async function () {
58+
// $anchor in $defs
59+
const schema1 = '{"$defs": {"foo": {"$anchor": "myAnchor", "type": "string"}}, "properties": {"x": {"$ref": "#myAnchor"}}}';
60+
// target: the object {"$anchor": "myAnchor", "type": "string"} starts at offset 18
61+
await testFindLinksFor(schema1, { target: 18, offset: 92, length: 9 });
62+
});
63+
64+
test('FindDefinition anchor reference (legacy $id fragment)', async function () {
65+
// $id: "#foo" is a legacy anchor in draft-06/07
66+
const schema2 = '{"$defs": {"a": {"$id": "#legacyAnchor", "type": "number"}}, "properties": {"x": {"$ref": "#legacyAnchor"}}}';
67+
// target: the object {"$id": "#legacyAnchor", ...} starts at offset 16
68+
await testFindLinksFor(schema2, { target: 16, offset: 91, length: 13 });
69+
});
70+
71+
test('FindDefinition embedded schema by $id', async function () {
72+
// $ref to an embedded schema URI matching a $id within the document
73+
const schema3 = '{"$defs": {"e": {"$id": "https://example.com/embedded", "type": "string"}}, "properties": {"x": {"$ref": "https://example.com/embedded"}}}';
74+
// target: the object {"$id": "https://example.com/embedded", ...} starts at offset 16
75+
await testFindLinksFor(schema3, { target: 16, offset: 106, length: 28 });
76+
});
77+
78+
test('FindDefinition embedded schema not matching root', async function () {
79+
// $ref to a URI that only the root has should not match
80+
const schema4 = '{"$id": "https://example.com/root", "$defs": {"e": {"type": "string"}}, "properties": {"x": {"$ref": "https://example.com/root"}}}';
81+
await testFindLinksFor(schema4, null);
82+
});
83+
84+
test('FindDefinition anchor reference not found', async function () {
85+
// $ref to a non-existing anchor
86+
await testFindLinksFor('{"$defs": {"foo": {"$anchor": "other"}}, "properties": {"x": {"$ref": "#missing"}}}', null);
87+
});
5688
});

0 commit comments

Comments
 (0)