diff --git a/src/languageservice/services/yamlSchemaService.ts b/src/languageservice/services/yamlSchemaService.ts index af9f7b2f..4125e91f 100644 --- a/src/languageservice/services/yamlSchemaService.ts +++ b/src/languageservice/services/yamlSchemaService.ts @@ -534,6 +534,7 @@ export class YAMLSchemaService extends JSONSchemaService { uri: string, linkPath: string, parentSchemaURL: string, + fallbackBaseURL: string, parentSchemaDependencies: SchemaDependencies, resolutionStack: Set, recursiveAnchorBase: string, @@ -619,7 +620,8 @@ export class YAMLSchemaService extends JSONSchemaService { }; const resolvedUri = _resolveRefUri(parentSchemaURL, uri); - const localSiblingUri = _resolveLocalSiblingFromRemoteUri(parentSchemaURL, resolvedUri); + const hasEmbeddedTarget = !!resourceIndexByUri.get(resolvedUri)?.root; + const localSiblingUri = hasEmbeddedTarget ? undefined : _resolveLocalSiblingFromRemoteUri(fallbackBaseURL, resolvedUri); const targetUris = localSiblingUri && localSiblingUri !== resolvedUri ? [localSiblingUri, resolvedUri] : [resolvedUri]; return _resolveByUri(targetUris); }; @@ -642,21 +644,30 @@ export class YAMLSchemaService extends JSONSchemaService { type WalkItem = { node: JSONSchema; baseURL?: string; + fallbackBaseURL?: string; dialect?: SchemaDialect; recursiveAnchorBase?: string; inheritedDynamicScope?: Map; siblingRefCycleKeys?: Set; }; - const toWalk: WalkItem[] = [{ node, baseURL: parentSchemaURL, recursiveAnchorBase, inheritedDynamicScope }]; + const toWalk: WalkItem[] = [ + { node, baseURL: parentSchemaURL, fallbackBaseURL: parentSchemaURL, recursiveAnchorBase, inheritedDynamicScope }, + ]; const seen = new WeakSet(); // prevents re-walking the same schema object graph // eslint-disable-next-line @typescript-eslint/no-explicit-any const openPromises: Promise[] = []; + const _getChildFallbackBaseURL = (entry: JSONSchema, currentFallbackBaseURL: string): string => { + const resourceUri = entry?._baseUrl; + return resourceUri && resourceIndexByUri.get(resourceUri)?.root === entry ? resourceUri : currentFallbackBaseURL; + }; + // handle $ref with siblings based on dialect const _handleRef = ( next: JSONSchema, nodeBaseURL: string, + fallbackBaseURL: string, nodeDialect: SchemaDialect, recursiveAnchorBase?: string, inheritedDynamicScope?: Map, @@ -666,7 +677,13 @@ export class YAMLSchemaService extends JSONSchemaService { this.collectSchemaNodes( (entry) => - toWalk.push({ node: entry, baseURL: nodeBaseURL, recursiveAnchorBase, inheritedDynamicScope: currentDynamicScope }), + toWalk.push({ + node: entry, + baseURL: nodeBaseURL, + fallbackBaseURL: _getChildFallbackBaseURL(entry, fallbackBaseURL), + recursiveAnchorBase, + inheritedDynamicScope: currentDynamicScope, + }), this.schemaMapValues(next.definitions || next.$defs) ); @@ -760,6 +777,7 @@ export class YAMLSchemaService extends JSONSchemaService { toWalk.push({ node: entry as JSONSchema, baseURL: nodeBaseURL, + fallbackBaseURL: _getChildFallbackBaseURL(entry as JSONSchema, fallbackBaseURL), recursiveAnchorBase, inheritedDynamicScope: currentDynamicScope, siblingRefCycleKeys: nextSiblingRefCycleKeys, @@ -795,6 +813,7 @@ export class YAMLSchemaService extends JSONSchemaService { recursiveBase, '', nodeBaseURL, + fallbackBaseURL, parentSchemaDependencies, resolutionStack, recursiveAnchorBase, @@ -830,6 +849,7 @@ export class YAMLSchemaService extends JSONSchemaService { resolveResource, frag, nodeBaseURL, + fallbackBaseURL, parentSchemaDependencies, resolutionStack, recursiveAnchorBase, @@ -850,6 +870,7 @@ export class YAMLSchemaService extends JSONSchemaService { baseUri, frag, nodeBaseURL, + fallbackBaseURL, parentSchemaDependencies, resolutionStack, recursiveAnchorBase, @@ -872,6 +893,7 @@ export class YAMLSchemaService extends JSONSchemaService { toWalk.push({ node: entry, baseURL: next._baseUrl || nodeBaseURL, + fallbackBaseURL: _getChildFallbackBaseURL(entry, fallbackBaseURL), dialect: nodeDialect, recursiveAnchorBase, inheritedDynamicScope: currentDynamicScope, @@ -907,6 +929,7 @@ export class YAMLSchemaService extends JSONSchemaService { segments[0], segments[1], parentSchemaURL, + parentSchemaURL, parentSchemaDependencies, resolutionStack, recursiveAnchorBase, @@ -928,11 +951,20 @@ export class YAMLSchemaService extends JSONSchemaService { const item = toWalk.pop(); const next = item.node; const nodeBaseURL = next._baseUrl || item.baseURL; + const fallbackBaseURL = item.fallbackBaseURL || item.baseURL; const nodeDialect = next._dialect || item.dialect; const nodeRecursiveAnchorBase = item.recursiveAnchorBase ?? (next.$recursiveAnchor ? nodeBaseURL : undefined); if (seen.has(next)) continue; seen.add(next); - _handleRef(next, nodeBaseURL, nodeDialect, nodeRecursiveAnchorBase, item.inheritedDynamicScope, item.siblingRefCycleKeys); + _handleRef( + next, + nodeBaseURL, + fallbackBaseURL, + nodeDialect, + nodeRecursiveAnchorBase, + item.inheritedDynamicScope, + item.siblingRefCycleKeys + ); } return Promise.all(openPromises); }; diff --git a/test/yamlSchemaService.test.ts b/test/yamlSchemaService.test.ts index 44069db9..ac5768c9 100644 --- a/test/yamlSchemaService.test.ts +++ b/test/yamlSchemaService.test.ts @@ -164,6 +164,121 @@ describe('YAML Schema Service', () => { expect(requestedUris).to.not.include('https://example.com/schemas/secondary.json'); }); + it('should resolve relative local sibling refs when the root $id basename differs from the local filename', async () => { + const content = + `# yaml-language-server: $schema=file:///schemas/repro_main_schema.json\n` + + `members:\n` + + ` - name: Alice\n` + + ` age: 30`; + const yamlDock = parse(content); + + const primarySchema = { + $id: 'https://example.com/schemas/repro-main-v1', + type: 'object', + properties: { + members: { + type: 'array', + items: { + $ref: './repro_defs.json#/$defs/Person', + }, + }, + }, + required: ['members'], + }; + const defsSchema = { + $id: 'https://example.com/schemas/repro-defs-v1', + $defs: { + Person: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'integer' }, + }, + required: ['name'], + }, + }, + }; + + requestServiceMock = sandbox.fake((uri: string) => { + if (uri === 'file:///schemas/repro_main_schema.json') { + return Promise.resolve(JSON.stringify(primarySchema)); + } + if (uri === 'file:///schemas/repro_defs.json') { + return Promise.resolve(JSON.stringify(defsSchema)); + } + return Promise.reject(`Resource ${uri} not found.`); + }); + + const service = new SchemaService.YAMLSchemaService(requestServiceMock, workspaceContext); + const schema = await service.getSchemaForResource('', yamlDock.documents[0]); + + const requestedUris = requestServiceMock.getCalls().map((call) => call.args[0]); + expect(requestedUris).to.include('file:///schemas/repro_main_schema.json'); + expect(requestedUris).to.include('file:///schemas/repro_defs.json'); + expect(requestedUris).to.not.include('https://example.com/schemas/repro_defs.json'); + expect(schema.errors).to.eql([]); + expect(schema.schema.properties.members.items).to.deep.include({ + type: 'object', + url: 'file:///schemas/repro_defs.json', + }); + }); + + it('should resolve nested local sibling refs relative to the loaded sibling schema file', async () => { + const content = `# yaml-language-server: $schema=file:///schemas/primary.json\nitem: ok`; + const yamlDock = parse(content); + + const primarySchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://example.com/schemas/primary-v1', + $ref: './secondary.json', + }; + const secondarySchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://example.com/schemas/secondary-v1', + type: 'object', + properties: { + item: { + $ref: './third.json', + }, + }, + required: ['item'], + }; + const thirdSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://example.com/schemas/third-v1', + type: 'string', + enum: ['ok'], + }; + + requestServiceMock = sandbox.fake((uri: string) => { + if (uri === 'file:///schemas/primary.json') { + return Promise.resolve(JSON.stringify(primarySchema)); + } + if (uri === 'file:///schemas/secondary.json') { + return Promise.resolve(JSON.stringify(secondarySchema)); + } + if (uri === 'file:///schemas/third.json') { + return Promise.resolve(JSON.stringify(thirdSchema)); + } + return Promise.reject(`Resource ${uri} not found.`); + }); + + const service = new SchemaService.YAMLSchemaService(requestServiceMock, workspaceContext); + const schema = await service.getSchemaForResource('', yamlDock.documents[0]); + + const requestedUris = requestServiceMock.getCalls().map((call) => call.args[0]); + expect(requestedUris).to.include('file:///schemas/primary.json'); + expect(requestedUris).to.include('file:///schemas/secondary.json'); + expect(requestedUris).to.include('file:///schemas/third.json'); + expect(requestedUris).to.not.include('https://example.com/schemas/secondary.json'); + expect(requestedUris).to.not.include('https://example.com/schemas/third.json'); + expect(schema.errors).to.eql([]); + expect(schema.schema.properties.item).to.deep.include({ + type: 'string', + url: 'file:///schemas/third.json', + }); + }); + it('should resolve absolute $ref via remote base and mapped local sibling path', async () => { const content = `# yaml-language-server: $schema=file:///dir/primary.json\nname: John\nage: -1`; const yamlDock = parse(content);