Skip to content
Merged
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
144 changes: 102 additions & 42 deletions src/languageservice/services/yamlSchemaService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,22 @@ export class YAMLSchemaService extends JSONSchemaService {
return this.normalizeId(ref);
};

const _preferLocalBaseForRemoteId = async (currentBase: string, id: string): Promise<string> => {
try {
const currentBaseUri = URI.parse(currentBase);
const idUri = URI.parse(id);
const localFileName = path.posix.basename(idUri.path);
const localDir = path.posix.dirname(currentBaseUri.path);
const localPath = path.posix.join(localDir, localFileName);
const localUriStr = currentBaseUri.with({ path: localPath, query: idUri.query, fragment: idUri.fragment }).toString();
if (localUriStr === currentBase) return localUriStr;
const content = await this.requestService(localUriStr);
return content ? localUriStr : _resolveAgainstBase(currentBase, id);
} catch {
return _resolveAgainstBase(currentBase, id);
}
};

const _indexSchemaResources = async (root: JSONSchema, initialBaseUri: string): Promise<void> => {
type WorkItem = { node: JSONSchema; baseUri: string };
const preOrderStack: WorkItem[] = [{ node: root, baseUri: initialBaseUri }];
Expand All @@ -364,17 +380,17 @@ export class YAMLSchemaService extends JSONSchemaService {
let baseUri = current.baseUri;
const id = node.$id || node.id;
if (id) {
const normalizedId = _resolveAgainstBase(baseUri, id);
node._baseUrl = normalizedId;
const hashIndex = normalizedId.indexOf('#');
if (hashIndex !== -1 && hashIndex < normalizedId.length - 1) {
const preferredBaseUri = await _preferLocalBaseForRemoteId(baseUri, id);
node._baseUrl = preferredBaseUri;
const hashIndex = preferredBaseUri.indexOf('#');
if (hashIndex !== -1 && hashIndex < preferredBaseUri.length - 1) {
// Draft-07 and earlier: $id with fragment defines a plain-name anchor scoped to the resolved base
const frag = normalizedId.slice(hashIndex + 1);
const frag = preferredBaseUri.slice(hashIndex + 1);
_getResourceIndex(baseUri).fragments.set(frag, { node });
} else {
// $id without fragment creates a new embedded resource scope
baseUri = normalizedId;
const entry = _getResourceIndex(normalizedId);
baseUri = preferredBaseUri;
const entry = _getResourceIndex(preferredBaseUri);
if (!entry.root) {
entry.root = node;
}
Expand Down Expand Up @@ -440,7 +456,8 @@ export class YAMLSchemaService extends JSONSchemaService {
};

let schema = raw as JSONSchema;
await _indexSchemaResources(schema, schemaURL);
const schemaBaseURL = schemaToResolve.uri ?? schemaURL;
await _indexSchemaResources(schema, schemaBaseURL);

const _findSection = (schemaRoot: JSONSchema, refPath: string, sourceURI: string): JSONSchema => {
if (!refPath) {
Expand Down Expand Up @@ -482,6 +499,34 @@ export class YAMLSchemaService extends JSONSchemaService {
}
};

const _resolveRefUri = (parentSchemaURL: string, refUri: string): string => {
const resolvedAgainstParent = _resolveAgainstBase(parentSchemaURL, refUri);
if (!refUri.startsWith('/')) return resolvedAgainstParent;
const parentResource = resourceIndexByUri.get(parentSchemaURL)?.root;
const parentResourceId = parentResource?.$id || parentResource?.id;
const resolvedParentId = _resolveAgainstBase(parentSchemaURL, parentResourceId);
if (!resolvedParentId.startsWith('http://') && !resolvedParentId.startsWith('https://')) return resolvedAgainstParent;

return _resolveAgainstBase(resolvedParentId, refUri);
};

const _resolveLocalSiblingFromRemoteUri = (parentSchemaURL: string, resolvedRefUri: string): string | undefined => {
try {
const parentUri = URI.parse(parentSchemaURL);
const targetUri = URI.parse(resolvedRefUri);
if (parentUri.scheme !== 'file') return undefined;
if (targetUri.scheme !== 'http' && targetUri.scheme !== 'https') return undefined;

const localFileName = path.posix.basename(targetUri.path);
if (!localFileName) return undefined;
const localDir = path.posix.dirname(parentUri.path);
const localPath = path.posix.join(localDir, localFileName);
return parentUri.with({ path: localPath, query: targetUri.query, fragment: targetUri.fragment }).toString();
} catch {
return undefined;
}
};

const resolveExternalLink = (
node: JSONSchema,
uri: string,
Expand Down Expand Up @@ -524,42 +569,57 @@ export class YAMLSchemaService extends JSONSchemaService {
);
};

const resolvedUri = _resolveAgainstBase(parentSchemaURL, uri);
const embeddedSchema = resourceIndexByUri.get(resolvedUri)?.root;
if (embeddedSchema) {
return _attachResolvedSchema(
node,
embeddedSchema,
resolvedUri,
linkPath,
parentSchemaDependencies,
parentSchemaDependencies,
resolutionStack,
recursiveAnchorBase,
inheritedDynamicScope
);
}
const _resolveByUri = (targetUris: string[], index = 0): Promise<unknown> => {
const targetUri = targetUris[index];

const referencedHandle = this.getOrAddSchemaHandle(resolvedUri);
return referencedHandle.getUnresolvedSchema().then(async (unresolvedSchema) => {
if (unresolvedSchema.errors.length) {
const loc = linkPath ? resolvedUri + '#' + linkPath : resolvedUri;
resolveErrors.push(l10n.t("Problems loading reference '{0}': {1}", loc, unresolvedSchema.errors[0]));
const embeddedSchema = resourceIndexByUri.get(targetUri)?.root;
if (embeddedSchema) {
return _attachResolvedSchema(
node,
embeddedSchema,
targetUri,
linkPath,
parentSchemaDependencies,
parentSchemaDependencies,
resolutionStack,
recursiveAnchorBase,
inheritedDynamicScope
);
}
// index resources for the newly loaded schema
await _indexSchemaResources(unresolvedSchema.schema, resolvedUri);
return _attachResolvedSchema(
node,
unresolvedSchema.schema,
resolvedUri,
linkPath,
parentSchemaDependencies,
referencedHandle.dependencies,
resolutionStack,
recursiveAnchorBase,
inheritedDynamicScope
);
});

const referencedHandle = this.getOrAddSchemaHandle(targetUri);
return referencedHandle.getUnresolvedSchema().then(async (unresolvedSchema) => {
if (
unresolvedSchema.errors?.some((error) => error.toLowerCase().includes('unable to load schema from')) &&
index + 1 < targetUris.length
) {
return _resolveByUri(targetUris, index + 1);
}

if (unresolvedSchema.errors.length) {
const loc = linkPath ? targetUri + '#' + linkPath : targetUri;
resolveErrors.push(l10n.t("Problems loading reference '{0}': {1}", loc, unresolvedSchema.errors[0]));
}
// index resources for the newly loaded schema
await _indexSchemaResources(unresolvedSchema.schema, targetUri);
return _attachResolvedSchema(
node,
unresolvedSchema.schema,
targetUri,
linkPath,
parentSchemaDependencies,
referencedHandle.dependencies,
resolutionStack,
recursiveAnchorBase,
inheritedDynamicScope
);
});
};

const resolvedUri = _resolveRefUri(parentSchemaURL, uri);
const localSiblingUri = _resolveLocalSiblingFromRemoteUri(parentSchemaURL, resolvedUri);
const targetUris = localSiblingUri && localSiblingUri !== resolvedUri ? [localSiblingUri, resolvedUri] : [resolvedUri];
return _resolveByUri(targetUris);
};

const resolveRefs = async (
Expand Down
26 changes: 26 additions & 0 deletions test/schemaValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2594,4 +2594,30 @@ pkg: 123
const result = await parseSetup(content);
assert.equal(result.length, 0);
});

it('resolves relative $ref using local sibling schema path before remote $id ref', async () => {
const primaryUri = 'file:///schemas/primary.json';
const secondaryUri = 'file:///schemas/secondary.json';
const primarySchema: JSONSchema = {
$id: 'https://example.com/schemas/primary.json',
type: 'object',
properties: {
mode: { $ref: 'secondary.json' },
},
required: ['mode'],
};
const secondarySchema: JSONSchema = {
$id: 'https://example.com/schemas/secondary.json',
type: 'string',
enum: ['dev', 'prod'],
};

schemaProvider.addSchemaWithUri(SCHEMA_ID, primaryUri, primarySchema);
schemaProvider.addSchemaWithUri(SCHEMA_ID, secondaryUri, secondarySchema);

const content = `# yaml-language-server: $schema=${primaryUri}\nmode: stage`;
const result = await parseSetup(content);
expect(result).to.have.length(1);
expect(result[0].message).to.include('Value is not accepted. Valid values');
});
});
Loading