vscode-json-languageservice
Version:
Language service for JSON
1,203 lines • 61.5 kB
JavaScript
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as Json from 'jsonc-parser';
import { URI } from 'vscode-uri';
import * as Strings from '../utils/strings.js';
import { asSchema, getSchemaDraftFromId, normalizeId } from '../parser/jsonParser.js';
import { SchemaDraft, ErrorCode } from '../jsonLanguageTypes.js';
import * as l10n from '@vscode/l10n';
import { createRegex } from '../utils/glob.js';
import { isString } from '../utils/objects.js';
import { Range } from 'vscode-languageserver-types';
const hasSchemePattern = /^[A-Za-z][A-Za-z0-9+\-.+]*:\/.*/.source;
const hasSchemeRegex = new RegExp(hasSchemePattern);
const BANG = '!';
const PATH_SEP = '/';
class FilePatternAssociation {
constructor(pattern, folderUri, uris) {
this.folderUri = folderUri;
this.uris = uris;
this.globWrappers = [];
try {
for (let patternString of pattern) {
const include = patternString[0] !== BANG;
if (!include) {
patternString = patternString.substring(1);
}
if (patternString.length > 0) {
if (patternString[0] === PATH_SEP) {
patternString = patternString.substring(1);
}
this.globWrappers.push({
regexp: createRegex('**/' + patternString, { extended: true, globstar: true }),
include: include,
});
}
}
;
if (folderUri) {
folderUri = normalizeResourceForMatching(folderUri);
if (!folderUri.endsWith('/')) {
folderUri = folderUri + '/';
}
this.folderUri = folderUri;
}
}
catch (e) {
this.globWrappers.length = 0;
this.uris = [];
}
}
matchesPattern(fileName) {
if (this.folderUri && !fileName.startsWith(this.folderUri)) {
return false;
}
let match = false;
for (const { regexp, include } of this.globWrappers) {
if (regexp.test(fileName)) {
match = include;
}
}
return match;
}
getURIs() {
return this.uris;
}
}
class SchemaHandle {
constructor(service, uri, unresolvedSchemaContent) {
this.service = service;
this.uri = uri;
this.dependencies = new Set();
this.anchors = undefined;
if (unresolvedSchemaContent) {
this.unresolvedSchema = this.service.promise.resolve(new UnresolvedSchema(unresolvedSchemaContent));
}
}
getUnresolvedSchema() {
if (!this.unresolvedSchema) {
this.unresolvedSchema = this.service.loadSchema(this.uri);
}
return this.unresolvedSchema;
}
getResolvedSchema() {
if (!this.resolvedSchema) {
this.resolvedSchema = this.getUnresolvedSchema().then(unresolved => {
return this.service.resolveSchemaContent(unresolved, this);
});
}
return this.resolvedSchema;
}
clearSchema() {
const hasChanges = !!this.unresolvedSchema;
this.resolvedSchema = undefined;
this.unresolvedSchema = undefined;
this.dependencies.clear();
this.anchors = undefined;
return hasChanges;
}
setSchemaContent(schemaContent) {
this.unresolvedSchema = this.service.promise.resolve(new UnresolvedSchema(schemaContent));
this.resolvedSchema = undefined;
this.anchors = undefined;
}
}
export class UnresolvedSchema {
constructor(schema, errors = []) {
this.schema = schema;
this.errors = errors;
}
}
function toDiagnostic(message, code, relatedURL) {
const relatedInformation = relatedURL ? [{
location: { uri: relatedURL, range: Range.create(0, 0, 0, 0) },
message
}] : undefined;
return { message, code, relatedInformation };
}
export class ResolvedSchema {
constructor(schema, errors = [], warnings = [], schemaDraft, activeVocabularies) {
this.schema = schema;
this.errors = errors;
this.warnings = warnings;
this.schemaDraft = schemaDraft;
this.activeVocabularies = activeVocabularies;
}
getSection(path) {
const schemaRef = this.getSectionRecursive(path, this.schema);
if (schemaRef) {
return asSchema(schemaRef);
}
return undefined;
}
getSectionRecursive(path, schema) {
if (!schema || typeof schema === 'boolean' || path.length === 0) {
return schema;
}
const next = path.shift();
if (schema.properties && typeof schema.properties[next]) {
return this.getSectionRecursive(path, schema.properties[next]);
}
else if (schema.patternProperties) {
for (const pattern of Object.keys(schema.patternProperties)) {
const regex = Strings.extendedRegExp(pattern);
if (regex?.test(next)) {
return this.getSectionRecursive(path, schema.patternProperties[pattern]);
}
}
}
else if (typeof schema.additionalProperties === 'object') {
return this.getSectionRecursive(path, schema.additionalProperties);
}
else if (next.match('[0-9]+')) {
if (Array.isArray(schema.items)) {
const index = parseInt(next, 10);
if (!isNaN(index) && schema.items[index]) {
return this.getSectionRecursive(path, schema.items[index]);
}
}
else if (schema.items) {
return this.getSectionRecursive(path, schema.items);
}
}
return undefined;
}
}
export class JSONSchemaService {
static traverseSchemaProperties(node, callback) {
// `$defs`/`definitions` are visited first so that a reusable resource they
// contain (which may itself carry a `$ref` chain) begins resolving before an
// `if`/`then`/`else`/`items`/… sibling references it. During asynchronous
// `$ref` resolution the referenced resource must be fully assembled before a
// referrer merges it, otherwise the referrer captures a half-resolved snapshot
// (e.g. a `$dynamicRef` chain reached through `then: { $ref: "numberList" }`).
const schemaMapProps = ['definitions', '$defs', 'properties', 'patternProperties',
'dependencies', 'dependentSchemas'];
const singleSchemaProps = ['additionalItems', 'additionalProperties', 'not', 'contains',
'propertyNames', 'if', 'then', 'else', 'unevaluatedItems', 'unevaluatedProperties', 'items'];
const schemaArrayProps = ['anyOf', 'allOf', 'oneOf', 'prefixItems'];
const visitValue = (value) => {
if (value) {
if (Array.isArray(value)) {
value.forEach(item => visitValue(item));
}
else if (typeof value === 'object') {
callback(value);
}
}
};
for (const prop of schemaMapProps) {
const map = node[prop];
if (map && typeof map === 'object') {
Object.values(map).forEach(visitValue);
}
}
for (const prop of singleSchemaProps) {
const propValue = node[prop];
if (propValue) {
visitValue(propValue);
}
}
for (const prop of schemaArrayProps) {
const propValue = node[prop];
if (propValue) {
visitValue(propValue);
}
}
}
constructor(requestService, contextService, promiseConstructor) {
this.contextService = contextService;
this.requestService = requestService;
this.promiseConstructor = promiseConstructor || Promise;
this.callOnDispose = [];
this.contributionSchemas = {};
this.contributionAssociations = [];
this.schemasById = {};
this.filePatternAssociations = [];
this.registeredSchemasIds = {};
}
getRegisteredSchemaIds(filter) {
return Object.keys(this.registeredSchemasIds).filter(id => {
const scheme = URI.parse(id).scheme;
return scheme !== 'schemaservice' && (!filter || filter(scheme));
});
}
get promise() {
return this.promiseConstructor;
}
dispose() {
while (this.callOnDispose.length > 0) {
this.callOnDispose.pop()();
}
}
onResourceChange(uri) {
// always clear this local cache when a resource changes
this.cachedSchemaForResource = undefined;
let hasChanges = false;
uri = normalizeId(uri);
const toWalk = [uri];
const all = Object.keys(this.schemasById).map(key => this.schemasById[key]);
while (toWalk.length) {
const curr = toWalk.pop();
for (let i = 0; i < all.length; i++) {
const handle = all[i];
if (handle && (handle.uri === curr || handle.dependencies.has(curr))) {
if (handle.uri !== curr) {
toWalk.push(handle.uri);
}
if (handle.clearSchema()) {
hasChanges = true;
}
all[i] = undefined;
}
}
}
return hasChanges;
}
setSchemaContributions(schemaContributions) {
if (schemaContributions.schemas) {
const schemas = schemaContributions.schemas;
for (const id in schemas) {
const normalizedId = normalizeId(id);
this.contributionSchemas[normalizedId] = this.addSchemaHandle(normalizedId, schemas[id]);
}
}
if (Array.isArray(schemaContributions.schemaAssociations)) {
const schemaAssociations = schemaContributions.schemaAssociations;
for (let schemaAssociation of schemaAssociations) {
const uris = schemaAssociation.uris.map(normalizeId);
const association = this.addFilePatternAssociation(schemaAssociation.pattern, schemaAssociation.folderUri, uris);
this.contributionAssociations.push(association);
}
}
}
addSchemaHandle(id, unresolvedSchemaContent) {
const schemaHandle = new SchemaHandle(this, id, unresolvedSchemaContent);
this.schemasById[id] = schemaHandle;
return schemaHandle;
}
getOrAddSchemaHandle(id, unresolvedSchemaContent) {
return this.schemasById[id] || this.addSchemaHandle(id, unresolvedSchemaContent);
}
addFilePatternAssociation(pattern, folderUri, uris) {
const fpa = new FilePatternAssociation(pattern, folderUri, uris);
this.filePatternAssociations.push(fpa);
return fpa;
}
registerExternalSchema(config) {
const id = normalizeId(config.uri);
this.registeredSchemasIds[id] = true;
this.cachedSchemaForResource = undefined;
if (config.fileMatch && config.fileMatch.length) {
this.addFilePatternAssociation(config.fileMatch, config.folderUri, [id]);
}
return config.schema ? this.addSchemaHandle(id, config.schema) : this.getOrAddSchemaHandle(id);
}
clearExternalSchemas() {
this.schemasById = {};
this.filePatternAssociations = [];
this.registeredSchemasIds = {};
this.cachedSchemaForResource = undefined;
for (const id in this.contributionSchemas) {
this.schemasById[id] = this.contributionSchemas[id];
this.registeredSchemasIds[id] = true;
}
for (const contributionAssociation of this.contributionAssociations) {
this.filePatternAssociations.push(contributionAssociation);
}
}
getResolvedSchema(schemaId) {
const id = normalizeId(schemaId);
const schemaHandle = this.schemasById[id];
if (schemaHandle) {
return schemaHandle.getResolvedSchema();
}
return this.promise.resolve(undefined);
}
loadSchema(url) {
if (!this.requestService) {
const errorMessage = l10n.t('Unable to load schema from \'{0}\'. No schema request service available', toDisplayString(url));
return this.promise.resolve(new UnresolvedSchema({}, [toDiagnostic(errorMessage, ErrorCode.SchemaResolveError, url)]));
}
return this.requestService(url).then(content => {
if (!content) {
const errorMessage = l10n.t('Unable to load schema from \'{0}\': No content.', toDisplayString(url));
return new UnresolvedSchema({}, [toDiagnostic(errorMessage, ErrorCode.SchemaResolveError, url)]);
}
const errors = [];
if (content.charCodeAt(0) === 65279) {
errors.push(toDiagnostic(l10n.t('Problem reading content from \'{0}\': UTF-8 with BOM detected, only UTF 8 is allowed.', toDisplayString(url)), ErrorCode.SchemaResolveError, url));
content = content.trimStart();
}
let schemaContent = {};
const jsonErrors = [];
schemaContent = Json.parse(content, jsonErrors);
if (jsonErrors.length) {
errors.push(toDiagnostic(l10n.t('Unable to parse content from \'{0}\': Parse error at offset {1}.', toDisplayString(url), jsonErrors[0].offset), ErrorCode.SchemaResolveError, url));
}
return new UnresolvedSchema(schemaContent, errors);
}, (error) => {
let { message, code } = error;
if (typeof message !== 'string') {
let errorMessage = error.toString();
const errorSplit = error.toString().split('Error: ');
if (errorSplit.length > 1) {
// more concise error message, URL and context are attached by caller anyways
errorMessage = errorSplit[1];
}
if (Strings.endsWith(errorMessage, '.')) {
errorMessage = errorMessage.substr(0, errorMessage.length - 1);
}
message = errorMessage;
}
let errorCode = ErrorCode.SchemaResolveError;
if (typeof code === 'number' && code < 0x10000) {
errorCode += code;
}
const errorMessage = l10n.t('Unable to load schema from \'{0}\': {1}.', toDisplayString(url), message);
return new UnresolvedSchema({}, [toDiagnostic(errorMessage, errorCode, url)]);
});
}
resolveSchemaContent(schemaToResolve, handle) {
const resolveErrors = schemaToResolve.errors.slice(0);
const schema = schemaToResolve.schema;
// External documents whose embedded $id resources have already been registered
// as resolvable handles, so we only do it once per referenced document.
const embeddedRegisteredFor = new Set();
const schemaDraft = schema.$schema ? getSchemaDraftFromId(schema.$schema) : undefined;
if (schemaDraft === SchemaDraft.v3) {
return this.promise.resolve(new ResolvedSchema({}, [toDiagnostic(l10n.t("Draft-03 schemas are not supported."), ErrorCode.SchemaUnsupportedFeature)], [], schemaDraft, undefined));
}
let activeVocabularies = undefined;
const extractVocabularies = (metaschema) => {
if (!metaschema.$vocabulary || typeof metaschema.$vocabulary !== 'object') {
return undefined;
}
// Both true and false values indicate the vocabulary is active.
// The boolean indicates whether the vocabulary is required (true) or optional (false),
// not whether it's in use. All listed vocabularies should be included.
const vocabs = new Map();
for (const [uri, required] of Object.entries(metaschema.$vocabulary)) {
vocabs.set(uri, required);
}
return vocabs.size > 0 ? vocabs : undefined;
};
const contextService = this.contextService;
// Attach internal, non-enumerable metadata to a schema node. Hidden so it is
// invisible to schema traversal, merging and consumers, but available to the
// validator (e.g. for $recursiveRef/$dynamicRef resolution).
const setHidden = (obj, key, value) => {
Object.defineProperty(obj, key, {
value,
enumerable: false,
writable: true,
configurable: true
});
};
// Get (creating on first use) the hidden $dynamicRefInfo record for a $dynamicRef
// node. Its fields are populated across two passes — `scope` while collecting
// anchors, `target`/`name` while resolving the reference — over the same object.
const dynamicRefInfoOf = (node) => {
let info = node.$dynamicRefInfo;
if (!info) {
info = {};
setHidden(node, '$dynamicRefInfo', info);
}
return info;
};
const findSectionByJSONPointer = (schema, path) => {
path = decodeURIComponent(path);
let current = schema;
if (path[0] === '/') {
path = path.substring(1);
}
path.split('/').some((part) => {
part = part.replace(/~1/g, '/').replace(/~0/g, '~');
current = current[part];
// A boolean `false` is a valid schema, not a "missing" section, so only
// stop on genuinely absent values (undefined/null).
return current === undefined || current === null;
});
return current;
};
// Like findSectionByJSONPointer, but also tracks the effective base URI
// through any $id encountered along the path. This is needed so that
// $ref values merged from the found section can be resolved against the
// correct base, not the document root.
const findSectionAndBase = (schema, path, baseHandle) => {
path = decodeURIComponent(path);
let current = schema;
let currentBaseHandle = baseHandle;
if (path[0] === '/') {
path = path.substring(1);
}
path.split('/').some((part) => {
part = part.replace(/~1/g, '/').replace(/~0/g, '~');
current = current[part];
// A boolean `false` is a valid schema, not a "missing" section, so only
// stop on genuinely absent values (undefined/null).
if (current === undefined || current === null) {
return true;
}
const id = getSchemaId(current);
if (isString(id) && id.charAt(0) !== '#') {
let resolvedUri = id;
if (contextService && !hasSchemeRegex.test(id)) {
resolvedUri = contextService.resolveRelativePath(id, currentBaseHandle.uri);
}
resolvedUri = normalizeId(resolvedUri);
currentBaseHandle = this.getOrAddSchemaHandle(resolvedUri);
}
return false;
});
return { section: current, baseHandle: currentBaseHandle };
};
const findSchemaById = (schema, handle, id) => {
if (!handle.anchors) {
handle.anchors = collectAnchors(schema);
}
return handle.anchors.get(id);
};
const getSchemaId = (schema) => schema.$id || schema.id;
// Fold a source resource's $dynamicAnchor names into a target node's dynamic
// map, creating it on demand. Existing entries win, so the outermost resource
// in a dynamic scope retains precedence — this is what lets a $dynamicAnchor in
// an outer resource override an inner (referenced) one during the validation
// walk. Only the `dynamic` map is folded: the `local` map must stay per-resource
// (it resolves an internal $dynamicRef's initial target within its own lexical
// resource), so a sibling/referenced resource's identically-named anchor must
// not leak into it.
const unionAnchorMaps = (target, srcMaps) => {
if (srcMaps === undefined) {
return;
}
let maps = target.$anchorMaps;
if (maps === undefined) {
maps = { dynamic: new Map(), local: new Map() };
setHidden(target, '$anchorMaps', maps);
}
for (const [name, node] of srcMaps.dynamic) {
if (!maps.dynamic.has(name)) {
maps.dynamic.set(name, node);
}
}
};
const merge = (target, section) => {
for (const key in section) {
if (!section.hasOwnProperty(key) || key === 'id' || key === '$id') {
continue;
}
// Deep merge for properties and patternProperties to combine them
const shouldDeepMerge = (key === 'properties' || key === 'patternProperties') &&
typeof section[key] === 'object' && section[key] !== null &&
typeof target[key] === 'object' && target[key] !== null;
target[key] = shouldDeepMerge
? { ...target[key], ...section[key] }
: section[key];
}
// Preserve $id as a non-enumerable hidden property for $recursiveRef resolution.
// This allows $recursiveRef to correctly resolve references within the schema without exposing $id publicly.
const id = section.$id || section.id;
if (id) {
Object.defineProperty(target, '$originalId', {
value: id,
enumerable: false,
writable: true,
configurable: true
});
}
// Propagate hidden $dynamicRef metadata. When a $dynamicRef lives directly on
// a $ref'd schema resource, that resource is a distinct object from the
// referencing schema and is often resolved standalone first — deleting its
// $dynamicRef and recording the (non-enumerable, so not copied above)
// $dynamicRefInfo. Carry it over so the merged schema still resolves
// dynamically. (When the $dynamicRef lives on a child of the merged resource,
// that child is shared by reference and already carries its own metadata.)
const src = section;
const dst = target;
if (src.$dynamicRefInfo !== undefined && dst.$dynamicRefInfo === undefined) {
setHidden(target, '$dynamicRefInfo', src.$dynamicRefInfo);
}
// When the merged section is itself a schema resource (it carries anchor
// maps), fold its $dynamicAnchor / $anchor maps into the target's. The
// merged node stands in for that resource during validation (it becomes a
// dynamic-scope root via $originalId), so those anchors must participate in
// dynamic-scope resolution — this is what lets a $dynamicAnchor defined in
// an external (or $ref'd sub-) resource override a base default. Existing
// entries are kept so the outermost resource retains precedence.
unionAnchorMaps(dst, src.$anchorMaps);
};
const hasKeyword = (schema, keyword) => schema[keyword] !== undefined;
const hasAnyKeyword = (schema, keywords) => keywords.some(keyword => hasKeyword(schema, keyword));
const objectPropertyKeywords = ['properties', 'patternProperties'];
const objectEvaluatorKeywords = [
...objectPropertyKeywords,
'additionalProperties',
'dependentSchemas',
'allOf',
'anyOf',
'oneOf',
'if',
'then',
'else'
];
const arrayEvaluatorKeywords = [
'items',
'additionalItems',
'prefixItems',
'allOf',
'anyOf',
'oneOf',
'if',
'then',
'else'
];
const containsBoundKeywords = ['minContains', 'maxContains'];
const conditionalBranchKeywords = ['then', 'else'];
const arrayApplicatorKeywords = ['items', 'prefixItems', 'additionalItems'];
const uses2020_12ArrayAnnotations = schemaDraft === undefined || schemaDraft >= SchemaDraft.v2020_12;
// Some keywords only make sense relative to adjacent keywords in the same schema object.
// If they live behind $ref, sibling keywords on the referencing schema must not be
// flattened into the same scope.
const needsScopeIsolation = (referencedSchema, referencingSchema) => {
const hasSiblingObjectProperties = hasAnyKeyword(referencingSchema, objectPropertyKeywords);
if (hasKeyword(referencedSchema, 'additionalProperties') && hasSiblingObjectProperties) {
return true;
}
if (hasKeyword(referencedSchema, 'additionalItems') && Array.isArray(referencingSchema.items)) {
return true;
}
const hasSiblingObjectEvaluators = hasAnyKeyword(referencingSchema, objectEvaluatorKeywords);
if (hasKeyword(referencedSchema, 'unevaluatedProperties') && hasSiblingObjectEvaluators) {
return true;
}
const hasSiblingArrayEvaluators = hasAnyKeyword(referencingSchema, arrayEvaluatorKeywords) ||
(uses2020_12ArrayAnnotations && hasKeyword(referencingSchema, 'contains'));
if (hasKeyword(referencedSchema, 'unevaluatedItems') && hasSiblingArrayEvaluators) {
return true;
}
// Reverse of the above: the *referencing* schema declares unevaluatedItems
// while the referenced schema constrains items positionally and may contain
// internal self-references (`$ref: "#"`). Flattening would pull the referenced
// items into the referencing scope, so those self-references would inherit the
// referencing unevaluatedItems. Isolate so the referenced resource keeps its
// own annotation scope.
if (hasKeyword(referencingSchema, 'unevaluatedItems') && hasAnyKeyword(referencedSchema, arrayApplicatorKeywords)) {
return true;
}
if (hasKeyword(referencedSchema, 'contains') && hasAnyKeyword(referencingSchema, containsBoundKeywords)) {
return true;
}
// Both the referenced and referencing schemas constrain array items
// (tuple `items`, or 2020-12 `items`/`prefixItems`). A flatten-merge can
// only keep one `items`, silently dropping the other; isolate into an
// allOf so both position constraints apply and item-evaluation
// annotations (for unevaluatedItems) accumulate across both.
if (Array.isArray(referencedSchema.items) && Array.isArray(referencingSchema.items)) {
return true;
}
if (hasAnyKeyword(referencedSchema, containsBoundKeywords) && hasKeyword(referencingSchema, 'contains')) {
return true;
}
if (hasKeyword(referencedSchema, 'if') && hasAnyKeyword(referencingSchema, conditionalBranchKeywords)) {
return true;
}
if (hasAnyKeyword(referencedSchema, conditionalBranchKeywords) && hasKeyword(referencingSchema, 'if')) {
return true;
}
return uses2020_12ArrayAnnotations &&
hasKeyword(referencedSchema, 'items') &&
hasKeyword(referencingSchema, 'prefixItems');
};
const mergeRef = (target, sourceRoot, sourceHandle, refSegment) => {
let section;
let sectionBaseHandle = sourceHandle;
if (refSegment === undefined || refSegment.length === 0) {
section = sourceRoot;
}
else if (refSegment.charAt(0) === '/') {
// A $ref to a JSON Pointer (i.e #/definitions/foo)
// Track $id base changes along the path so inner $refs resolve correctly.
({ section, baseHandle: sectionBaseHandle } = findSectionAndBase(sourceRoot, refSegment, sourceHandle));
}
else {
// A $ref to a sub-schema with an $id (i.e #hello)
section = findSchemaById(sourceRoot, sourceHandle, refSegment);
}
// A boolean is a valid JSON Schema: `true` accepts everything ({}) and
// `false` rejects everything ({ not: {} }). Normalize so it merges like any
// other schema (and isn't mistaken for an unresolved section below).
if (typeof section === 'boolean') {
section = section ? {} : { not: {} };
}
if (section) {
// If the found section contains a $ref that needs to be resolved
// relative to a different base (e.g. it's inside a schema with $id),
// pre-resolve it now so the merged target carries the correct base.
// Clone before rewriting to avoid mutating the cached source schema.
if (section.$ref && sectionBaseHandle !== sourceHandle) {
const innerRef = section.$ref;
const innerSegments = innerRef.split('#', 2);
if (innerSegments[0].length > 0 && contextService && !hasSchemeRegex.test(innerSegments[0])) {
section = {
...section,
$ref: contextService.resolveRelativePath(innerSegments[0], sectionBaseHandle.uri) +
(innerSegments[1] !== undefined ? '#' + innerSegments[1] : '')
};
}
}
const reservedKeys = new Set(['$ref', '$defs', 'definitions', '$schema', '$id', 'id']);
// Keywords that must stay on the wrapper rather than move into the allOf
// sibling. Scope-anchor keywords ($recursiveAnchor/$dynamicAnchor) keep this
// resource discoverable as a $recursiveRef/$dynamicRef bookending target.
// The unevaluated* annotations must observe the results of *all* in-place
// applicators (including the $ref'd schema in allOf[0]); leaving them on the
// wrapper lets them see the aggregated annotations rather than only the
// sibling's own evaluations.
const keepOnWrapperKeys = new Set([
'$recursiveAnchor', '$dynamicAnchor', 'unevaluatedItems', 'unevaluatedProperties'
]);
// In JSON Schema draft-04 through draft-07, $ref completely overrides any sibling keywords.
// Starting in 2019-09, sibling keywords are processed alongside $ref.
// Only strip siblings when schema explicitly declares a pre-2019-09 draft via $schema.
const isPreDraft201909 = schemaDraft !== undefined && schemaDraft < SchemaDraft.v2019_09;
if (isPreDraft201909) {
// Clear all sibling keywords from target - $ref takes precedence
for (const key in target) {
if (target.hasOwnProperty(key) && !reservedKeys.has(key)) {
delete target[key];
}
}
merge(target, section);
}
else if (needsScopeIsolation(section, target)) {
// In JSON Schema 2019-09 or greater, $ref creates a new scope when sibling
// keywords would otherwise change the meaning of same-object-dependent keywords
// from the referenced schema.
// To achieve this, we wrap the $ref in an allOf when needed.
const siblingSchema = {};
const refSchema = { ...section };
// Move all existing properties from target to siblingSchema, except
// keywords that must remain on the wrapper resource.
for (const key in target) {
if (target.hasOwnProperty(key) && !reservedKeys.has(key) && !keepOnWrapperKeys.has(key)) {
const k = key;
siblingSchema[k] = target[k];
delete target[k];
}
}
// Create allOf with the $ref'd schema and sibling schema
target.allOf = [refSchema, siblingSchema];
}
else {
merge(target, section);
}
}
else {
const message = l10n.t('$ref \'{0}\' in \'{1}\' can not be resolved.', refSegment || '', sourceHandle.uri);
resolveErrors.push(toDiagnostic(message, ErrorCode.SchemaResolveError));
}
};
const getExternalSchemaHandle = (uri, parentHandle) => {
if (contextService && !hasSchemeRegex.test(uri)) {
uri = contextService.resolveRelativePath(uri, parentHandle.uri);
}
uri = normalizeId(uri);
return this.getOrAddSchemaHandle(uri);
};
const resolveExternalLink = (node, uri, refSegment, parentHandle) => {
const referencedHandle = getExternalSchemaHandle(uri, parentHandle);
uri = referencedHandle.uri;
return referencedHandle.getUnresolvedSchema().then(unresolvedSchema => {
parentHandle.dependencies.add(uri);
if (unresolvedSchema.errors.length) {
const error = unresolvedSchema.errors[0];
const loc = refSegment ? uri + '#' + refSegment : uri;
const errorMessage = refSegment ? l10n.t('Problems loading reference \'{0}\': {1}', refSegment, error.message) : error.message;
resolveErrors.push(toDiagnostic(errorMessage, error.code, uri));
}
// A referenced document may itself embed subschemas with their own
// absolute $id. Register those as resolvable handles (once per document)
// so a nested $ref by that $id resolves to the embedded resource instead
// of being (mis)loaded as a standalone document. registerEmbeddedSchemas
// only runs for the root document otherwise.
if (!embeddedRegisteredFor.has(uri)) {
embeddedRegisteredFor.add(uri);
registerEmbeddedSchemas(unresolvedSchema.schema, uri);
}
// 2020-12: collect the referenced document's own resource anchor maps
// (once) before merging, so its $dynamicAnchor declarations can join the
// dynamic scope and its $dynamicRef nodes get their lexical resource
// recorded — mirroring what is done eagerly for the root document, before
// $ref merging flattens resource boundaries.
if (unresolvedSchema.schema.$anchorMaps === undefined) {
collectDynamicAnchors(unresolvedSchema.schema);
}
mergeRef(node, unresolvedSchema.schema, referencedHandle, refSegment);
// Referencing a fragment of another resource (e.g. "other#/$defs/x")
// still *enters* that resource's dynamic scope, so its $dynamicAnchor
// declarations must join `node`'s scope even though only the sub-schema
// was merged. (For a whole-document ref the merge above already did this,
// since the merged section is the resource root; this is a no-op then.)
unionAnchorMaps(node, unresolvedSchema.schema.$anchorMaps);
return resolveRefs(node, unresolvedSchema.schema, referencedHandle);
});
};
const resolveDynamicRef = (schema, newBase, newBaseHandle, currentBaseHandle) => {
// 2020-12 $dynamicRef. Its *initial* target is resolved statically, exactly
// like a plain $ref, and kept as hidden metadata (info.target) instead of
// being merged into `schema`, so `schema`'s own keywords are preserved. The
// referenced plain-name fragment is recorded as info.name. The "bookending"
// decision (does the initial target name a $dynamicAnchor?) and the
// dynamic-scope walk are both deferred to validation time, where the (possibly
// asynchronously resolved) target is fully populated. Returns the external
// resolution promise, if one is needed.
const info = dynamicRefInfoOf(schema);
const ref = schema.$dynamicRef;
const segments = ref.split('#', 2);
delete schema.$dynamicRef;
// A JSON-pointer fragment ("#/…") or a missing fragment can never name a
// $dynamicAnchor, so such a $dynamicRef always behaves like a plain $ref.
const fragment = segments[1];
const dynamicName = (isString(fragment) && fragment.length > 0 && fragment.charAt(0) !== '/') ? fragment : undefined;
if (dynamicName !== undefined) {
info.name = dynamicName;
}
if (segments[0].length > 0) {
// External / relative reference: resolve the target document into a
// throwaway container so `schema`'s own keywords are preserved.
const target = {};
info.target = target;
const refBase = (newBase === schema) ? currentBaseHandle : newBaseHandle;
return resolveExternalLink(target, segments[0], segments[1], refBase);
}
// Internal reference. Resolve #name within the *lexical* schema resource
// (recorded as info.scope before $ref merging flattened resource boundaries)
// so a sibling resource's identically-named anchor cannot leak in.
let target;
if (dynamicName !== undefined && info.scope?.$anchorMaps) {
target = info.scope.$anchorMaps.local.get(dynamicName);
}
if (target === undefined) {
// JSON-pointer fragment, or no lexical anchor found: fall back to a plain
// static $ref resolution against the current base.
const container = {};
mergeRef(container, newBase, newBaseHandle, segments[1]);
target = container;
}
info.target = target;
return undefined;
};
const resolveRefs = (node, parentSchema, parentHandle) => {
const prefetchExternalSchema = (uri, parentHandle) => {
// Ordered traversal reuses this cached request before mutating schema state.
getExternalSchemaHandle(uri, parentHandle).getUnresolvedSchema();
};
const prefetchExternalRefs = (schema, currentBase, currentBaseHandle, seen) => {
if (!schema || typeof schema !== 'object' || seen.has(schema)) {
return;
}
seen.add(schema);
const id = getSchemaId(schema);
let newBase = currentBase;
let newBaseHandle = currentBaseHandle;
if (isString(id) && id.charAt(0) !== '#') {
let resolvedUri = id;
if (contextService && !hasSchemeRegex.test(id)) {
resolvedUri = contextService.resolveRelativePath(id, currentBaseHandle.uri);
}
newBase = schema;
newBaseHandle = this.getOrAddSchemaHandle(normalizeId(resolvedUri));
}
const refBase = (newBase === schema) ? currentBaseHandle : newBaseHandle;
if (schema.$ref) {
const segments = schema.$ref.split('#', 2);
if (segments[0].length > 0) {
prefetchExternalSchema(segments[0], refBase);
}
if (schemaDraft !== undefined && schemaDraft < SchemaDraft.v2019_09) {
return;
}
}
if (schema.$dynamicRef && (schemaDraft === undefined || schemaDraft >= SchemaDraft.v2020_12)) {
const segments = schema.$dynamicRef.split('#', 2);
if (segments[0].length > 0) {
prefetchExternalSchema(segments[0], refBase);
}
}
JSONSchemaService.traverseSchemaProperties(schema, childSchema => {
prefetchExternalRefs(childSchema, newBase, newBaseHandle, seen);
});
};
prefetchExternalRefs(node, parentSchema, parentHandle, new Set());
// Traversal that tracks the current base schema for internal refs.
// When we encounter a schema with its own $id, that becomes the new base
// for resolving fragment refs (#...) in its descendants
const traverseWithBaseTracking = (schema, currentBase, currentBaseHandle, isRoot, seen) => {
if (!schema || typeof schema !== 'object' || seen.has(schema)) {
return this.promise.resolve(undefined);
}
seen.add(schema);
// A schema with its own $id defines a new base URI scope for everything
// inside it. Resolve the id (relative to the current base) and use that
// handle as the base going forward.
//
// We do this even when `isRoot` is true so that a recursive call from
// `resolveExternalLink` — which passes the external schema's handle as
// `currentBaseHandle` — still uses *this* node's own $id as its base
// when re-traversing it. Without this, nested $id values inside the
// original schema would be resolved against the external schema's URI.
const id = getSchemaId(schema);
let newBase = currentBase;
let newBaseHandle = currentBaseHandle;
if (isString(id) && id.charAt(0) !== '#') {
let resolvedUri = id;
if (contextService && !hasSchemeRegex.test(id)) {
resolvedUri = contextService.resolveRelativePath(id, currentBaseHandle.uri);
}
resolvedUri = normalizeId(resolvedUri);
newBase = schema;
newBaseHandle = this.getOrAddSchemaHandle(resolvedUri);
// Register the schema under its id if we haven't already.
if (!this.schemasById[resolvedUri]) {
this.addSchemaHandle(resolvedUri, schema);
}
}
// Process refs in this schema
const seenRefs = new Set();
while (schema.$ref) {
const ref = schema.$ref;
const segments = ref.split('#', 2);
delete schema.$ref;
if (segments[0].length > 0) {
// This is a reference to an external schema (like "foo.json" or "foo.json#/bar")
// Per JSON Schema spec, $ref is resolved against the current base URI.
// If this schema has its own $id (sibling case), the $ref should resolve
// against the parent's base, not the sibling $id. Otherwise, use the
// nearest ancestor's base (newBaseHandle).
const refBase = (newBase === schema) ? currentBaseHandle : newBaseHandle;
return resolveExternalLink(schema, segments[0], segments[1], refBase).then(() => undefined);
}
else {
// This is an internal reference (like "#/definitions/foo")
// Internal refs are resolved within the current document
// If this schema has its own $id, it is a new document, so use newBase
// Otherwise, use the parent's base (currentBase)
if (!seenRefs.has(ref)) {
const refId = segments[1];
mergeRef(schema, newBase, newBaseHandle, refId);
seenRefs.add(ref);
}
}
}
// Apply child schemas in keyword order so references never observe
// partially resolved state from an earlier schema.
const traverseChildren = () => {
let result = this.promise.resolve(undefined);
JSONSchemaService.traverseSchemaProperties(schema, childSchema => {
result = result.then(() => traverseWithBaseTracking(childSchema, newBase, newBaseHandle, false, seen));
});
return result;
};
// $dynamicRef is a 2020-12 core keyword. In earlier drafts it is an
// unknown keyword, so leave it untouched and unresolved (ignored) there.
if (schema.$dynamicRef && (schemaDraft === undefined || schemaDraft >= SchemaDraft.v2020_12)) {
const dynamicRefPromise = resolveDynamicRef(schema, newBase, newBaseHandle, currentBaseHandle);
if (dynamicRefPromise) {
return dynamicRefPromise.then(() => traverseChildren());
}
}
return traverseChildren();
};
return traverseWithBaseTracking(node, parentSchema, parentHandle, true, new Set());
};
const collectAnchors = (root) => {
const result = new Map();
const seen = new Set();
// Use the schema's own $schema to determine draft, so that an external
// schema referenced from a doesn't inherit the parent's anchor rules.
const draft = root.$schema ? getSchemaDraftFromId(root.$schema) : schemaDraft;
// Traversal that stops at sub-schemas with their own $id
// because those create a new URI scope for anchors
const traverseForAnchors = (node, isRoot) => {
if (!node || typeof node !== 'object' || seen.has(node)) {
return;
}
seen.add(node);
// If this node has its own $id, and it's not the root, it's a new URI scope
const id = getSchemaId(node);
if (!isRoot && isString(id) && id.charAt(0) !== '#') {
return;
}
// Collect anchor from this node
// In draft-04/06/07, anchors are defined via $id/#fragment (e.g., "$id": "#myanchor")
// In 2019-09+, $id fragments are no longer anchors; $anchor is used instead
// In 2020-12, $dynamicAnchor also defines a plain-name fragment that a
// (static) $ref can resolve to, just like $anchor.
const fragmentAnchor = (draft === undefined || draft < SchemaDraft.v2019_09) && isString(id) && id.charAt(0) === '#' ? id.substring(1) : undefined;
const dollarAnchor = (draft === undefined || draft >= SchemaDraft.v2019_09) ? node.$anchor : undefined;
const dynamicAnchor = (draft === undefined || draft >= SchemaDraft.v2020_12) ? node.$dynamicAnchor : undefined;
const registerAnchor = (anchor) => {
if (!anchor) {
return;
}
if (result.has(anchor) && result.get(anchor) !== node) {
resolveErrors.push(toDiagnostic(l10n.t('Duplicate anchor declaration: \'{0}\'', anchor), ErrorCode.SchemaResolveError));
}
else {
result.set(anchor, node);
}
};
registerAnchor(fragmentAnchor ?? dollarAnchor);
registerAnchor(dynamicAnchor);
// Continue traversing child schemas
JSONSchemaService.traverseSchemaProperties(node, (childSchema) => {
traverseForAnchors(childSchema, false);
});
};
traverseForAnchors(root, true);
return result;
};
// 2020-12: group $anchor / $dynamicAnchor declarations by the schema resource
// ($id scope) that contains them, and attach each resource's name→node maps to
// its resource-root node as hidden metadata ($anchorMaps):
// - $anchorMaps.dynamic: only $dynamicAnchor names, used by the validator to
// walk the dynamic scope (outermost resource first).
// - $anchorMaps.local: both $anchor and $dynamicAnchor names, used to resolve
// an internal $dynamicRef's initial target within its own resource.
// Each $dynamicRef node also gets its lexical resource recorded as
// $dynamicRefInfo.scope. This must run on the original schema, before $ref
// resolution merges (and thereby flattens) resource boundaries; node identities
// are preserved through in-place resolution, so the attached metadata stays valid.
const collectDynamicAnchors = (root) => {
// Respect the resource's own $schema (like collectAnchors) so a referenced
// document with a different, pre-2020-12 dialect is not given $dynamicAnchor
// semantics just because the root document is 2020-12.
const draft = root && typeof root === 'object' && root.$schema ? getSchemaDraftFromId(root.$schema) : schemaDraft;
if (!(draft === undefined || draft >= SchemaDraft.v2020_12)) {
return;
}
const seen = new Set();
const visit = (node, resourceRoot) => {
if (!node || typeof node !== 'object' || seen.has(node)) {
return;
}
seen.add(node);
// A node with its own $id (or the document root) starts a new resource.
let currentRoot = resourceRoot;
const id = getSchemaId(node);
if (node === root || (isString(id) && id.charAt(0) !== '#')) {
currentRoot = node;
if (!currentRoot.$anchorMaps) {
const maps = { dynamic: new Map(), local: new Map() };
setHidden(currentRoot, '$anchorMaps', maps);
}
}
const maps = currentRoot.$anchorMaps;
if (isString(node.$dynamicAnchor)) {
if (!maps.dynamic.has(node.$dynamicAnchor)) {
maps.dynamic.set(node.$dynamicAnchor, node);
}
if (!maps.local.has(node.$dynamicAnchor)) {
maps.local.set(node.$dynamicAnchor, node);
}
}
if (isString(node.$anchor) && !maps.local.has(node.$anchor)) {
maps.local.set(node.$anchor, node);
}
if (isString(node.$dynamicRef)) {
dynamicRefInfoOf(node).scope = currentRoot;
}
JSONSchemaService.traverseSchemaProperties(node, (childSchema) => {
visit(childSchema, currentRoot);
});
};
visit(root, root);
};
// Collect and register embedded schemas with $id so they can be resolved as external refs
// This traversal tracks the current base URI so nested $id values are resolved correctly
const registerEmbeddedSchemas = (root, baseUri) => {
const seen = new Set();
const resolveId = (id, currentBaseUri) => {
if (contextService && !hasSchemeRegex.test(id)) {
return normalizeId(contextService.resolveRelativePath(id, currentBaseUri));
}
return normalizeId(id);
};
const visit = (node, currentBaseUri) => {
if (!node || typeof node !== 'object' || seen.has(node)) {
return;
}
seen.add(node);
// Check if this node has its own $id that changes the base URI
const id = getSchemaId(node);
let newBaseUri = currentBaseUri;
if (isString(id) && id.charAt(0) !== '#') {
const resolvedUri = resolveId(id, currentBaseUri);
const existingHandle = this.schemasById[resolvedUri];
if (!existingHandle) {
this.addSchemaHandle(resolvedUri, node);
}
else {
// Update existing handle with embedded schema content
// This ensures embedded schemas take precedence over external schemas
existingHandle.setSchemaContent(node);
}
newBaseUri = resolvedUri;
}
// Visit child schemas
JSONSchemaService.traverseSchemaProperties(node, (childSchema) => {
visit(childSchema, newBaseUri);
});
};
visit(root, baseUri);
};
// Register embedded schemas before resolving refs
registerEmbeddedSchemas(schema, handle.uri);
// Collect anchors eagerly before $ref resolution mutates the schema.
// resolveRefs merges referenced nodes (including $anchor) into $ref targets,
// so a lazy collectAnchors call could see duplicates from merged copies.
handle.anchors = collectAnchors(schema);
// Collect $dynamicAnchor maps eagerly too, for the same reason: resource
// boundaries must be read before $ref resolution flattens them.
collectDynamicAnchors(schema);
// Resolve meta-schema to extract vocabularies if present
const resolveMetaschemaVocabularies = () => {
if (!schema.$schema || typeof schema.$schema !== 'string') {
return this.promise.resolve(undefined);
}
let metaschemaUri = schema.$schema;
if (contextService && !hasSchemeRegex.test(metaschemaUri)) {
metaschemaUri = contextService.resolveRelativePath(metaschemaUri, handle.uri);
}
const normalizedMetaschemaUri = normalizeId(metaschemaUri);
const metaschemaHandle = this.getOrAddSchemaHandle(normalizedMetaschemaUri);
return metaschemaHandle.getUnresolvedSchema().then(unresolvedMetaschema => {
// Only extract vocabularies if the meta-schema has a $vocabulary property
// or if it's draft 2019-09 or later which support vocabularies.
const metaschemaDraft = unresolvedMetaschema.schema.$schema ? getSchemaDraftFromId(unresolvedMetaschema.schema.$schema) : undefined;
const isDraft2019OrLater = metaschemaDraft && metaschemaDraft >= SchemaDraft.v2019_09;
const hasVocabulary = unresolvedMetaschema.schema.$vocabulary && typeof unresolvedMetaschema.schema.$vocabulary === 'object';
if (hasVocabulary || isDraft2019OrLater) {
activeVocabularies = extractVocabularies(unresolvedMetaschema.schema);
}
return undefined;
}, () => {
// If we can't load the meta-schema, proceed without vocabulary info
return undefined;
});
};
return resolveMetaschemaVocabularies().then(() => {
return resolveRefs(schema, schema, handle).then(_ => {
return new ResolvedSchema(schema, resolveErrors, [], schemaDraft, activeVocabularies);
});
});
}
;
getSchemaFromProperty(resource, document) {
if (document.root?.type === 'object') {
for (const p of document.root.properties) {
if (p.keyNode.value === '$schema' && p.valueNode?.type === 'string') {
let schemaId = p.valueNode.value;
if (this.contextService && !/^\w[\w\d+.-]*:/.test(schemaId)) { // has scheme
schemaId = this.contextService.resolveRelativePath(schemaId, resource);
}
return schemaId;
}
}
}
return undefined;
}
getAssociatedSchemas(resource) {
const seen = Object.create(null);
const schemas = [];
const normalizedResource = normalizeResourceForMatching(resource);
for (const entry of this.filePatternAssociations) {
if (entry.matchesPattern(normalizedResource)) {
for (const schemaId of entry.getURIs()) {
if (!seen[schemaId]) {
schemas.push(schemaId);
seen[schemaId] = true;
}
}
}
}
return schemas;
}
getSchemaURIsForResource(resource, document) {
let schemeId = document && this.getSchemaFromProperty(resource, document);
if (schemeId) {
return [schemeId];
}
return this.getAssociatedSchemas(resource);
}
getSchemaForResource(resource, document) {
if (document) {
// first use $schema if present
let schemeId = this.getSchemaFromProperty(resource, document);
if (schemeId) {
const id = normalizeId(schemeId);
return this.getOrAddSchemaHandle(id).getResolvedSchema();
}
}
if (this.cachedSchemaForResource && this.cachedSchemaForResource.resource === resource) {
return this.cachedSchemaForResource.resolvedSchema;
}
const schemas = this.getAssociatedSchemas(resource);
const resolvedSchema = schemas.length > 0 ? this.createCombinedSchema(resource, schemas).getResolvedSchema() : this.promise.resolve(undefined);
this.cachedSchemaForResource = { resource, resolvedSchema };
return resolvedSchema;
}
createCombinedSchema(resource, schemaIds) {
if (schemaIds.length === 1) {
return this.getOrAddSchemaHandle(schemaIds[0]);
}
else {
const combinedSchemaId = 'schemaservice://combinedSchema/' + encodeURIComponent(resource);
const combinedSchema = {
allOf: schemaIds.map(schemaId => ({ $ref: schemaId }))
};
return this.addSchemaHandle(combinedSchemaId, combinedSchema);
}
}
getMatchingSchemas(document, jsonDocument, schema) {
if (schema) {
const id = schema.id || ('schemaservice://untitled/matchingSchemas/' + idCounter++);
const handle = this.addSchemaHandle(id, schema);
return handle.getResolvedSchema().then(resolvedSchema => {
return jsonDocument.getMatchingSchemas(resolvedSchema.schema).filter(s => !s.inverted);
});
}
return this.getSchemaForResource(document.uri, jsonDocument).then(schema => {
if (schema) {
return jsonDocument.getMatchingSchemas(schema.schema).filter(s => !s.inverted);
}
return [];
});
}
}
let idCounter = 0;
function normalizeResourceForMatching(resource) {
// remove queries and fragments, normalize drive capitalization
try {
return URI.parse(resource).with({ fragment: null, query: null }).toString(true);
}
catch (e) {
return resource;
}
}
function toDisplayString(url) {
try {
const uri = URI.parse(url);
if (uri.scheme === 'file') {
return uri.fsPath;
}
}
catch (e) {
// ignore
}
return url;
}