Skip to content

Commit d3774ae

Browse files
authored
Prefer local sibling schema resolution for relative $ref before remote $id lookup (#1186)
* resolves relative $ref using local sibling schema path before remote $id ref Signed-off-by: Morgan Chang <shin19991207@gmail.com> * update implementation with cleaner design + fix 'local changes aren't reloaded' issue Signed-off-by: Morgan Chang <shin19991207@gmail.com> * fix absolute ref resolution behavior Signed-off-by: Morgan Chang <shin19991207@gmail.com> --------- Signed-off-by: Morgan Chang <shin19991207@gmail.com>
1 parent 425f02a commit d3774ae

File tree

3 files changed

+298
-42
lines changed

3 files changed

+298
-42
lines changed

src/languageservice/services/yamlSchemaService.ts

Lines changed: 102 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,22 @@ export class YAMLSchemaService extends JSONSchemaService {
346346
return this.normalizeId(ref);
347347
};
348348

349+
const _preferLocalBaseForRemoteId = async (currentBase: string, id: string): Promise<string> => {
350+
try {
351+
const currentBaseUri = URI.parse(currentBase);
352+
const idUri = URI.parse(id);
353+
const localFileName = path.posix.basename(idUri.path);
354+
const localDir = path.posix.dirname(currentBaseUri.path);
355+
const localPath = path.posix.join(localDir, localFileName);
356+
const localUriStr = currentBaseUri.with({ path: localPath, query: idUri.query, fragment: idUri.fragment }).toString();
357+
if (localUriStr === currentBase) return localUriStr;
358+
const content = await this.requestService(localUriStr);
359+
return content ? localUriStr : _resolveAgainstBase(currentBase, id);
360+
} catch {
361+
return _resolveAgainstBase(currentBase, id);
362+
}
363+
};
364+
349365
const _indexSchemaResources = async (root: JSONSchema, initialBaseUri: string): Promise<void> => {
350366
type WorkItem = { node: JSONSchema; baseUri: string };
351367
const preOrderStack: WorkItem[] = [{ node: root, baseUri: initialBaseUri }];
@@ -364,17 +380,17 @@ export class YAMLSchemaService extends JSONSchemaService {
364380
let baseUri = current.baseUri;
365381
const id = node.$id || node.id;
366382
if (id) {
367-
const normalizedId = _resolveAgainstBase(baseUri, id);
368-
node._baseUrl = normalizedId;
369-
const hashIndex = normalizedId.indexOf('#');
370-
if (hashIndex !== -1 && hashIndex < normalizedId.length - 1) {
383+
const preferredBaseUri = await _preferLocalBaseForRemoteId(baseUri, id);
384+
node._baseUrl = preferredBaseUri;
385+
const hashIndex = preferredBaseUri.indexOf('#');
386+
if (hashIndex !== -1 && hashIndex < preferredBaseUri.length - 1) {
371387
// Draft-07 and earlier: $id with fragment defines a plain-name anchor scoped to the resolved base
372-
const frag = normalizedId.slice(hashIndex + 1);
388+
const frag = preferredBaseUri.slice(hashIndex + 1);
373389
_getResourceIndex(baseUri).fragments.set(frag, { node });
374390
} else {
375391
// $id without fragment creates a new embedded resource scope
376-
baseUri = normalizedId;
377-
const entry = _getResourceIndex(normalizedId);
392+
baseUri = preferredBaseUri;
393+
const entry = _getResourceIndex(preferredBaseUri);
378394
if (!entry.root) {
379395
entry.root = node;
380396
}
@@ -440,7 +456,8 @@ export class YAMLSchemaService extends JSONSchemaService {
440456
};
441457

442458
let schema = raw as JSONSchema;
443-
await _indexSchemaResources(schema, schemaURL);
459+
const schemaBaseURL = schemaToResolve.uri ?? schemaURL;
460+
await _indexSchemaResources(schema, schemaBaseURL);
444461

445462
const _findSection = (schemaRoot: JSONSchema, refPath: string, sourceURI: string): JSONSchema => {
446463
if (!refPath) {
@@ -482,6 +499,34 @@ export class YAMLSchemaService extends JSONSchemaService {
482499
}
483500
};
484501

502+
const _resolveRefUri = (parentSchemaURL: string, refUri: string): string => {
503+
const resolvedAgainstParent = _resolveAgainstBase(parentSchemaURL, refUri);
504+
if (!refUri.startsWith('/')) return resolvedAgainstParent;
505+
const parentResource = resourceIndexByUri.get(parentSchemaURL)?.root;
506+
const parentResourceId = parentResource?.$id || parentResource?.id;
507+
const resolvedParentId = _resolveAgainstBase(parentSchemaURL, parentResourceId);
508+
if (!resolvedParentId.startsWith('http://') && !resolvedParentId.startsWith('https://')) return resolvedAgainstParent;
509+
510+
return _resolveAgainstBase(resolvedParentId, refUri);
511+
};
512+
513+
const _resolveLocalSiblingFromRemoteUri = (parentSchemaURL: string, resolvedRefUri: string): string | undefined => {
514+
try {
515+
const parentUri = URI.parse(parentSchemaURL);
516+
const targetUri = URI.parse(resolvedRefUri);
517+
if (parentUri.scheme !== 'file') return undefined;
518+
if (targetUri.scheme !== 'http' && targetUri.scheme !== 'https') return undefined;
519+
520+
const localFileName = path.posix.basename(targetUri.path);
521+
if (!localFileName) return undefined;
522+
const localDir = path.posix.dirname(parentUri.path);
523+
const localPath = path.posix.join(localDir, localFileName);
524+
return parentUri.with({ path: localPath, query: targetUri.query, fragment: targetUri.fragment }).toString();
525+
} catch {
526+
return undefined;
527+
}
528+
};
529+
485530
const resolveExternalLink = (
486531
node: JSONSchema,
487532
uri: string,
@@ -524,42 +569,57 @@ export class YAMLSchemaService extends JSONSchemaService {
524569
);
525570
};
526571

527-
const resolvedUri = _resolveAgainstBase(parentSchemaURL, uri);
528-
const embeddedSchema = resourceIndexByUri.get(resolvedUri)?.root;
529-
if (embeddedSchema) {
530-
return _attachResolvedSchema(
531-
node,
532-
embeddedSchema,
533-
resolvedUri,
534-
linkPath,
535-
parentSchemaDependencies,
536-
parentSchemaDependencies,
537-
resolutionStack,
538-
recursiveAnchorBase,
539-
inheritedDynamicScope
540-
);
541-
}
572+
const _resolveByUri = (targetUris: string[], index = 0): Promise<unknown> => {
573+
const targetUri = targetUris[index];
542574

543-
const referencedHandle = this.getOrAddSchemaHandle(resolvedUri);
544-
return referencedHandle.getUnresolvedSchema().then(async (unresolvedSchema) => {
545-
if (unresolvedSchema.errors.length) {
546-
const loc = linkPath ? resolvedUri + '#' + linkPath : resolvedUri;
547-
resolveErrors.push(l10n.t("Problems loading reference '{0}': {1}", loc, unresolvedSchema.errors[0]));
575+
const embeddedSchema = resourceIndexByUri.get(targetUri)?.root;
576+
if (embeddedSchema) {
577+
return _attachResolvedSchema(
578+
node,
579+
embeddedSchema,
580+
targetUri,
581+
linkPath,
582+
parentSchemaDependencies,
583+
parentSchemaDependencies,
584+
resolutionStack,
585+
recursiveAnchorBase,
586+
inheritedDynamicScope
587+
);
548588
}
549-
// index resources for the newly loaded schema
550-
await _indexSchemaResources(unresolvedSchema.schema, resolvedUri);
551-
return _attachResolvedSchema(
552-
node,
553-
unresolvedSchema.schema,
554-
resolvedUri,
555-
linkPath,
556-
parentSchemaDependencies,
557-
referencedHandle.dependencies,
558-
resolutionStack,
559-
recursiveAnchorBase,
560-
inheritedDynamicScope
561-
);
562-
});
589+
590+
const referencedHandle = this.getOrAddSchemaHandle(targetUri);
591+
return referencedHandle.getUnresolvedSchema().then(async (unresolvedSchema) => {
592+
if (
593+
unresolvedSchema.errors?.some((error) => error.toLowerCase().includes('unable to load schema from')) &&
594+
index + 1 < targetUris.length
595+
) {
596+
return _resolveByUri(targetUris, index + 1);
597+
}
598+
599+
if (unresolvedSchema.errors.length) {
600+
const loc = linkPath ? targetUri + '#' + linkPath : targetUri;
601+
resolveErrors.push(l10n.t("Problems loading reference '{0}': {1}", loc, unresolvedSchema.errors[0]));
602+
}
603+
// index resources for the newly loaded schema
604+
await _indexSchemaResources(unresolvedSchema.schema, targetUri);
605+
return _attachResolvedSchema(
606+
node,
607+
unresolvedSchema.schema,
608+
targetUri,
609+
linkPath,
610+
parentSchemaDependencies,
611+
referencedHandle.dependencies,
612+
resolutionStack,
613+
recursiveAnchorBase,
614+
inheritedDynamicScope
615+
);
616+
});
617+
};
618+
619+
const resolvedUri = _resolveRefUri(parentSchemaURL, uri);
620+
const localSiblingUri = _resolveLocalSiblingFromRemoteUri(parentSchemaURL, resolvedUri);
621+
const targetUris = localSiblingUri && localSiblingUri !== resolvedUri ? [localSiblingUri, resolvedUri] : [resolvedUri];
622+
return _resolveByUri(targetUris);
563623
};
564624

565625
const resolveRefs = async (

test/schemaValidation.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2594,4 +2594,30 @@ pkg: 123
25942594
const result = await parseSetup(content);
25952595
assert.equal(result.length, 0);
25962596
});
2597+
2598+
it('resolves relative $ref using local sibling schema path before remote $id ref', async () => {
2599+
const primaryUri = 'file:///schemas/primary.json';
2600+
const secondaryUri = 'file:///schemas/secondary.json';
2601+
const primarySchema: JSONSchema = {
2602+
$id: 'https://example.com/schemas/primary.json',
2603+
type: 'object',
2604+
properties: {
2605+
mode: { $ref: 'secondary.json' },
2606+
},
2607+
required: ['mode'],
2608+
};
2609+
const secondarySchema: JSONSchema = {
2610+
$id: 'https://example.com/schemas/secondary.json',
2611+
type: 'string',
2612+
enum: ['dev', 'prod'],
2613+
};
2614+
2615+
schemaProvider.addSchemaWithUri(SCHEMA_ID, primaryUri, primarySchema);
2616+
schemaProvider.addSchemaWithUri(SCHEMA_ID, secondaryUri, secondarySchema);
2617+
2618+
const content = `# yaml-language-server: $schema=${primaryUri}\nmode: stage`;
2619+
const result = await parseSetup(content);
2620+
expect(result).to.have.length(1);
2621+
expect(result[0].message).to.include('Value is not accepted. Valid values');
2622+
});
25972623
});

0 commit comments

Comments
 (0)