From 1f5dc581fb963f0aeb2b6177befba08419bd526e Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Tue, 3 Feb 2026 01:17:09 +0100 Subject: [PATCH 01/18] feat: add bundled schema support to LSP server Introduce BundledSchemaProvider and CompositeSchemaProvider to enable schemas to be shipped with the extension. CompositeSchemaProvider chains multiple providers with priority ordering, ensuring user-configured schemas take precedence over bundled ones. - Add BundledSchemaProvider for extension-bundled schemas - Add CompositeSchemaProvider with priority-based provider chaining - Extend SchemaProvider interface with optional languageId parameter - Add unit and integration tests for both providers # Conflicts: # tooling/language-server-protocol/src/core/document/KsonDocumentsManager.ts --- .../src/core/document/KsonDocumentsManager.ts | 12 +- .../src/core/schema/BundledSchemaProvider.ts | 129 +++++++++ .../core/schema/CompositeSchemaProvider.ts | 76 +++++ .../core/schema/FileSystemSchemaProvider.ts | 3 +- .../src/core/schema/SchemaProvider.ts | 5 +- .../src/startKsonServer.ts | 77 ++++- .../core/schema/BundledSchemaProvider.test.ts | 208 ++++++++++++++ .../schema/CompositeSchemaProvider.test.ts | 264 ++++++++++++++++++ .../vscode/test/suite/bundled-schema.test.ts | 232 +++++++++++++++ .../vscode/test/suite/language-config.test.ts | 48 ++++ 10 files changed, 1042 insertions(+), 12 deletions(-) create mode 100644 tooling/language-server-protocol/src/core/schema/BundledSchemaProvider.ts create mode 100644 tooling/language-server-protocol/src/core/schema/CompositeSchemaProvider.ts create mode 100644 tooling/language-server-protocol/src/test/core/schema/BundledSchemaProvider.test.ts create mode 100644 tooling/language-server-protocol/src/test/core/schema/CompositeSchemaProvider.test.ts create mode 100644 tooling/lsp-clients/vscode/test/suite/bundled-schema.test.ts diff --git a/tooling/language-server-protocol/src/core/document/KsonDocumentsManager.ts b/tooling/language-server-protocol/src/core/document/KsonDocumentsManager.ts index ec1b118b..72fd534b 100644 --- a/tooling/language-server-protocol/src/core/document/KsonDocumentsManager.ts +++ b/tooling/language-server-protocol/src/core/document/KsonDocumentsManager.ts @@ -26,8 +26,8 @@ export class KsonDocumentsManager extends TextDocuments { ): KsonDocument => { const textDocument = TextDocument.create(uri, languageId, version, content); - // Try to get schema from provider - let schemaDocument = provider.getSchemaForDocument(uri); + // Try to get schema from provider, passing languageId for bundled schema support + let schemaDocument = provider.getSchemaForDocument(uri, languageId); const parseResult = Kson.getInstance().analyze(content, uri); return new KsonDocument(textDocument, parseResult, schemaDocument); @@ -43,10 +43,12 @@ export class KsonDocumentsManager extends TextDocuments { version ); const parseResult = Kson.getInstance().analyze(textDocument.getText(), ksonDocument.uri); + // Pass languageId for bundled schema support + const languageId = textDocument.languageId; return new KsonDocument( textDocument, parseResult, - provider.getSchemaForDocument(ksonDocument.uri) + provider.getSchemaForDocument(ksonDocument.uri, languageId) ); } }); @@ -83,7 +85,9 @@ export class KsonDocumentsManager extends TextDocuments { for (const doc of allDocs) { const textDocument = doc.textDocument; const parseResult = doc.getAnalysisResult(); - const updatedSchema = this.schemaProvider.getSchemaForDocument(doc.uri); + // Pass languageId for bundled schema support + const languageId = textDocument.languageId; + const updatedSchema = this.schemaProvider.getSchemaForDocument(doc.uri, languageId); // Create new document instance with updated schema const updatedDoc = new KsonDocument(textDocument, parseResult, updatedSchema); diff --git a/tooling/language-server-protocol/src/core/schema/BundledSchemaProvider.ts b/tooling/language-server-protocol/src/core/schema/BundledSchemaProvider.ts new file mode 100644 index 00000000..14fec60b --- /dev/null +++ b/tooling/language-server-protocol/src/core/schema/BundledSchemaProvider.ts @@ -0,0 +1,129 @@ +import {TextDocument} from 'vscode-languageserver-textdocument'; +import {DocumentUri} from 'vscode-languageserver'; +import {SchemaProvider} from './SchemaProvider.js'; + +/** + * Configuration for a bundled schema. + */ +export interface BundledSchemaConfig { + /** The language ID this schema applies to (e.g., 'kxt', 'kson-config') */ + languageId: string; + /** The pre-loaded schema content as a string */ + schemaContent: string; +} + +/** + * Schema provider for bundled schemas that are shipped with the extension. + * Works in both browser and Node.js environments since it doesn't require file system access. + * + * Bundled schemas are identified by language ID and use a special URI scheme: + * bundled://schema/{languageId} + */ +export class BundledSchemaProvider implements SchemaProvider { + private schemas: Map; + private enabled: boolean; + + /** + * Creates a new BundledSchemaProvider. + * + * @param schemas Array of bundled schema configurations + * @param enabled Whether bundled schemas are enabled (default: true) + * @param logger Optional logger for warnings and errors + */ + constructor( + schemas: BundledSchemaConfig[], + enabled: boolean = true, + private logger?: { + info: (message: string) => void; + warn: (message: string) => void; + error: (message: string) => void; + } + ) { + this.enabled = enabled; + this.schemas = new Map(); + + // Create TextDocuments from the provided schema content + for (const config of schemas) { + try { + const schemaUri = `bundled://schema/${config.languageId}`; + const schemaDocument = TextDocument.create( + schemaUri, + 'kson', + 1, + config.schemaContent + ); + this.schemas.set(config.languageId, schemaDocument); + this.logger?.info(`Loaded bundled schema for language: ${config.languageId}`); + } catch (error) { + this.logger?.error(`Failed to load bundled schema for ${config.languageId}: ${error}`); + } + } + + this.logger?.info(`BundledSchemaProvider initialized with ${this.schemas.size} schemas, enabled: ${enabled}`); + } + + /** + * Get the schema for a document based on its language ID. + * + * @param _documentUri The URI of the KSON document (unused by bundled provider) + * @param languageId The language ID to look up the schema for + * @returns TextDocument containing the schema, or undefined if no bundled schema exists for this language + */ + getSchemaForDocument(_documentUri: DocumentUri, languageId?: string): TextDocument | undefined { + if (!this.enabled || !languageId) { + return undefined; + } + + return this.schemas.get(languageId); + } + + /** + * Reload the schema configuration. + * For bundled schemas, this is a no-op since schemas are provided at construction time. + */ + reload(): void { + // No-op: bundled schemas are immutable and provided at construction time + } + + /** + * Check if a given file URI is a schema file. + * Bundled schemas use the bundled:// scheme. + * + * @param fileUri The URI of the file to check + * @returns True if the file is a bundled schema file + */ + isSchemaFile(fileUri: DocumentUri): boolean { + return fileUri.startsWith('bundled://schema/'); + } + + /** + * Enable or disable bundled schema support. + * + * @param enabled Whether bundled schemas should be enabled + */ + setEnabled(enabled: boolean): void { + this.enabled = enabled; + this.logger?.info(`BundledSchemaProvider ${enabled ? 'enabled' : 'disabled'}`); + } + + /** + * Check if bundled schemas are currently enabled. + */ + isEnabled(): boolean { + return this.enabled; + } + + /** + * Get the list of language IDs that have bundled schemas. + */ + getAvailableLanguageIds(): string[] { + return Array.from(this.schemas.keys()); + } + + /** + * Check if a bundled schema exists for a given language ID. + */ + hasBundledSchema(languageId: string): boolean { + return this.schemas.has(languageId); + } +} diff --git a/tooling/language-server-protocol/src/core/schema/CompositeSchemaProvider.ts b/tooling/language-server-protocol/src/core/schema/CompositeSchemaProvider.ts new file mode 100644 index 00000000..44013e74 --- /dev/null +++ b/tooling/language-server-protocol/src/core/schema/CompositeSchemaProvider.ts @@ -0,0 +1,76 @@ +import {TextDocument} from 'vscode-languageserver-textdocument'; +import {DocumentUri} from 'vscode-languageserver'; +import {SchemaProvider} from './SchemaProvider.js'; + +/** + * A composite schema provider that chains multiple providers together. + * Providers are queried in order, and the first non-undefined result is returned. + * + * This enables the priority system where user-configured schemas (via .kson-schema.kson) + * take precedence over bundled schemas. + * + * Typical usage: + * CompositeSchemaProvider([FileSystemSchemaProvider, BundledSchemaProvider]) + */ +export class CompositeSchemaProvider implements SchemaProvider { + /** + * Creates a new CompositeSchemaProvider. + * + * @param providers Array of schema providers to chain, in priority order (first takes precedence) + * @param logger Optional logger for debugging + */ + constructor( + private providers: SchemaProvider[], + private logger?: { + info: (message: string) => void; + warn: (message: string) => void; + error: (message: string) => void; + } + ) { + this.logger?.info(`CompositeSchemaProvider initialized with ${providers.length} providers`); + } + + /** + * Get the schema for a document by querying all providers in order. + * Returns the first non-undefined result. + * + * @param documentUri The URI of the KSON document + * @param languageId Optional language ID for bundled schema lookup + * @returns TextDocument containing the schema, or undefined if no provider has a schema + */ + getSchemaForDocument(documentUri: DocumentUri, languageId?: string): TextDocument | undefined { + for (const provider of this.providers) { + const schema = provider.getSchemaForDocument(documentUri, languageId); + if (schema) { + return schema; + } + } + return undefined; + } + + /** + * Reload all providers' configurations. + */ + reload(): void { + for (const provider of this.providers) { + provider.reload(); + } + } + + /** + * Check if a given file URI is a schema file in any provider. + * + * @param fileUri The URI of the file to check + * @returns True if any provider considers this a schema file + */ + isSchemaFile(fileUri: DocumentUri): boolean { + return this.providers.some(provider => provider.isSchemaFile(fileUri)); + } + + /** + * Get the list of providers. + */ + getProviders(): ReadonlyArray { + return this.providers; + } +} diff --git a/tooling/language-server-protocol/src/core/schema/FileSystemSchemaProvider.ts b/tooling/language-server-protocol/src/core/schema/FileSystemSchemaProvider.ts index 542eabe3..9c9c13f7 100644 --- a/tooling/language-server-protocol/src/core/schema/FileSystemSchemaProvider.ts +++ b/tooling/language-server-protocol/src/core/schema/FileSystemSchemaProvider.ts @@ -67,9 +67,10 @@ export class FileSystemSchemaProvider implements SchemaProvider { * Get the schema for a given document URI. * * @param documentUri The URI of the KSON document + * @param _languageId Optional language ID (unused by file system provider) * @returns TextDocument containing the schema, or undefined if no schema is configured */ - getSchemaForDocument(documentUri: DocumentUri): TextDocument | undefined { + getSchemaForDocument(documentUri: DocumentUri, _languageId?: string): TextDocument | undefined { if (!this.config || !this.workspaceRoot) { return undefined; } diff --git a/tooling/language-server-protocol/src/core/schema/SchemaProvider.ts b/tooling/language-server-protocol/src/core/schema/SchemaProvider.ts index 2c4ffbf6..c1006cf1 100644 --- a/tooling/language-server-protocol/src/core/schema/SchemaProvider.ts +++ b/tooling/language-server-protocol/src/core/schema/SchemaProvider.ts @@ -10,9 +10,10 @@ export interface SchemaProvider { * Get the schema document for a given KSON document URI. * * @param documentUri The URI of the KSON document + * @param languageId Optional language ID for bundled schema lookup * @returns TextDocument containing the schema, or undefined if no schema is available */ - getSchemaForDocument(documentUri: DocumentUri): TextDocument | undefined; + getSchemaForDocument(documentUri: DocumentUri, languageId?: string): TextDocument | undefined; /** * Reload the schema configuration. @@ -34,7 +35,7 @@ export interface SchemaProvider { * Used as a fallback when no schema configuration is available. */ export class NoOpSchemaProvider implements SchemaProvider { - getSchemaForDocument(_documentUri: DocumentUri): TextDocument | undefined { + getSchemaForDocument(_documentUri: DocumentUri, _languageId?: string): TextDocument | undefined { return undefined; } diff --git a/tooling/language-server-protocol/src/startKsonServer.ts b/tooling/language-server-protocol/src/startKsonServer.ts index e40cda79..594bb4f7 100644 --- a/tooling/language-server-protocol/src/startKsonServer.ts +++ b/tooling/language-server-protocol/src/startKsonServer.ts @@ -12,9 +12,21 @@ import {KSON_LEGEND} from './core/features/SemanticTokensService.js'; import {getAllCommandIds} from './core/commands/CommandType.js'; import { ksonSettingsWithDefaults } from './core/KsonSettings.js'; import {SchemaProvider} from './core/schema/SchemaProvider.js'; +import {BundledSchemaProvider, BundledSchemaConfig} from './core/schema/BundledSchemaProvider.js'; +import {CompositeSchemaProvider} from './core/schema/CompositeSchemaProvider.js'; import {SCHEMA_CONFIG_FILENAME} from "./core/schema/SchemaConfig"; import {CommandExecutorFactory} from "./core/commands/CommandExecutorFactory"; +/** + * Initialization options passed from the VSCode client. + */ +interface KsonInitializationOptions { + /** Bundled schemas to be loaded */ + bundledSchemas?: BundledSchemaConfig[]; + /** Whether bundled schemas are enabled */ + enableBundledSchemas?: boolean; +} + type SchemaProviderFactory = ( workspaceRootUri: URI | undefined, logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void } @@ -45,6 +57,7 @@ export function startKsonServer( // Initialize core components (documentManager will be created after onInitialize) let documentManager: KsonDocumentsManager; let textDocumentService: KsonTextDocumentService; + let bundledSchemaProvider: BundledSchemaProvider | undefined; // Setup connection event handlers connection.onInitialize(async (params): Promise => { @@ -54,8 +67,31 @@ export function startKsonServer( workspaceRootUri = URI.parse(stringUri) } - // Create the appropriate schema provider for this environment - const schemaProvider = await createSchemaProvider(workspaceRootUri, logger); + // Extract bundled schema configuration from initialization options + const initOptions = params.initializationOptions as KsonInitializationOptions | undefined; + const bundledSchemas = initOptions?.bundledSchemas ?? []; + const enableBundledSchemas = initOptions?.enableBundledSchemas ?? true; + + // Create the appropriate schema provider for this environment (file system or no-op) + const fileSystemSchemaProvider = await createSchemaProvider(workspaceRootUri, logger); + + // Create bundled schema provider if schemas are configured + let schemaProvider: SchemaProvider | undefined; + if (bundledSchemas.length > 0) { + bundledSchemaProvider = new BundledSchemaProvider(bundledSchemas, enableBundledSchemas, logger); + + // Create composite provider: file system takes priority over bundled + const providers: SchemaProvider[] = []; + if (fileSystemSchemaProvider) { + providers.push(fileSystemSchemaProvider); + } + providers.push(bundledSchemaProvider); + + schemaProvider = new CompositeSchemaProvider(providers, logger); + logger.info(`Created composite schema provider with ${providers.length} providers`); + } else { + schemaProvider = fileSystemSchemaProvider; + } // Now that we have workspace root and schema provider, create the document manager documentManager = new KsonDocumentsManager(schemaProvider); @@ -142,25 +178,30 @@ export function startKsonServer( const schemaDocument = documentManager.get(params.uri)?.getSchemaDocument(); if (schemaDocument) { const schemaUri = schemaDocument.uri; + // Check if this is a bundled schema (uses bundled:// scheme) + const isBundled = schemaUri.startsWith('bundled://'); // Extract readable path from URI const schemaPath = schemaUri.startsWith('file://') ? URI.parse(schemaUri).fsPath : schemaUri; return { schemaUri, schemaPath, - hasSchema: true + hasSchema: true, + isBundled }; } return { schemaUri: undefined, schemaPath: undefined, - hasSchema: false + hasSchema: false, + isBundled: false }; } catch (error) { logger.error(`Error getting schema for document: ${error}`); return { schemaUri: undefined, schemaPath: undefined, - hasSchema: false + hasSchema: false, + isBundled: false }; } }); @@ -198,9 +239,35 @@ export function startKsonServer( const configuration = ksonSettingsWithDefaults(change.settings); textDocumentService.updateConfiguration(configuration); + // Check if bundled schema setting changed + if (bundledSchemaProvider && change.settings?.kson?.enableBundledSchemas !== undefined) { + const enabled = change.settings.kson.enableBundledSchemas; + bundledSchemaProvider.setEnabled(enabled); + // Refresh all documents to apply the change + documentManager.refreshDocumentSchemas(); + // Notify client that schema configuration changed + connection.sendNotification('kson/schemaConfigurationChanged'); + // Refresh diagnostics + connection.sendRequest('workspace/diagnostic/refresh'); + } + connection.console.info('Configuration updated'); }); + // Handle notification to update bundled schema settings + connection.onNotification('kson/updateBundledSchemaSettings', (params: { enabled: boolean }) => { + if (bundledSchemaProvider) { + bundledSchemaProvider.setEnabled(params.enabled); + // Refresh all documents to apply the change + documentManager.refreshDocumentSchemas(); + // Notify client that schema configuration changed + connection.sendNotification('kson/schemaConfigurationChanged'); + // Refresh diagnostics + connection.sendRequest('workspace/diagnostic/refresh'); + logger.info(`Bundled schemas ${params.enabled ? 'enabled' : 'disabled'}`); + } + }); + // Start listening for requests connection.listen(); connection.console.info('Kson Language Server started and listening'); diff --git a/tooling/language-server-protocol/src/test/core/schema/BundledSchemaProvider.test.ts b/tooling/language-server-protocol/src/test/core/schema/BundledSchemaProvider.test.ts new file mode 100644 index 00000000..f412d81f --- /dev/null +++ b/tooling/language-server-protocol/src/test/core/schema/BundledSchemaProvider.test.ts @@ -0,0 +1,208 @@ +import {describe, it} from 'mocha'; +import * as assert from 'assert'; +import {BundledSchemaProvider, BundledSchemaConfig} from '../../../core/schema/BundledSchemaProvider'; + +describe('BundledSchemaProvider', () => { + let logs: string[] = []; + + const logger = { + info: (msg: string) => logs.push(`INFO: ${msg}`), + warn: (msg: string) => logs.push(`WARN: ${msg}`), + error: (msg: string) => logs.push(`ERROR: ${msg}`) + }; + + beforeEach(() => { + logs = []; + }); + + describe('constructor', () => { + it('should create provider with no schemas', () => { + const provider = new BundledSchemaProvider([], true, logger); + assert.ok(provider); + assert.strictEqual(provider.getAvailableLanguageIds().length, 0); + assert.ok(logs.some(msg => msg.includes('initialized with 0 schemas'))); + }); + + it('should create provider with schemas', () => { + const schemas: BundledSchemaConfig[] = [ + { languageId: 'test-lang', schemaContent: '{ "type": "object" }' } + ]; + const provider = new BundledSchemaProvider(schemas, true, logger); + + assert.ok(provider); + assert.strictEqual(provider.getAvailableLanguageIds().length, 1); + assert.ok(provider.hasBundledSchema('test-lang')); + assert.ok(logs.some(msg => msg.includes('Loaded bundled schema for language: test-lang'))); + }); + + it('should create provider with multiple schemas', () => { + const schemas: BundledSchemaConfig[] = [ + { languageId: 'lang-a', schemaContent: '{ "type": "object" }' }, + { languageId: 'lang-b', schemaContent: '{ "type": "array" }' } + ]; + const provider = new BundledSchemaProvider(schemas, true, logger); + + assert.strictEqual(provider.getAvailableLanguageIds().length, 2); + assert.ok(provider.hasBundledSchema('lang-a')); + assert.ok(provider.hasBundledSchema('lang-b')); + }); + + it('should respect enabled flag', () => { + const schemas: BundledSchemaConfig[] = [ + { languageId: 'test-lang', schemaContent: '{ "type": "object" }' } + ]; + const provider = new BundledSchemaProvider(schemas, false, logger); + + assert.strictEqual(provider.isEnabled(), false); + assert.ok(logs.some(msg => msg.includes('enabled: false'))); + }); + }); + + describe('getSchemaForDocument', () => { + it('should return undefined when disabled', () => { + const schemas: BundledSchemaConfig[] = [ + { languageId: 'test-lang', schemaContent: '{ "type": "object" }' } + ]; + const provider = new BundledSchemaProvider(schemas, false, logger); + + const schema = provider.getSchemaForDocument('file:///test.kson', 'test-lang'); + assert.strictEqual(schema, undefined); + }); + + it('should return undefined when no languageId provided', () => { + const schemas: BundledSchemaConfig[] = [ + { languageId: 'test-lang', schemaContent: '{ "type": "object" }' } + ]; + const provider = new BundledSchemaProvider(schemas, true, logger); + + const schema = provider.getSchemaForDocument('file:///test.kson'); + assert.strictEqual(schema, undefined); + }); + + it('should return undefined for unknown languageId', () => { + const schemas: BundledSchemaConfig[] = [ + { languageId: 'test-lang', schemaContent: '{ "type": "object" }' } + ]; + const provider = new BundledSchemaProvider(schemas, true, logger); + + const schema = provider.getSchemaForDocument('file:///test.kson', 'unknown-lang'); + assert.strictEqual(schema, undefined); + }); + + it('should return schema for matching languageId', () => { + const schemaContent = '{ "type": "object" }'; + const schemas: BundledSchemaConfig[] = [ + { languageId: 'test-lang', schemaContent } + ]; + const provider = new BundledSchemaProvider(schemas, true, logger); + + const schema = provider.getSchemaForDocument('file:///test.kson', 'test-lang'); + assert.ok(schema); + assert.strictEqual(schema!.getText(), schemaContent); + assert.strictEqual(schema!.uri, 'bundled://schema/test-lang'); + }); + + it('should ignore documentUri and use languageId', () => { + const schemaContent = '{ "type": "object" }'; + const schemas: BundledSchemaConfig[] = [ + { languageId: 'test-lang', schemaContent } + ]; + const provider = new BundledSchemaProvider(schemas, true, logger); + + // Different document URIs should return same schema for same languageId + const schema1 = provider.getSchemaForDocument('file:///a.kson', 'test-lang'); + const schema2 = provider.getSchemaForDocument('file:///b.kson', 'test-lang'); + + assert.ok(schema1); + assert.ok(schema2); + assert.strictEqual(schema1!.uri, schema2!.uri); + }); + }); + + describe('isSchemaFile', () => { + it('should return true for bundled schema URIs', () => { + const provider = new BundledSchemaProvider([], true, logger); + + assert.strictEqual(provider.isSchemaFile('bundled://schema/test-lang'), true); + assert.strictEqual(provider.isSchemaFile('bundled://schema/other'), true); + }); + + it('should return false for non-bundled URIs', () => { + const provider = new BundledSchemaProvider([], true, logger); + + assert.strictEqual(provider.isSchemaFile('file:///test.kson'), false); + assert.strictEqual(provider.isSchemaFile('untitled:///test.kson'), false); + }); + }); + + describe('setEnabled', () => { + it('should toggle enabled state', () => { + const schemas: BundledSchemaConfig[] = [ + { languageId: 'test-lang', schemaContent: '{ "type": "object" }' } + ]; + const provider = new BundledSchemaProvider(schemas, true, logger); + + assert.strictEqual(provider.isEnabled(), true); + + provider.setEnabled(false); + assert.strictEqual(provider.isEnabled(), false); + + // Should not return schema when disabled + const schema = provider.getSchemaForDocument('file:///test.kson', 'test-lang'); + assert.strictEqual(schema, undefined); + + provider.setEnabled(true); + assert.strictEqual(provider.isEnabled(), true); + + // Should return schema when re-enabled + const schema2 = provider.getSchemaForDocument('file:///test.kson', 'test-lang'); + assert.ok(schema2); + }); + }); + + describe('reload', () => { + it('should be a no-op (bundled schemas are immutable)', () => { + const schemas: BundledSchemaConfig[] = [ + { languageId: 'test-lang', schemaContent: '{ "type": "object" }' } + ]; + const provider = new BundledSchemaProvider(schemas, true, logger); + + // reload should not throw or change anything + provider.reload(); + + assert.strictEqual(provider.getAvailableLanguageIds().length, 1); + assert.ok(provider.hasBundledSchema('test-lang')); + }); + }); + + describe('hasBundledSchema', () => { + it('should return true for configured languages', () => { + const schemas: BundledSchemaConfig[] = [ + { languageId: 'lang-a', schemaContent: '{}' }, + { languageId: 'lang-b', schemaContent: '{}' } + ]; + const provider = new BundledSchemaProvider(schemas, true, logger); + + assert.strictEqual(provider.hasBundledSchema('lang-a'), true); + assert.strictEqual(provider.hasBundledSchema('lang-b'), true); + assert.strictEqual(provider.hasBundledSchema('lang-c'), false); + }); + }); + + describe('getAvailableLanguageIds', () => { + it('should return all configured language IDs', () => { + const schemas: BundledSchemaConfig[] = [ + { languageId: 'alpha', schemaContent: '{}' }, + { languageId: 'beta', schemaContent: '{}' }, + { languageId: 'gamma', schemaContent: '{}' } + ]; + const provider = new BundledSchemaProvider(schemas, true, logger); + + const ids = provider.getAvailableLanguageIds(); + assert.strictEqual(ids.length, 3); + assert.ok(ids.includes('alpha')); + assert.ok(ids.includes('beta')); + assert.ok(ids.includes('gamma')); + }); + }); +}); diff --git a/tooling/language-server-protocol/src/test/core/schema/CompositeSchemaProvider.test.ts b/tooling/language-server-protocol/src/test/core/schema/CompositeSchemaProvider.test.ts new file mode 100644 index 00000000..280ce8b1 --- /dev/null +++ b/tooling/language-server-protocol/src/test/core/schema/CompositeSchemaProvider.test.ts @@ -0,0 +1,264 @@ +import {describe, it} from 'mocha'; +import * as assert from 'assert'; +import {TextDocument} from 'vscode-languageserver-textdocument'; +import {DocumentUri} from 'vscode-languageserver'; +import {CompositeSchemaProvider} from '../../../core/schema/CompositeSchemaProvider'; +import {SchemaProvider, NoOpSchemaProvider} from '../../../core/schema/SchemaProvider'; + +/** + * Mock schema provider that returns a predefined schema for specific URIs. + */ +class MockSchemaProvider implements SchemaProvider { + private schemas: Map = new Map(); + private schemaFiles: Set = new Set(); + + addSchema(documentUri: string, schema: TextDocument): void { + this.schemas.set(documentUri, schema); + } + + addSchemaByLanguageId(languageId: string, schema: TextDocument): void { + this.schemas.set(`lang:${languageId}`, schema); + } + + markAsSchemaFile(uri: string): void { + this.schemaFiles.add(uri); + } + + getSchemaForDocument(documentUri: DocumentUri, languageId?: string): TextDocument | undefined { + // Check by languageId first + if (languageId) { + const langSchema = this.schemas.get(`lang:${languageId}`); + if (langSchema) return langSchema; + } + // Then check by URI + return this.schemas.get(documentUri); + } + + reload(): void { + // No-op for mock + } + + isSchemaFile(fileUri: DocumentUri): boolean { + return this.schemaFiles.has(fileUri); + } +} + +describe('CompositeSchemaProvider', () => { + let logs: string[] = []; + + const logger = { + info: (msg: string) => logs.push(`INFO: ${msg}`), + warn: (msg: string) => logs.push(`WARN: ${msg}`), + error: (msg: string) => logs.push(`ERROR: ${msg}`) + }; + + beforeEach(() => { + logs = []; + }); + + function createSchema(uri: string, content: string): TextDocument { + return TextDocument.create(uri, 'kson', 1, content); + } + + describe('constructor', () => { + it('should create provider with no providers', () => { + const provider = new CompositeSchemaProvider([], logger); + assert.ok(provider); + assert.strictEqual(provider.getProviders().length, 0); + }); + + it('should create provider with multiple providers', () => { + const mock1 = new MockSchemaProvider(); + const mock2 = new MockSchemaProvider(); + const provider = new CompositeSchemaProvider([mock1, mock2], logger); + + assert.strictEqual(provider.getProviders().length, 2); + }); + }); + + describe('getSchemaForDocument', () => { + it('should return undefined when no providers', () => { + const provider = new CompositeSchemaProvider([], logger); + const schema = provider.getSchemaForDocument('file:///test.kson'); + assert.strictEqual(schema, undefined); + }); + + it('should return undefined when no provider has schema', () => { + const mock1 = new MockSchemaProvider(); + const mock2 = new MockSchemaProvider(); + const provider = new CompositeSchemaProvider([mock1, mock2], logger); + + const schema = provider.getSchemaForDocument('file:///test.kson'); + assert.strictEqual(schema, undefined); + }); + + it('should return schema from first matching provider', () => { + const mock1 = new MockSchemaProvider(); + const mock2 = new MockSchemaProvider(); + + const schema1 = createSchema('file:///schema1.kson', '{ "from": "first" }'); + mock1.addSchema('file:///test.kson', schema1); + + const schema2 = createSchema('file:///schema2.kson', '{ "from": "second" }'); + mock2.addSchema('file:///test.kson', schema2); + + const provider = new CompositeSchemaProvider([mock1, mock2], logger); + const result = provider.getSchemaForDocument('file:///test.kson'); + + assert.ok(result); + assert.strictEqual(result!.getText(), '{ "from": "first" }'); + }); + + it('should try next provider when first returns undefined', () => { + const mock1 = new MockSchemaProvider(); + const mock2 = new MockSchemaProvider(); + + // mock1 has no schema for test.kson + const schema2 = createSchema('file:///schema2.kson', '{ "from": "second" }'); + mock2.addSchema('file:///test.kson', schema2); + + const provider = new CompositeSchemaProvider([mock1, mock2], logger); + const result = provider.getSchemaForDocument('file:///test.kson'); + + assert.ok(result); + assert.strictEqual(result!.getText(), '{ "from": "second" }'); + }); + + it('should pass languageId to providers', () => { + const mock1 = new MockSchemaProvider(); + const mock2 = new MockSchemaProvider(); + + const schema = createSchema('bundled://schema/test-lang', '{ "type": "object" }'); + mock2.addSchemaByLanguageId('test-lang', schema); + + const provider = new CompositeSchemaProvider([mock1, mock2], logger); + const result = provider.getSchemaForDocument('file:///test.kson', 'test-lang'); + + assert.ok(result); + assert.strictEqual(result!.uri, 'bundled://schema/test-lang'); + }); + + it('should prioritize file system provider over bundled (typical usage)', () => { + // Simulate file system provider (has schema by URI) + const fileSystemProvider = new MockSchemaProvider(); + const fsSchema = createSchema('file:///workspace/schema.kson', '{ "from": "filesystem" }'); + fileSystemProvider.addSchema('file:///test.kson', fsSchema); + + // Simulate bundled provider (has schema by languageId) + const bundledProvider = new MockSchemaProvider(); + const bundledSchema = createSchema('bundled://schema/kson', '{ "from": "bundled" }'); + bundledProvider.addSchemaByLanguageId('kson', bundledSchema); + + // File system first (higher priority) + const provider = new CompositeSchemaProvider([fileSystemProvider, bundledProvider], logger); + const result = provider.getSchemaForDocument('file:///test.kson', 'kson'); + + assert.ok(result); + assert.strictEqual(result!.getText(), '{ "from": "filesystem" }'); + }); + + it('should fall back to bundled when file system has no schema', () => { + // File system provider with no schema + const fileSystemProvider = new MockSchemaProvider(); + + // Bundled provider has schema + const bundledProvider = new MockSchemaProvider(); + const bundledSchema = createSchema('bundled://schema/kson', '{ "from": "bundled" }'); + bundledProvider.addSchemaByLanguageId('kson', bundledSchema); + + const provider = new CompositeSchemaProvider([fileSystemProvider, bundledProvider], logger); + const result = provider.getSchemaForDocument('file:///test.kson', 'kson'); + + assert.ok(result); + assert.strictEqual(result!.getText(), '{ "from": "bundled" }'); + }); + }); + + describe('isSchemaFile', () => { + it('should return false when no providers', () => { + const provider = new CompositeSchemaProvider([], logger); + assert.strictEqual(provider.isSchemaFile('file:///schema.kson'), false); + }); + + it('should return true when any provider considers it a schema file', () => { + const mock1 = new MockSchemaProvider(); + const mock2 = new MockSchemaProvider(); + + mock2.markAsSchemaFile('file:///schema.kson'); + + const provider = new CompositeSchemaProvider([mock1, mock2], logger); + assert.strictEqual(provider.isSchemaFile('file:///schema.kson'), true); + }); + + it('should return false when no provider considers it a schema file', () => { + const mock1 = new MockSchemaProvider(); + const mock2 = new MockSchemaProvider(); + + const provider = new CompositeSchemaProvider([mock1, mock2], logger); + assert.strictEqual(provider.isSchemaFile('file:///random.kson'), false); + }); + + it('should check all provider types', () => { + const mock1 = new MockSchemaProvider(); + mock1.markAsSchemaFile('file:///schema1.kson'); + + const mock2 = new MockSchemaProvider(); + mock2.markAsSchemaFile('bundled://schema/lang'); + + const provider = new CompositeSchemaProvider([mock1, mock2], logger); + + assert.strictEqual(provider.isSchemaFile('file:///schema1.kson'), true); + assert.strictEqual(provider.isSchemaFile('bundled://schema/lang'), true); + assert.strictEqual(provider.isSchemaFile('file:///other.kson'), false); + }); + }); + + describe('reload', () => { + it('should reload all providers', () => { + let reloadCount = 0; + + class CountingProvider implements SchemaProvider { + getSchemaForDocument(): TextDocument | undefined { return undefined; } + reload(): void { reloadCount++; } + isSchemaFile(): boolean { return false; } + } + + const provider = new CompositeSchemaProvider([ + new CountingProvider(), + new CountingProvider(), + new CountingProvider() + ], logger); + + provider.reload(); + + assert.strictEqual(reloadCount, 3); + }); + }); + + describe('getProviders', () => { + it('should return readonly array of providers', () => { + const mock1 = new MockSchemaProvider(); + const mock2 = new MockSchemaProvider(); + const provider = new CompositeSchemaProvider([mock1, mock2], logger); + + const providers = provider.getProviders(); + + assert.strictEqual(providers.length, 2); + assert.strictEqual(providers[0], mock1); + assert.strictEqual(providers[1], mock2); + }); + }); + + describe('integration with NoOpSchemaProvider', () => { + it('should work with NoOpSchemaProvider as fallback', () => { + const mock = new MockSchemaProvider(); + const noOp = new NoOpSchemaProvider(); + + const provider = new CompositeSchemaProvider([mock, noOp], logger); + + // Should return undefined when mock has no schema + const schema = provider.getSchemaForDocument('file:///test.kson'); + assert.strictEqual(schema, undefined); + }); + }); +}); diff --git a/tooling/lsp-clients/vscode/test/suite/bundled-schema.test.ts b/tooling/lsp-clients/vscode/test/suite/bundled-schema.test.ts new file mode 100644 index 00000000..f6fdec8b --- /dev/null +++ b/tooling/lsp-clients/vscode/test/suite/bundled-schema.test.ts @@ -0,0 +1,232 @@ +import * as vscode from 'vscode'; +import { assert } from './assert'; +import { createTestFile, cleanUp } from './common'; +import { v4 as uuid } from 'uuid'; + +/** + * Tests for bundled schema support. + * + * These tests verify that: + * 1. Bundled schemas are loaded from package.json configuration + * 2. Language configuration includes bundled schema mappings + * 3. The enableBundledSchemas setting is respected + */ +describe('Bundled Schema Support Tests', () => { + /** + * Get the extension and verify it's active. + */ + function getExtension(): vscode.Extension | undefined { + return vscode.extensions.getExtension('kson.kson'); + } + + describe('Configuration', () => { + it('Should have enableBundledSchemas setting defined', async function () { + const extension = getExtension(); + if (!extension) { + this.skip(); + return; + } + + const packageJson = extension.packageJSON; + const configuration = packageJson?.contributes?.configuration; + const properties = configuration?.properties; + + assert.ok(properties, 'Configuration properties should be defined'); + assert.ok( + properties['kson.enableBundledSchemas'], + 'Should have kson.enableBundledSchemas setting' + ); + assert.strictEqual( + properties['kson.enableBundledSchemas'].type, + 'boolean', + 'Setting should be boolean type' + ); + assert.strictEqual( + properties['kson.enableBundledSchemas'].default, + true, + 'Setting should default to true' + ); + }).timeout(5000); + + it('Should have bundledSchema field in language contributions', async function () { + const extension = getExtension(); + if (!extension) { + this.skip(); + return; + } + + const packageJson = extension.packageJSON; + const languages = packageJson?.contributes?.languages || []; + + assert.ok(languages.length > 0, 'Should have at least one language defined'); + + // The kson language should have bundledSchema field (even if null) + const ksonLanguage = languages.find((lang: any) => lang.id === 'kson'); + assert.ok(ksonLanguage, 'Should have kson language defined'); + assert.ok( + 'bundledSchema' in ksonLanguage, + 'kson language should have bundledSchema field' + ); + }).timeout(5000); + + it('Should be able to read enableBundledSchemas setting', async function () { + const config = vscode.workspace.getConfiguration('kson'); + const enabled = config.get('enableBundledSchemas'); + + // Should be defined and default to true + assert.strictEqual(typeof enabled, 'boolean', 'Setting should be a boolean'); + assert.strictEqual(enabled, true, 'Default value should be true'); + }).timeout(5000); + }); + + describe('Schema Loading', () => { + /** + * Detects if we're running in a Node.js environment (vs browser). + * Some tests may behave differently between environments. + */ + function isNodeEnvironment(): boolean { + return typeof process !== 'undefined' && + process.versions != null && + process.versions.node != null; + } + + it('Should create language configuration with bundledSchemas', async function () { + const extension = getExtension(); + if (!extension) { + this.skip(); + return; + } + + // Import and test the language config module + // This verifies the configuration is being parsed correctly + const packageJson = extension.packageJSON; + const languages = packageJson?.contributes?.languages || []; + + const bundledSchemas = languages + .filter((lang: any) => lang.id && lang.bundledSchema) + .map((lang: any) => ({ + languageId: lang.id, + schemaPath: lang.bundledSchema + })); + + // Log what was found + console.log(`Found ${bundledSchemas.length} bundled schema configurations`); + if (bundledSchemas.length > 0) { + console.log('Bundled schemas:', JSON.stringify(bundledSchemas, null, 2)); + } + + // This is a structural test - we're verifying the config format is correct + assert.ok(Array.isArray(bundledSchemas), 'bundledSchemas should be an array'); + }).timeout(5000); + + it('Should handle language with no bundled schema', async function () { + const extension = getExtension(); + if (!extension) { + this.skip(); + return; + } + + // Create a test file with standard .kson extension + const content = 'key: "value"'; + const [testFileUri, document] = await createTestFile(content); + + try { + // Document should be created successfully + assert.ok(document, 'Document should be created'); + assert.strictEqual(document.languageId, 'kson', 'Should have kson language ID'); + + // Wait a bit for the language server to process + await new Promise(resolve => setTimeout(resolve, 500)); + + // No errors should occur - this is a basic sanity check + const diagnostics = vscode.languages.getDiagnostics(document.uri); + // Should have 0 diagnostics for valid KSON + assert.strictEqual(diagnostics.length, 0, 'Valid KSON should have no diagnostics'); + } finally { + await cleanUp(testFileUri); + } + }).timeout(10000); + }); + + describe('Status Bar Integration', () => { + it('Should show status bar for KSON files', async function () { + // Skip in browser environment as status bar may behave differently + const extension = getExtension(); + if (!extension) { + this.skip(); + return; + } + + const content = 'key: "value"'; + const [testFileUri, document] = await createTestFile(content); + + try { + // Make sure the document is shown + await vscode.window.showTextDocument(document); + + // Wait for status bar to update + await new Promise(resolve => setTimeout(resolve, 500)); + + // We can't directly test the status bar content from tests, + // but we verify the document is properly set up + assert.ok(vscode.window.activeTextEditor, 'Should have active editor'); + assert.strictEqual( + vscode.window.activeTextEditor?.document.uri.toString(), + testFileUri.toString(), + 'Active editor should show test file' + ); + } finally { + await cleanUp(testFileUri); + } + }).timeout(10000); + }); + + describe('Bundled Schema Loader', () => { + it('Should export correct types from bundledSchemaLoader', async function () { + // This test verifies the module structure is correct + // We can't import directly in tests, so we verify through package.json + const extension = getExtension(); + if (!extension) { + this.skip(); + return; + } + + // Verify the language configuration is properly structured + const packageJson = extension.packageJSON; + const languages = packageJson?.contributes?.languages || []; + + for (const lang of languages) { + if (lang.bundledSchema) { + // If bundledSchema is defined, it should be a string path + assert.strictEqual( + typeof lang.bundledSchema, + 'string', + `bundledSchema for ${lang.id} should be a string path` + ); + assert.ok( + lang.bundledSchema.includes('/') || lang.bundledSchema.includes('\\'), + `bundledSchema path for ${lang.id} should be a path` + ); + } + } + }).timeout(5000); + }); + + describe('Settings Changes', () => { + it('Should have correct setting scope', async function () { + const extension = getExtension(); + if (!extension) { + this.skip(); + return; + } + + const packageJson = extension.packageJSON; + const properties = packageJson?.contributes?.configuration?.properties; + const setting = properties?.['kson.enableBundledSchemas']; + + assert.ok(setting, 'Setting should exist'); + assert.ok(setting.description, 'Setting should have a description'); + assert.strictEqual(setting.type, 'boolean', 'Setting should be boolean'); + }).timeout(5000); + }); +}); diff --git a/tooling/lsp-clients/vscode/test/suite/language-config.test.ts b/tooling/lsp-clients/vscode/test/suite/language-config.test.ts index 9a966ea0..3aa31afd 100644 --- a/tooling/lsp-clients/vscode/test/suite/language-config.test.ts +++ b/tooling/lsp-clients/vscode/test/suite/language-config.test.ts @@ -69,4 +69,52 @@ describe('Language Configuration Tests', () => { assert.strictEqual(isKsonDialect('python'), false); }); }); + + describe('bundledSchemas', () => { + it('Should extract bundled schema mappings', () => { + initWithLanguages([ + { id: 'kson', extensions: ['.kson'], bundledSchema: null }, + { id: 'kxt', extensions: ['.kxt'], bundledSchema: './dist/extension/schemas/kxt.schema.kson' } + ]); + const config = getLanguageConfiguration(); + + assert.ok(config.bundledSchemas, 'bundledSchemas should be defined'); + assert.strictEqual(config.bundledSchemas.length, 1, 'Should have 1 bundled schema'); + assert.strictEqual(config.bundledSchemas[0].languageId, 'kxt'); + assert.strictEqual(config.bundledSchemas[0].schemaPath, './dist/extension/schemas/kxt.schema.kson'); + }); + + it('Should handle no bundled schemas', () => { + initWithLanguages([ + { id: 'kson', extensions: ['.kson'], bundledSchema: null } + ]); + const config = getLanguageConfiguration(); + + assert.ok(config.bundledSchemas, 'bundledSchemas should be defined'); + assert.strictEqual(config.bundledSchemas.length, 0, 'Should have 0 bundled schemas'); + }); + + it('Should handle missing bundledSchema field', () => { + initWithLanguages([ + { id: 'kson', extensions: ['.kson'] } + ]); + const config = getLanguageConfiguration(); + + assert.ok(config.bundledSchemas, 'bundledSchemas should be defined'); + assert.strictEqual(config.bundledSchemas.length, 0, 'Should have 0 bundled schemas'); + }); + + it('Should handle multiple bundled schemas', () => { + initWithLanguages([ + { id: 'kson', extensions: ['.kson'], bundledSchema: null }, + { id: 'kxt', extensions: ['.kxt'], bundledSchema: './schemas/kxt.schema.kson' }, + { id: 'config', extensions: ['.config'], bundledSchema: './schemas/config.schema.kson' } + ]); + const config = getLanguageConfiguration(); + + assert.strictEqual(config.bundledSchemas.length, 2, 'Should have 2 bundled schemas'); + assert.ok(config.bundledSchemas.some(s => s.languageId === 'kxt')); + assert.ok(config.bundledSchemas.some(s => s.languageId === 'config')); + }); + }); }); From 411adfb6b94e539bffbc0a1bc053e688f21d8151 Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Tue, 3 Feb 2026 01:27:32 +0100 Subject: [PATCH 02/18] feat: add VSCode client support for bundled schemas - Add bundledSchemaLoader utility to load schemas from extension - Update languageConfig to extract bundledSchema from package.json - Update clientOptions to pass bundled schemas via initializationOptions - Update both node and browser clients to load and pass bundled schemas - Update StatusBarManager to show (bundled) indicator for bundled schemas - Add enableBundledSchemas setting to package.json - Update esbuild to copy schemas directory to dist --- tooling/lsp-clients/vscode/esbuild.ts | 8 ++ tooling/lsp-clients/vscode/package.json | 8 +- tooling/lsp-clients/vscode/schemas/.gitkeep | 10 +++ .../src/client/browser/ksonClientMain.ts | 21 +++++- .../src/client/common/StatusBarManager.ts | 31 +++++++- .../vscode/src/client/node/ksonClientMain.ts | 14 +++- .../vscode/src/config/bundledSchemaLoader.ts | 73 +++++++++++++++++++ .../vscode/src/config/clientOptions.ts | 23 +++++- .../vscode/src/config/languageConfig.ts | 24 +++++- 9 files changed, 200 insertions(+), 12 deletions(-) create mode 100644 tooling/lsp-clients/vscode/schemas/.gitkeep create mode 100644 tooling/lsp-clients/vscode/src/config/bundledSchemaLoader.ts diff --git a/tooling/lsp-clients/vscode/esbuild.ts b/tooling/lsp-clients/vscode/esbuild.ts index 915a89ae..59c69cde 100644 --- a/tooling/lsp-clients/vscode/esbuild.ts +++ b/tooling/lsp-clients/vscode/esbuild.ts @@ -50,6 +50,14 @@ async function build() { join(__dirname, 'dist', 'extension'), {recursive: true}); + // Copy bundled schemas if they exist + const schemasDir = join(__dirname, 'schemas'); + const distSchemasDir = join(__dirname, 'dist', 'extension', 'schemas'); + if (require('fs').existsSync(schemasDir)) { + cpSync(schemasDir, distSchemasDir, {recursive: true}); + console.log('📄 Copied bundled schemas to dist/extension/schemas'); + } + const testEntries = await getTestEntries(); // Common build options diff --git a/tooling/lsp-clients/vscode/package.json b/tooling/lsp-clients/vscode/package.json index c1589101..60f90c92 100644 --- a/tooling/lsp-clients/vscode/package.json +++ b/tooling/lsp-clients/vscode/package.json @@ -39,7 +39,8 @@ "light": "./dist/extension/assets/kson-icon.png", "dark": "./dist/extension/assets/kson-icon.png" }, - "configuration": "./dist/extension/config/language-configuration.json" + "configuration": "./dist/extension/config/language-configuration.json", + "bundledSchema": null } ], "grammars": [ @@ -71,6 +72,11 @@ "configuration": { "title": "Kson", "properties": { + "kson.enableBundledSchemas": { + "type": "boolean", + "default": true, + "description": "Enable bundled schemas for configured KSON dialects. When enabled, KSON dialects with bundled schemas will automatically provide hover, completions, and validation." + }, "kson.format.tabSize": { "type": "number", "default": 2, diff --git a/tooling/lsp-clients/vscode/schemas/.gitkeep b/tooling/lsp-clients/vscode/schemas/.gitkeep new file mode 100644 index 00000000..3f0551a3 --- /dev/null +++ b/tooling/lsp-clients/vscode/schemas/.gitkeep @@ -0,0 +1,10 @@ +# This directory contains bundled schema files for KSON dialects. +# To add a bundled schema for a language: +# 1. Add the schema file here (e.g., my-dialect.schema.kson) +# 2. Update package.json to add bundledSchema field to the language contribution: +# { +# "id": "my-dialect", +# "extensions": [".md"], +# "bundledSchema": "./dist/extension/schemas/my-dialect.schema.kson" +# } +# 3. The schema will be automatically bundled and loaded for that language ID. diff --git a/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts b/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts index e5157bb6..1abc4ca0 100644 --- a/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts +++ b/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { LanguageClient } from 'vscode-languageclient/browser'; import { createClientOptions } from '../../config/clientOptions'; import { initializeLanguageConfig } from '../../config/languageConfig'; +import { loadBundledSchemas, areBundledSchemasEnabled } from '../../config/bundledSchemaLoader'; import {deactivate} from '../common/deactivate'; /** @@ -21,8 +22,18 @@ export async function activate(context: vscode.ExtensionContext) { const serverModule = vscode.Uri.joinPath(context.extensionUri, 'dist', 'browserServer.js'); const worker = new Worker(serverModule.toString(true)); - // Create the language client options - const clientOptions = createClientOptions(logOutputChannel); + // Load bundled schemas (works in browser via vscode.workspace.fs) + const bundledSchemas = await loadBundledSchemas(context.extensionUri, { + info: (msg) => logOutputChannel.info(msg), + warn: (msg) => logOutputChannel.warn(msg), + error: (msg) => logOutputChannel.error(msg) + }); + + // Create the language client options with bundled schemas + const clientOptions = createClientOptions(logOutputChannel, { + bundledSchemas, + enableBundledSchemas: areBundledSchemasEnabled() + }); // In test environments, we need to support the vscode-test-web scheme // This is only needed for the test runner, not in production @@ -49,7 +60,11 @@ export async function activate(context: vscode.ExtensionContext) { await languageClient.start(); logOutputChannel.info('KSON Browser extension activated successfully'); - logOutputChannel.info('Note: Schema features are not available in browser environment'); + if (bundledSchemas.length > 0) { + logOutputChannel.info(`Loaded ${bundledSchemas.length} bundled schemas for browser environment`); + } else { + logOutputChannel.info('Note: No bundled schemas configured. User-defined schemas require file system access.'); + } console.log('KSON Language Server (Browser) started'); } catch (error) { diff --git a/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts b/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts index b004ee3e..515cc9e7 100644 --- a/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts +++ b/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import {BaseLanguageClient} from 'vscode-languageclient'; import * as path from 'path'; +import { isKsonDialect } from '../../config/languageConfig'; /** * Response from the LSP server for schema information @@ -9,6 +10,8 @@ interface SchemaInfo { schemaUri?: string; schemaPath?: string; hasSchema: boolean; + /** Whether the schema is bundled with the extension */ + isBundled: boolean; } /** @@ -33,7 +36,7 @@ export class StatusBarManager { * Queries the LSP server for schema information and updates the display. */ async updateForDocument(document: vscode.TextDocument): Promise { - if (document.languageId !== 'kson') { + if (!isKsonDialect(document.languageId)) { this.hide(); return; } @@ -48,9 +51,18 @@ export class StatusBarManager { if (schemaInfo.hasSchema && schemaInfo.schemaPath) { // Extract just the filename for display - const schemaFileName = path.basename(schemaInfo.schemaPath); - this.statusBarItem.text = `$(file-code) Schema: ${schemaFileName}`; - this.statusBarItem.tooltip = `Schema: ${schemaInfo.schemaPath}\nClick to change schema`; + const schemaFileName = schemaInfo.isBundled + ? this.extractBundledSchemaName(schemaInfo.schemaPath) + : path.basename(schemaInfo.schemaPath); + + // Show bundled indicator + const bundledSuffix = schemaInfo.isBundled ? ' (bundled)' : ''; + const icon = schemaInfo.isBundled ? '$(package)' : '$(file-code)'; + + this.statusBarItem.text = `${icon} Schema: ${schemaFileName}${bundledSuffix}`; + this.statusBarItem.tooltip = schemaInfo.isBundled + ? `Bundled schema for this language\nClick to override with custom schema` + : `Schema: ${schemaInfo.schemaPath}\nClick to change schema`; } else { this.statusBarItem.text = '$(warning) No Schema'; this.statusBarItem.tooltip = 'No schema associated. Click to select a schema'; @@ -79,6 +91,17 @@ export class StatusBarManager { this.statusBarItem.hide(); } + /** + * Extract a display name from a bundled schema URI. + * Bundled schema URIs have the format: bundled://schema/{languageId} + */ + private extractBundledSchemaName(schemaPath: string): string { + if (schemaPath.startsWith('bundled://schema/')) { + return schemaPath.replace('bundled://schema/', ''); + } + return path.basename(schemaPath); + } + /** * Clean up resources */ diff --git a/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts b/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts index c37b320c..695b1c06 100644 --- a/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts +++ b/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts @@ -12,6 +12,7 @@ import { } from '../../config/clientOptions'; import {StatusBarManager} from '../common/StatusBarManager'; import { isKsonDialect, initializeLanguageConfig } from '../../config/languageConfig'; +import { loadBundledSchemas, areBundledSchemasEnabled } from '../../config/bundledSchemaLoader'; /** * Node.js-specific activation function for the KSON extension. @@ -39,7 +40,18 @@ export async function activate(context: vscode.ExtensionContext) { run: {module: serverModule, transport: TransportKind.ipc}, debug: {module: serverModule, transport: TransportKind.ipc, options: debugOptions} }; - const clientOptions: LanguageClientOptions = createClientOptions(logOutputChannel) + + // Load bundled schemas + const bundledSchemas = await loadBundledSchemas(context.extensionUri, { + info: (msg) => logOutputChannel.info(msg), + warn: (msg) => logOutputChannel.warn(msg), + error: (msg) => logOutputChannel.error(msg) + }); + + const clientOptions: LanguageClientOptions = createClientOptions(logOutputChannel, { + bundledSchemas, + enableBundledSchemas: areBundledSchemasEnabled() + }); let languageClient = new LanguageClient("kson", serverOptions, clientOptions, false) await languageClient.start(); diff --git a/tooling/lsp-clients/vscode/src/config/bundledSchemaLoader.ts b/tooling/lsp-clients/vscode/src/config/bundledSchemaLoader.ts new file mode 100644 index 00000000..1b6b01fa --- /dev/null +++ b/tooling/lsp-clients/vscode/src/config/bundledSchemaLoader.ts @@ -0,0 +1,73 @@ +import * as vscode from 'vscode'; +import { getLanguageConfiguration, BundledSchemaMapping } from './languageConfig'; + +/** + * Configuration for a bundled schema to be passed to the LSP server. + */ +export interface BundledSchemaConfig { + /** The language ID this schema applies to */ + languageId: string; + /** The pre-loaded schema content as a string */ + schemaContent: string; +} + +/** + * Load all bundled schemas defined in the language configuration. + * Uses vscode.workspace.fs for cross-platform file access (works in both browser and Node.js). + * + * @param extensionUri The URI of the extension root + * @param logger Optional logger for debugging + * @returns Array of loaded bundled schema configurations + */ +export async function loadBundledSchemas( + extensionUri: vscode.Uri, + logger?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void } +): Promise { + const { bundledSchemas } = getLanguageConfiguration(); + const loadedSchemas: BundledSchemaConfig[] = []; + + for (const mapping of bundledSchemas) { + try { + const schemaContent = await loadSchemaFile(extensionUri, mapping, logger); + if (schemaContent) { + loadedSchemas.push({ + languageId: mapping.languageId, + schemaContent + }); + } + } catch (error) { + logger?.warn(`Failed to load bundled schema for ${mapping.languageId}: ${error}`); + } + } + + logger?.info(`Loaded ${loadedSchemas.length} bundled schemas`); + return loadedSchemas; +} + +/** + * Load a single schema file from the extension. + */ +async function loadSchemaFile( + extensionUri: vscode.Uri, + mapping: BundledSchemaMapping, + logger?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void } +): Promise { + try { + const schemaUri = vscode.Uri.joinPath(extensionUri, mapping.schemaPath); + const schemaBytes = await vscode.workspace.fs.readFile(schemaUri); + const schemaContent = new TextDecoder().decode(schemaBytes); + logger?.info(`Loaded bundled schema for ${mapping.languageId} from ${mapping.schemaPath}`); + return schemaContent; + } catch (error) { + logger?.warn(`Schema file not found for ${mapping.languageId}: ${mapping.schemaPath}`); + return undefined; + } +} + +/** + * Check if bundled schemas are enabled via VS Code settings. + */ +export function areBundledSchemasEnabled(): boolean { + const config = vscode.workspace.getConfiguration('kson'); + return config.get('enableBundledSchemas', true); +} diff --git a/tooling/lsp-clients/vscode/src/config/clientOptions.ts b/tooling/lsp-clients/vscode/src/config/clientOptions.ts index a9098652..19000f15 100644 --- a/tooling/lsp-clients/vscode/src/config/clientOptions.ts +++ b/tooling/lsp-clients/vscode/src/config/clientOptions.ts @@ -6,9 +6,27 @@ import { DocumentSelector, } from 'vscode-languageclient'; import { getLanguageConfiguration } from './languageConfig'; +import { BundledSchemaConfig } from './bundledSchemaLoader'; -// Create shared client options -export const createClientOptions = (outputChannel: vscode.OutputChannel): LanguageClientOptions => { +/** + * Initialization options passed to the LSP server. + */ +export interface KsonInitializationOptions { + /** Bundled schemas to be loaded */ + bundledSchemas: BundledSchemaConfig[]; + /** Whether bundled schemas are enabled */ + enableBundledSchemas: boolean; +} + +/** + * Create shared client options. + * @param outputChannel The output channel for logging + * @param initializationOptions Optional initialization options including bundled schemas + */ +export const createClientOptions = ( + outputChannel: vscode.OutputChannel, + initializationOptions?: KsonInitializationOptions +): LanguageClientOptions => { const { languageIds, fileExtensions } = getLanguageConfiguration(); // Build document selector for all supported language IDs @@ -24,6 +42,7 @@ export const createClientOptions = (outputChannel: vscode.OutputChannel): Langua return { documentSelector, + initializationOptions, synchronize: { /** * TODO - Even though this setting is deprecated it is the easiest way to get configuration going. diff --git a/tooling/lsp-clients/vscode/src/config/languageConfig.ts b/tooling/lsp-clients/vscode/src/config/languageConfig.ts index 78c06bfc..64699dfe 100644 --- a/tooling/lsp-clients/vscode/src/config/languageConfig.ts +++ b/tooling/lsp-clients/vscode/src/config/languageConfig.ts @@ -1,6 +1,18 @@ +/** + * Configuration for bundled schemas mapped by language ID. + */ +export interface BundledSchemaMapping { + /** Language ID this schema applies to */ + languageId: string; + /** Relative path to the bundled schema file (from extension root) */ + schemaPath: string; +} + export interface LanguageConfiguration { languageIds: string[]; fileExtensions: string[]; + /** Bundled schema mappings extracted from package.json */ + bundledSchemas: BundledSchemaMapping[]; } let cachedConfig: LanguageConfiguration | null = null; @@ -28,12 +40,22 @@ export function isKsonDialect(languageId: string): boolean { */ export function initializeLanguageConfig(packageJson: any): void { const languages = packageJson?.contributes?.languages || []; + + // Extract bundled schema mappings + const bundledSchemas: BundledSchemaMapping[] = languages + .filter((lang: any) => lang.id && lang.bundledSchema) + .map((lang: any) => ({ + languageId: lang.id, + schemaPath: lang.bundledSchema + })); + cachedConfig = { languageIds: languages.map((lang: any) => lang.id).filter(Boolean), fileExtensions: languages .flatMap((lang: any) => lang.extensions || []) .filter(Boolean) - .map((ext: string) => ext.replace(/^\./, '')) + .map((ext: string) => ext.replace(/^\./, '')), + bundledSchemas }; } From 5321e3244b64f5466391ca0ded53012bfb35c415 Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Tue, 3 Feb 2026 15:59:23 +0100 Subject: [PATCH 03/18] fix bundled schema navigation with TextDocumentContentProvider VS Code failed to navigate to bundled schemas with error: "Unable to resolve resource bundled://schema/xxx" The LSP server creates schema documents with bundled:// URIs for schemas shipped with the extension. VS Code doesn't natively understand this scheme, so "Go to Definition" failed when pointing to these URIs. Fix: Register a TextDocumentContentProvider for the bundled:// scheme that maps bundled://schema/{languageId} URIs to the pre-loaded schema content. The bundled:// approach was chosen over file:// URIs because: - Browser environments may not have access to extension files via file:// - The server doesn't need to know the extension's installation path - Schema content is loaded once at startup and kept in memory Added integration tests for bundled schema navigation by bundling the json draft 7 metaschema. --- .../src/core/document/KsonDocumentsManager.ts | 60 ++++- .../src/core/features/DefinitionService.ts | 24 +- .../src/core/schema/BundledSchemaProvider.ts | 141 ++++++++-- .../core/schema/CompositeSchemaProvider.ts | 17 ++ .../core/schema/FileSystemSchemaProvider.ts | 4 + .../src/core/schema/SchemaProvider.ts | 13 + tooling/language-server-protocol/src/index.ts | 2 + .../src/startKsonServer.ts | 13 +- .../core/features/DefinitionService.test.ts | 91 ++++--- .../core/schema/BundledSchemaProvider.test.ts | 214 +++++++++++---- .../schema/CompositeSchemaProvider.test.ts | 250 +++++++++++------- .../vscode/schemas/metaschema.draft7.kson | 225 ++++++++++++++++ .../src/client/browser/ksonClientMain.ts | 23 +- .../common/BundledSchemaContentProvider.ts | 72 +++++ .../vscode/src/client/node/ksonClientMain.ts | 23 +- .../vscode/src/config/bundledSchemaLoader.ts | 74 +++++- .../vscode/src/config/clientOptions.ts | 9 +- .../vscode/test/suite/bundled-schema.test.ts | 236 ++++++++++------- .../status-bar-schema-association.test.ts | 1 - 19 files changed, 1139 insertions(+), 353 deletions(-) create mode 100644 tooling/language-server-protocol/src/index.ts create mode 100644 tooling/lsp-clients/vscode/schemas/metaschema.draft7.kson create mode 100644 tooling/lsp-clients/vscode/src/client/common/BundledSchemaContentProvider.ts diff --git a/tooling/language-server-protocol/src/core/document/KsonDocumentsManager.ts b/tooling/language-server-protocol/src/core/document/KsonDocumentsManager.ts index 72fd534b..97a3c8b7 100644 --- a/tooling/language-server-protocol/src/core/document/KsonDocumentsManager.ts +++ b/tooling/language-server-protocol/src/core/document/KsonDocumentsManager.ts @@ -1,9 +1,28 @@ import {TextDocument} from 'vscode-languageserver-textdocument'; -import {Kson} from 'kson'; +import {Analysis, Kson, KsonValue, KsonValueType} from 'kson'; import {KsonDocument} from "./KsonDocument.js"; import {DocumentUri, TextDocuments, TextDocumentContentChangeEvent} from "vscode-languageserver"; import {SchemaProvider, NoOpSchemaProvider} from "../schema/SchemaProvider.js"; +/** + * Extract the $schema field value from a parsed KSON analysis result. + * + * @param analysis The KSON analysis result + * @returns The $schema string value, or undefined if not present or not a string + */ +function extractSchemaId(analysis: Analysis): string | undefined { + const ksonValue = analysis.ksonValue; + if (!ksonValue || ksonValue.type !== KsonValueType.OBJECT) { + return undefined; + } + const obj = ksonValue as KsonValue.KsonObject; + const schemaValue = obj.properties.asJsReadonlyMapView().get('$schema'); + if (!schemaValue || schemaValue.type !== KsonValueType.STRING) { + return undefined; + } + return (schemaValue as KsonValue.KsonString).value; +} + /** * Document management for the Kson Language Server. * The {@link KsonDocumentsManager} keeps track of all {@link KsonDocument}'s that @@ -26,10 +45,19 @@ export class KsonDocumentsManager extends TextDocuments { ): KsonDocument => { const textDocument = TextDocument.create(uri, languageId, version, content); - // Try to get schema from provider, passing languageId for bundled schema support - let schemaDocument = provider.getSchemaForDocument(uri, languageId); + // Try to get schema from provider based on file extension/URI + let schemaDocument = provider.getSchemaForDocument(uri); const parseResult = Kson.getInstance().analyze(content, uri); + + // If no schema from URI-based resolution, try content-based metaschema resolution + if (!schemaDocument) { + const schemaId = extractSchemaId(parseResult); + if (schemaId) { + schemaDocument = provider.getMetaSchemaForId(schemaId); + } + } + return new KsonDocument(textDocument, parseResult, schemaDocument); }, update: ( @@ -43,12 +71,20 @@ export class KsonDocumentsManager extends TextDocuments { version ); const parseResult = Kson.getInstance().analyze(textDocument.getText(), ksonDocument.uri); - // Pass languageId for bundled schema support - const languageId = textDocument.languageId; + + // Try URI-based resolution first, then content-based metaschema resolution + let schemaDocument = provider.getSchemaForDocument(ksonDocument.uri); + if (!schemaDocument) { + const schemaId = extractSchemaId(parseResult); + if (schemaId) { + schemaDocument = provider.getMetaSchemaForId(schemaId); + } + } + return new KsonDocument( textDocument, parseResult, - provider.getSchemaForDocument(ksonDocument.uri, languageId) + schemaDocument ); } }); @@ -85,9 +121,15 @@ export class KsonDocumentsManager extends TextDocuments { for (const doc of allDocs) { const textDocument = doc.textDocument; const parseResult = doc.getAnalysisResult(); - // Pass languageId for bundled schema support - const languageId = textDocument.languageId; - const updatedSchema = this.schemaProvider.getSchemaForDocument(doc.uri, languageId); + + // Try URI-based resolution first, then content-based metaschema resolution + let updatedSchema = this.schemaProvider.getSchemaForDocument(doc.uri); + if (!updatedSchema) { + const schemaId = extractSchemaId(parseResult); + if (schemaId) { + updatedSchema = this.schemaProvider.getMetaSchemaForId(schemaId); + } + } // Create new document instance with updated schema const updatedDoc = new KsonDocument(textDocument, parseResult, updatedSchema); diff --git a/tooling/language-server-protocol/src/core/features/DefinitionService.ts b/tooling/language-server-protocol/src/core/features/DefinitionService.ts index 251a0b1e..1bfacca9 100644 --- a/tooling/language-server-protocol/src/core/features/DefinitionService.ts +++ b/tooling/language-server-protocol/src/core/features/DefinitionService.ts @@ -19,11 +19,18 @@ export class DefinitionService { */ getDefinition(document: KsonDocument, position: Position): DefinitionLink[] { const tooling = KsonTooling.getInstance(); + const results: DefinitionLink[] = []; - // Get the schema for this document - const schemaDocument = document.getSchemaDocument(); + // Try $ref resolution within the same document + const refLocations = tooling.resolveRefAtLocation( + document.getText(), + position.line, + position.character + ); + results.push(...this.convertRangesToDefinitionLinks(refLocations, document.uri)); - // Try document-to-schema navigation first (if schema is configured) + // Try document-to-schema navigation (if schema is configured) + const schemaDocument = document.getSchemaDocument(); if (schemaDocument) { const locations = tooling.getSchemaLocationAtLocation( document.getText(), @@ -31,17 +38,10 @@ export class DefinitionService { position.line, position.character ); - return this.convertRangesToDefinitionLinks(locations, schemaDocument.uri); + results.push(...this.convertRangesToDefinitionLinks(locations, schemaDocument.uri)); } - // Try schema $ref resolution (within the same document) - // This handles the case where we're editing a schema file and want to jump to internal refs - const refLocations = tooling.resolveRefAtLocation( - document.getText(), - position.line, - position.character - ); - return this.convertRangesToDefinitionLinks(refLocations, document.uri); + return results; } /** diff --git a/tooling/language-server-protocol/src/core/schema/BundledSchemaProvider.ts b/tooling/language-server-protocol/src/core/schema/BundledSchemaProvider.ts index 14fec60b..2c9876fe 100644 --- a/tooling/language-server-protocol/src/core/schema/BundledSchemaProvider.ts +++ b/tooling/language-server-protocol/src/core/schema/BundledSchemaProvider.ts @@ -6,8 +6,21 @@ import {SchemaProvider} from './SchemaProvider.js'; * Configuration for a bundled schema. */ export interface BundledSchemaConfig { - /** The language ID this schema applies to (e.g., 'kxt', 'kson-config') */ - languageId: string; + /** The file extension this schema applies to (e.g., 'schema.kson', 'kxt') */ + fileExtension: string; + /** The pre-loaded schema content as a string */ + schemaContent: string; +} + +/** + * Configuration for a bundled metaschema. + * Metaschemas are matched by the document's $schema field value rather than file extension. + */ +export interface BundledMetaSchemaConfig { + /** The $id to match against a document's $schema field (e.g., "http://json-schema.org/draft-07/schema#") */ + schemaId: string; + /** Name for URI generation (e.g., "draft-07") */ + name: string; /** The pre-loaded schema content as a string */ schemaContent: string; } @@ -16,19 +29,27 @@ export interface BundledSchemaConfig { * Schema provider for bundled schemas that are shipped with the extension. * Works in both browser and Node.js environments since it doesn't require file system access. * - * Bundled schemas are identified by language ID and use a special URI scheme: - * bundled://schema/{languageId} + * Bundled schemas are identified by file extension and use a special URI scheme: + * bundled://schema/{fileExtension}.schema.kson + * + * Bundled metaschemas are identified by their $id and use: + * bundled://metaschema/{name}.schema.kson + * + * The .schema.kson suffix allows VS Code to recognize the file as KSON and apply + * syntax highlighting and other language features when navigating to definitions. */ export class BundledSchemaProvider implements SchemaProvider { private schemas: Map; + private metaSchemas: Map; private enabled: boolean; /** * Creates a new BundledSchemaProvider. * - * @param schemas Array of bundled schema configurations + * @param schemas Array of bundled schema configurations (matched by file extension) * @param enabled Whether bundled schemas are enabled (default: true) * @param logger Optional logger for warnings and errors + * @param metaSchemas Array of bundled metaschema configurations (matched by $schema content) */ constructor( schemas: BundledSchemaConfig[], @@ -37,44 +58,113 @@ export class BundledSchemaProvider implements SchemaProvider { info: (message: string) => void; warn: (message: string) => void; error: (message: string) => void; - } + }, + metaSchemas: BundledMetaSchemaConfig[] = [] ) { this.enabled = enabled; this.schemas = new Map(); + this.metaSchemas = new Map(); // Create TextDocuments from the provided schema content for (const config of schemas) { try { - const schemaUri = `bundled://schema/${config.languageId}`; + // Include .schema.kson suffix so VS Code recognizes it as KSON for syntax highlighting + const schemaUri = `bundled://schema/${config.fileExtension}.schema.kson`; const schemaDocument = TextDocument.create( schemaUri, 'kson', 1, config.schemaContent ); - this.schemas.set(config.languageId, schemaDocument); - this.logger?.info(`Loaded bundled schema for language: ${config.languageId}`); + this.schemas.set(config.fileExtension, schemaDocument); + this.logger?.info(`Loaded bundled schema for extension: ${config.fileExtension}`); } catch (error) { - this.logger?.error(`Failed to load bundled schema for ${config.languageId}: ${error}`); + this.logger?.error(`Failed to load bundled schema for ${config.fileExtension}: ${error}`); } } - this.logger?.info(`BundledSchemaProvider initialized with ${this.schemas.size} schemas, enabled: ${enabled}`); + // Create TextDocuments from the provided metaschema content + for (const config of metaSchemas) { + try { + const metaSchemaUri = `bundled://metaschema/${config.name}.schema.kson`; + const metaSchemaDocument = TextDocument.create( + metaSchemaUri, + 'kson', + 1, + config.schemaContent + ); + this.metaSchemas.set(config.schemaId, metaSchemaDocument); + this.logger?.info(`Loaded bundled metaschema: ${config.name} (${config.schemaId})`); + } catch (error) { + this.logger?.error(`Failed to load bundled metaschema ${config.name}: ${error}`); + } + } + + this.logger?.info(`BundledSchemaProvider initialized with ${this.schemas.size} schemas and ${this.metaSchemas.size} metaschemas, enabled: ${enabled}`); } /** - * Get the schema for a document based on its language ID. + * Get the schema for a document based on its file extension. * - * @param _documentUri The URI of the KSON document (unused by bundled provider) - * @param languageId The language ID to look up the schema for - * @returns TextDocument containing the schema, or undefined if no bundled schema exists for this language + * @param documentUri The URI of the KSON document + * @returns TextDocument containing the schema, or undefined if no bundled schema exists for this extension */ - getSchemaForDocument(_documentUri: DocumentUri, languageId?: string): TextDocument | undefined { - if (!this.enabled || !languageId) { + getSchemaForDocument(documentUri: DocumentUri): TextDocument | undefined { + if (!this.enabled) { return undefined; } - return this.schemas.get(languageId); + const matchedExtension = this.findMatchingExtension(documentUri); + if (!matchedExtension) { + return undefined; + } + + return this.schemas.get(matchedExtension); + } + + /** + * Get a bundled metaschema by its schema ID. + * Used for content-based schema resolution when a document declares $schema. + * + * @param schemaId The $id of the metaschema to look up + * @returns TextDocument containing the metaschema, or undefined if no match + */ + getMetaSchemaForId(schemaId: string): TextDocument | undefined { + if (!this.enabled) { + return undefined; + } + return this.metaSchemas.get(schemaId); + } + + /** + * Find a matching file extension from the available bundled schemas. + * Checks if the URI ends with any registered extension (preceded by a dot). + * If multiple extensions match, returns the longest one (most specific). + * + * For example, with extensions ['kson', 'orchestra.kson']: + * - 'file:///test.kson' matches 'kson' + * - 'file:///test.orchestra.kson' matches 'orchestra.kson' (longer/more specific) + * + * @param uri The URI to match against available extensions + * @returns The matched file extension, or undefined if none match + */ + private findMatchingExtension(uri: string): string | undefined { + // Extract just the filename part (after last slash) + const lastSlash = Math.max(uri.lastIndexOf('/'), uri.lastIndexOf('\\')); + const filename = lastSlash >= 0 ? uri.substring(lastSlash + 1) : uri; + + // Find all extensions that match the end of the filename + let bestMatch: string | undefined; + for (const extension of this.schemas.keys()) { + const suffix = '.' + extension; + if (filename.endsWith(suffix)) { + // Prefer longer matches (more specific extensions) + if (!bestMatch || extension.length > bestMatch.length) { + bestMatch = extension; + } + } + } + return bestMatch; } /** @@ -87,13 +177,14 @@ export class BundledSchemaProvider implements SchemaProvider { /** * Check if a given file URI is a schema file. - * Bundled schemas use the bundled:// scheme. + * Bundled schemas use the bundled:// scheme with .schema.kson suffix. * * @param fileUri The URI of the file to check * @returns True if the file is a bundled schema file */ isSchemaFile(fileUri: DocumentUri): boolean { - return fileUri.startsWith('bundled://schema/'); + return (fileUri.startsWith('bundled://schema/') || fileUri.startsWith('bundled://metaschema/')) + && fileUri.endsWith('.schema.kson'); } /** @@ -114,16 +205,16 @@ export class BundledSchemaProvider implements SchemaProvider { } /** - * Get the list of language IDs that have bundled schemas. + * Get the list of file extensions that have bundled schemas. */ - getAvailableLanguageIds(): string[] { + getAvailableFileExtensions(): string[] { return Array.from(this.schemas.keys()); } /** - * Check if a bundled schema exists for a given language ID. + * Check if a bundled schema exists for a given file extension. */ - hasBundledSchema(languageId: string): boolean { - return this.schemas.has(languageId); + hasBundledSchema(fileExtension: string): boolean { + return this.schemas.has(fileExtension); } } diff --git a/tooling/language-server-protocol/src/core/schema/CompositeSchemaProvider.ts b/tooling/language-server-protocol/src/core/schema/CompositeSchemaProvider.ts index 44013e74..5d6f9e44 100644 --- a/tooling/language-server-protocol/src/core/schema/CompositeSchemaProvider.ts +++ b/tooling/language-server-protocol/src/core/schema/CompositeSchemaProvider.ts @@ -57,6 +57,23 @@ export class CompositeSchemaProvider implements SchemaProvider { } } + /** + * Get a bundled metaschema by its schema ID. + * Queries all providers in order and returns the first non-undefined result. + * + * @param schemaId The $id of the metaschema to look up + * @returns TextDocument containing the metaschema, or undefined if no provider has a match + */ + getMetaSchemaForId(schemaId: string): TextDocument | undefined { + for (const provider of this.providers) { + const metaSchema = provider.getMetaSchemaForId(schemaId); + if (metaSchema) { + return metaSchema; + } + } + return undefined; + } + /** * Check if a given file URI is a schema file in any provider. * diff --git a/tooling/language-server-protocol/src/core/schema/FileSystemSchemaProvider.ts b/tooling/language-server-protocol/src/core/schema/FileSystemSchemaProvider.ts index 9c9c13f7..c000a0a6 100644 --- a/tooling/language-server-protocol/src/core/schema/FileSystemSchemaProvider.ts +++ b/tooling/language-server-protocol/src/core/schema/FileSystemSchemaProvider.ts @@ -63,6 +63,10 @@ export class FileSystemSchemaProvider implements SchemaProvider { }); } + getMetaSchemaForId(_schemaId: string): TextDocument | undefined { + return undefined; + } + /** * Get the schema for a given document URI. * diff --git a/tooling/language-server-protocol/src/core/schema/SchemaProvider.ts b/tooling/language-server-protocol/src/core/schema/SchemaProvider.ts index c1006cf1..f0fcc8a8 100644 --- a/tooling/language-server-protocol/src/core/schema/SchemaProvider.ts +++ b/tooling/language-server-protocol/src/core/schema/SchemaProvider.ts @@ -28,6 +28,15 @@ export interface SchemaProvider { * @returns True if the file is a schema file */ isSchemaFile(fileUri: DocumentUri): boolean; + + /** + * Get a bundled metaschema by its schema ID (e.g., the $id field value). + * Used for content-based schema resolution when a document declares $schema. + * + * @param schemaId The $id of the metaschema to look up + * @returns TextDocument containing the metaschema, or undefined if no match + */ + getMetaSchemaForId(schemaId: string): TextDocument | undefined; } /** @@ -46,4 +55,8 @@ export class NoOpSchemaProvider implements SchemaProvider { isSchemaFile(_fileUri: DocumentUri): boolean { return false; } + + getMetaSchemaForId(_schemaId: string): TextDocument | undefined { + return undefined; + } } diff --git a/tooling/language-server-protocol/src/index.ts b/tooling/language-server-protocol/src/index.ts new file mode 100644 index 00000000..5e0619a5 --- /dev/null +++ b/tooling/language-server-protocol/src/index.ts @@ -0,0 +1,2 @@ +// Common exports for kson-language-server package +export type { BundledSchemaConfig, BundledMetaSchemaConfig } from './core/schema/BundledSchemaProvider.js'; diff --git a/tooling/language-server-protocol/src/startKsonServer.ts b/tooling/language-server-protocol/src/startKsonServer.ts index 594bb4f7..43a3c413 100644 --- a/tooling/language-server-protocol/src/startKsonServer.ts +++ b/tooling/language-server-protocol/src/startKsonServer.ts @@ -12,7 +12,7 @@ import {KSON_LEGEND} from './core/features/SemanticTokensService.js'; import {getAllCommandIds} from './core/commands/CommandType.js'; import { ksonSettingsWithDefaults } from './core/KsonSettings.js'; import {SchemaProvider} from './core/schema/SchemaProvider.js'; -import {BundledSchemaProvider, BundledSchemaConfig} from './core/schema/BundledSchemaProvider.js'; +import {BundledSchemaProvider, BundledSchemaConfig, BundledMetaSchemaConfig} from './core/schema/BundledSchemaProvider.js'; import {CompositeSchemaProvider} from './core/schema/CompositeSchemaProvider.js'; import {SCHEMA_CONFIG_FILENAME} from "./core/schema/SchemaConfig"; import {CommandExecutorFactory} from "./core/commands/CommandExecutorFactory"; @@ -21,8 +21,10 @@ import {CommandExecutorFactory} from "./core/commands/CommandExecutorFactory"; * Initialization options passed from the VSCode client. */ interface KsonInitializationOptions { - /** Bundled schemas to be loaded */ + /** Bundled schemas to be loaded (matched by file extension) */ bundledSchemas?: BundledSchemaConfig[]; + /** Bundled metaschemas to be loaded (matched by document $schema content) */ + bundledMetaSchemas?: BundledMetaSchemaConfig[]; /** Whether bundled schemas are enabled */ enableBundledSchemas?: boolean; } @@ -70,15 +72,16 @@ export function startKsonServer( // Extract bundled schema configuration from initialization options const initOptions = params.initializationOptions as KsonInitializationOptions | undefined; const bundledSchemas = initOptions?.bundledSchemas ?? []; + const bundledMetaSchemas = initOptions?.bundledMetaSchemas ?? []; const enableBundledSchemas = initOptions?.enableBundledSchemas ?? true; // Create the appropriate schema provider for this environment (file system or no-op) const fileSystemSchemaProvider = await createSchemaProvider(workspaceRootUri, logger); - // Create bundled schema provider if schemas are configured + // Create bundled schema provider if schemas or metaschemas are configured let schemaProvider: SchemaProvider | undefined; - if (bundledSchemas.length > 0) { - bundledSchemaProvider = new BundledSchemaProvider(bundledSchemas, enableBundledSchemas, logger); + if (bundledSchemas.length > 0 || bundledMetaSchemas.length > 0) { + bundledSchemaProvider = new BundledSchemaProvider(bundledSchemas, enableBundledSchemas, logger, bundledMetaSchemas); // Create composite provider: file system takes priority over bundled const providers: SchemaProvider[] = []; diff --git a/tooling/language-server-protocol/src/test/core/features/DefinitionService.test.ts b/tooling/language-server-protocol/src/test/core/features/DefinitionService.test.ts index e9aba205..4348eaac 100644 --- a/tooling/language-server-protocol/src/test/core/features/DefinitionService.test.ts +++ b/tooling/language-server-protocol/src/test/core/features/DefinitionService.test.ts @@ -104,9 +104,32 @@ describe('DefinitionService', () => { }); describe('$ref resolution within schema documents', () => { + // Minimal metaschema simulating the bundled JSON Schema Draft-07 metaschema. + // Schema files that declare $schema get this assigned as their schema document, + // which exercises the code path where both $ref resolution and schema-to-metaschema + // navigation are attempted (the bug was that schema navigation early-returned, + // preventing $ref resolution from ever running). + const metaSchemaContent = `{ + "$id": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "type": { "type": "string" }, + "properties": { "type": "object", "additionalProperties": { "$ref": "#" } }, + "$ref": { "type": "string" }, + "$defs": { "type": "object" }, + "definitions": { "type": "object" }, + "$schema": { "type": "string" }, + "description": { "type": "string" } + } +}`; + const metaSchemaDocument = TextDocument.create( + 'bundled://metaschema/draft-07.schema.kson', 'kson', 1, metaSchemaContent + ); + it('should resolve $ref to $defs definition', () => { - // Create a schema document with a $ref + // Create a schema document with a $ref and a bundled metaschema const schemaContent = `{ + "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "user": { @@ -126,27 +149,28 @@ describe('DefinitionService', () => { }`; const textDoc = TextDocument.create('file:///schema.kson', 'kson', 1, schemaContent); const analysis = Kson.getInstance().analyze(schemaContent); - const document = new KsonDocument(textDoc, analysis, undefined); + const document = new KsonDocument(textDoc, analysis, metaSchemaDocument); // Position on the $ref value "#/$defs/User" - const position: Position = {line: 4, character: 24}; // Inside the ref string + const position: Position = {line: 5, character: 24}; // Inside the ref string const definition = definitionService.getDefinition(document, position); // Should return a definition pointing to the User definition assert.ok(Array.isArray(definition), 'Definition should be an array'); assert.ok(definition.length > 0, 'Definition array should not be empty'); - const firstDef = definition[0]; - assert.strictEqual(firstDef.targetUri, textDoc.uri, 'Target URI should be same document'); - assert.ok(firstDef.targetRange, 'Definition should have targetRange'); + const refDef = definition.find(d => d.targetUri === textDoc.uri); + assert.ok(refDef, 'Should have a $ref resolution result pointing to same document'); + assert.ok(refDef.targetRange, 'Definition should have targetRange'); - // Verify it points to the User definition (line 7 where "User": { starts) - assert.strictEqual(firstDef.targetRange.start.line, 8, 'Should point to User definition'); + // Verify it points to the User definition + assert.strictEqual(refDef.targetRange.start.line, 9, 'Should point to User definition'); }); it('should resolve $ref with JSON Pointer to nested property', () => { // Create a schema with a ref to a nested property const schemaContent = `{ + "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "data": { @@ -164,26 +188,27 @@ describe('DefinitionService', () => { }`; const textDoc = TextDocument.create('file:///schema.kson', 'kson', 1, schemaContent); const analysis = Kson.getInstance().analyze(schemaContent); - const document = new KsonDocument(textDoc, analysis, undefined); + const document = new KsonDocument(textDoc, analysis, metaSchemaDocument); // Position on the $ref value - const position: Position = {line: 4, character: 25}; // Inside the ref string + const position: Position = {line: 5, character: 25}; // Inside the ref string const definition = definitionService.getDefinition(document, position); // Should return a definition pointing to the name property assert.ok(Array.isArray(definition), 'Definition should be an array'); assert.ok(definition.length > 0, 'Definition array should not be empty'); - const firstDef = definition[0]; - assert.strictEqual(firstDef.targetUri, textDoc.uri, 'Target URI should be same document'); + const refDef = definition.find(d => d.targetUri === textDoc.uri); + assert.ok(refDef, 'Should have a $ref resolution result pointing to same document'); // Verify it points to the name property definition - assert.strictEqual(firstDef.targetRange.start.line, 9, 'Should point to name property'); + assert.strictEqual(refDef.targetRange.start.line, 10, 'Should point to name property'); }); it('should resolve $ref to root schema', () => { // Create a schema with a ref to root const schemaContent = `{ + "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "recursive": { @@ -193,25 +218,25 @@ describe('DefinitionService', () => { }`; const textDoc = TextDocument.create('file:///schema.kson', 'kson', 1, schemaContent); const analysis = Kson.getInstance().analyze(schemaContent); - const document = new KsonDocument(textDoc, analysis, undefined); + const document = new KsonDocument(textDoc, analysis, metaSchemaDocument); // Position on the $ref value "#" - const position: Position = {line: 4, character: 21}; // Inside the ref string + const position: Position = {line: 5, character: 21}; // Inside the ref string const definition = definitionService.getDefinition(document, position); // Should return a definition pointing to the root assert.ok(Array.isArray(definition), 'Definition should be an array'); assert.ok(definition.length > 0, 'Definition array should not be empty'); - const firstDef = definition[0]; - assert.strictEqual(firstDef.targetUri, textDoc.uri, 'Target URI should be same document'); + const refDef = definition.find(d => d.targetUri === textDoc.uri); + assert.ok(refDef, 'Should have a $ref resolution result pointing to same document'); // Verify it points to the root (line 0) - assert.strictEqual(firstDef.targetRange.start.line, 0, 'Should point to root'); + assert.strictEqual(refDef.targetRange.start.line, 0, 'Should point to root'); }); it('should return empty list when $ref target not found', () => { - // Create a schema with an invalid ref + // Create a schema with an invalid ref (no metaschema - simple negative case) const schemaContent = `{ "type": "object", "properties": { @@ -238,7 +263,7 @@ describe('DefinitionService', () => { }); it('should return empty list for external $ref', () => { - // Create a schema with an external ref (not supported yet) + // Create a schema with an external ref (no metaschema - simple negative case) const schemaContent = `{ "type": "object", "properties": { @@ -260,7 +285,7 @@ describe('DefinitionService', () => { }); it('should return empty list when not on a $ref', () => { - // Create a schema document + // Create a schema document without $ref usage (no metaschema - simple negative case) const schemaContent = `{ "type": "object", "properties": { @@ -284,6 +309,7 @@ describe('DefinitionService', () => { it('should resolve $ref using definitions instead of $defs', () => { // Test with JSON Schema Draft 4 style "definitions" instead of "$defs" const schemaContent = `{ + "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "item": { @@ -303,26 +329,27 @@ describe('DefinitionService', () => { }`; const textDoc = TextDocument.create('file:///schema.kson', 'kson', 1, schemaContent); const analysis = Kson.getInstance().analyze(schemaContent); - const document = new KsonDocument(textDoc, analysis, undefined); + const document = new KsonDocument(textDoc, analysis, metaSchemaDocument); // Position on the $ref value - const position: Position = {line: 4, character: 25}; // Inside the ref string + const position: Position = {line: 5, character: 25}; // Inside the ref string const definition = definitionService.getDefinition(document, position); // Should return a definition pointing to the Item definition assert.ok(Array.isArray(definition), 'Definition should be an array'); assert.ok(definition.length > 0, 'Definition array should not be empty'); - const firstDef = definition[0]; - assert.strictEqual(firstDef.targetUri, textDoc.uri, 'Target URI should be same document'); + const refDef = definition.find(d => d.targetUri === textDoc.uri); + assert.ok(refDef, 'Should have a $ref resolution result pointing to same document'); // Verify it points to the Item definition - assert.strictEqual(firstDef.targetRange.start.line, 8, 'Should point to Item definition'); + assert.strictEqual(refDef.targetRange.start.line, 9, 'Should point to Item definition'); }); it('should work with transitive $refs', () => { // Test case where a $ref points to another $ref const schemaContent = `{ + "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "data": { @@ -340,21 +367,21 @@ describe('DefinitionService', () => { }`; const textDoc = TextDocument.create('file:///schema.kson', 'kson', 1, schemaContent); const analysis = Kson.getInstance().analyze(schemaContent); - const document = new KsonDocument(textDoc, analysis, undefined); + const document = new KsonDocument(textDoc, analysis, metaSchemaDocument); // Position on the first $ref value - const position: Position = {line: 4, character: 23}; // Inside the ref string + const position: Position = {line: 5, character: 23}; // Inside the ref string const definition = definitionService.getDefinition(document, position); // Should return a definition pointing to the Alias (not following the transitive ref) assert.ok(Array.isArray(definition), 'Definition should be an array'); assert.ok(definition.length > 0, 'Definition array should not be empty'); - const firstDef = definition[0]; - assert.strictEqual(firstDef.targetUri, textDoc.uri, 'Target URI should be same document'); + const refDef = definition.find(d => d.targetUri === textDoc.uri); + assert.ok(refDef, 'Should have a $ref resolution result pointing to same document'); - // Verify it points to the Alias definition (line 8) - assert.strictEqual(firstDef.targetRange.start.line, 8, 'Should point to Alias definition'); + // Verify it points to the Alias definition (line 9 with $schema shift) + assert.strictEqual(refDef.targetRange.start.line, 9, 'Should point to Alias definition'); }); }); }); diff --git a/tooling/language-server-protocol/src/test/core/schema/BundledSchemaProvider.test.ts b/tooling/language-server-protocol/src/test/core/schema/BundledSchemaProvider.test.ts index f412d81f..0846539e 100644 --- a/tooling/language-server-protocol/src/test/core/schema/BundledSchemaProvider.test.ts +++ b/tooling/language-server-protocol/src/test/core/schema/BundledSchemaProvider.test.ts @@ -1,6 +1,6 @@ import {describe, it} from 'mocha'; import * as assert from 'assert'; -import {BundledSchemaProvider, BundledSchemaConfig} from '../../../core/schema/BundledSchemaProvider'; +import {BundledSchemaProvider, BundledSchemaConfig, BundledMetaSchemaConfig} from '../../../core/schema/BundledSchemaProvider'; describe('BundledSchemaProvider', () => { let logs: string[] = []; @@ -19,37 +19,48 @@ describe('BundledSchemaProvider', () => { it('should create provider with no schemas', () => { const provider = new BundledSchemaProvider([], true, logger); assert.ok(provider); - assert.strictEqual(provider.getAvailableLanguageIds().length, 0); - assert.ok(logs.some(msg => msg.includes('initialized with 0 schemas'))); + assert.strictEqual(provider.getAvailableFileExtensions().length, 0); + assert.ok(logs.some(msg => msg.includes('initialized with 0 schemas and 0 metaschemas'))); }); it('should create provider with schemas', () => { const schemas: BundledSchemaConfig[] = [ - { languageId: 'test-lang', schemaContent: '{ "type": "object" }' } + { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } ]; const provider = new BundledSchemaProvider(schemas, true, logger); assert.ok(provider); - assert.strictEqual(provider.getAvailableLanguageIds().length, 1); - assert.ok(provider.hasBundledSchema('test-lang')); - assert.ok(logs.some(msg => msg.includes('Loaded bundled schema for language: test-lang'))); + assert.strictEqual(provider.getAvailableFileExtensions().length, 1); + assert.ok(provider.hasBundledSchema('kxt')); + assert.ok(logs.some(msg => msg.includes('Loaded bundled schema for extension: kxt'))); }); it('should create provider with multiple schemas', () => { const schemas: BundledSchemaConfig[] = [ - { languageId: 'lang-a', schemaContent: '{ "type": "object" }' }, - { languageId: 'lang-b', schemaContent: '{ "type": "array" }' } + { fileExtension: 'ext-a', schemaContent: '{ "type": "object" }' }, + { fileExtension: 'ext-b', schemaContent: '{ "type": "array" }' } ]; const provider = new BundledSchemaProvider(schemas, true, logger); - assert.strictEqual(provider.getAvailableLanguageIds().length, 2); - assert.ok(provider.hasBundledSchema('lang-a')); - assert.ok(provider.hasBundledSchema('lang-b')); + assert.strictEqual(provider.getAvailableFileExtensions().length, 2); + assert.ok(provider.hasBundledSchema('ext-a')); + assert.ok(provider.hasBundledSchema('ext-b')); + }); + + it('should create provider with metaschemas', () => { + const metaSchemas: BundledMetaSchemaConfig[] = [ + { schemaId: 'http://json-schema.org/draft-07/schema#', name: 'draft-07', schemaContent: '{ "type": "object" }' } + ]; + const provider = new BundledSchemaProvider([], true, logger, metaSchemas); + + assert.ok(provider); + assert.ok(logs.some(msg => msg.includes('Loaded bundled metaschema: draft-07'))); + assert.ok(logs.some(msg => msg.includes('1 metaschemas'))); }); it('should respect enabled flag', () => { const schemas: BundledSchemaConfig[] = [ - { languageId: 'test-lang', schemaContent: '{ "type": "object" }' } + { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } ]; const provider = new BundledSchemaProvider(schemas, false, logger); @@ -61,70 +72,150 @@ describe('BundledSchemaProvider', () => { describe('getSchemaForDocument', () => { it('should return undefined when disabled', () => { const schemas: BundledSchemaConfig[] = [ - { languageId: 'test-lang', schemaContent: '{ "type": "object" }' } + { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } ]; const provider = new BundledSchemaProvider(schemas, false, logger); - const schema = provider.getSchemaForDocument('file:///test.kson', 'test-lang'); + const schema = provider.getSchemaForDocument('file:///test.kxt'); assert.strictEqual(schema, undefined); }); - it('should return undefined when no languageId provided', () => { + it('should return undefined when no file extension in URI', () => { const schemas: BundledSchemaConfig[] = [ - { languageId: 'test-lang', schemaContent: '{ "type": "object" }' } + { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } ]; const provider = new BundledSchemaProvider(schemas, true, logger); - const schema = provider.getSchemaForDocument('file:///test.kson'); + const schema = provider.getSchemaForDocument('file:///test'); assert.strictEqual(schema, undefined); }); - it('should return undefined for unknown languageId', () => { + it('should return undefined for unknown file extension', () => { const schemas: BundledSchemaConfig[] = [ - { languageId: 'test-lang', schemaContent: '{ "type": "object" }' } + { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } ]; const provider = new BundledSchemaProvider(schemas, true, logger); - const schema = provider.getSchemaForDocument('file:///test.kson', 'unknown-lang'); + const schema = provider.getSchemaForDocument('file:///test.unknown'); assert.strictEqual(schema, undefined); }); - it('should return schema for matching languageId', () => { + it('should return schema for matching file extension', () => { const schemaContent = '{ "type": "object" }'; const schemas: BundledSchemaConfig[] = [ - { languageId: 'test-lang', schemaContent } + { fileExtension: 'kxt', schemaContent } ]; const provider = new BundledSchemaProvider(schemas, true, logger); - const schema = provider.getSchemaForDocument('file:///test.kson', 'test-lang'); + const schema = provider.getSchemaForDocument('file:///test.kxt'); assert.ok(schema); assert.strictEqual(schema!.getText(), schemaContent); - assert.strictEqual(schema!.uri, 'bundled://schema/test-lang'); + assert.strictEqual(schema!.uri, 'bundled://schema/kxt.schema.kson'); }); - it('should ignore documentUri and use languageId', () => { + it('should return same schema for different paths with same extension', () => { const schemaContent = '{ "type": "object" }'; const schemas: BundledSchemaConfig[] = [ - { languageId: 'test-lang', schemaContent } + { fileExtension: 'kxt', schemaContent } ]; const provider = new BundledSchemaProvider(schemas, true, logger); - // Different document URIs should return same schema for same languageId - const schema1 = provider.getSchemaForDocument('file:///a.kson', 'test-lang'); - const schema2 = provider.getSchemaForDocument('file:///b.kson', 'test-lang'); + const schema1 = provider.getSchemaForDocument('file:///a.kxt'); + const schema2 = provider.getSchemaForDocument('file:///b.kxt'); assert.ok(schema1); assert.ok(schema2); assert.strictEqual(schema1!.uri, schema2!.uri); }); + + it('should match multi-dot extensions correctly', () => { + const ksonSchema = '{ "type": "kson" }'; + const orchestraSchema = '{ "type": "orchestra" }'; + const schemas: BundledSchemaConfig[] = [ + { fileExtension: 'kson', schemaContent: ksonSchema }, + { fileExtension: 'orchestra.kson', schemaContent: orchestraSchema } + ]; + const provider = new BundledSchemaProvider(schemas, true, logger); + + // Simple .kson file should match 'kson' extension + const simpleSchema = provider.getSchemaForDocument('file:///test.kson'); + assert.ok(simpleSchema); + assert.strictEqual(simpleSchema!.getText(), ksonSchema); + + // Multi-dot .orchestra.kson file should match the longer 'orchestra.kson' extension + const orchestraResult = provider.getSchemaForDocument('file:///my-config.orchestra.kson'); + assert.ok(orchestraResult); + assert.strictEqual(orchestraResult!.getText(), orchestraSchema); + }); + + it('should prefer longer extension when multiple match', () => { + const schemas: BundledSchemaConfig[] = [ + { fileExtension: 'kson', schemaContent: '{ "short": true }' }, + { fileExtension: 'config.kson', schemaContent: '{ "long": true }' } + ]; + const provider = new BundledSchemaProvider(schemas, true, logger); + + // File ending in .config.kson should match the longer extension + const schema = provider.getSchemaForDocument('file:///app.config.kson'); + assert.ok(schema); + assert.strictEqual(schema!.getText(), '{ "long": true }'); + }); + }); + + describe('getMetaSchemaForId', () => { + it('should return metaschema when ID matches', () => { + const metaSchemaContent = '{ "$id": "http://json-schema.org/draft-07/schema#" }'; + const metaSchemas: BundledMetaSchemaConfig[] = [ + { schemaId: 'http://json-schema.org/draft-07/schema#', name: 'draft-07', schemaContent: metaSchemaContent } + ]; + const provider = new BundledSchemaProvider([], true, logger, metaSchemas); + + const result = provider.getMetaSchemaForId('http://json-schema.org/draft-07/schema#'); + assert.ok(result); + assert.strictEqual(result!.getText(), metaSchemaContent); + assert.strictEqual(result!.uri, 'bundled://metaschema/draft-07.schema.kson'); + }); + + it('should return undefined when no matching ID', () => { + const metaSchemas: BundledMetaSchemaConfig[] = [ + { schemaId: 'http://json-schema.org/draft-07/schema#', name: 'draft-07', schemaContent: '{}' } + ]; + const provider = new BundledSchemaProvider([], true, logger, metaSchemas); + + const result = provider.getMetaSchemaForId('http://json-schema.org/draft-04/schema#'); + assert.strictEqual(result, undefined); + }); + + it('should return undefined when disabled', () => { + const metaSchemas: BundledMetaSchemaConfig[] = [ + { schemaId: 'http://json-schema.org/draft-07/schema#', name: 'draft-07', schemaContent: '{}' } + ]; + const provider = new BundledSchemaProvider([], false, logger, metaSchemas); + + const result = provider.getMetaSchemaForId('http://json-schema.org/draft-07/schema#'); + assert.strictEqual(result, undefined); + }); + + it('should return undefined when no metaschemas configured', () => { + const provider = new BundledSchemaProvider([], true, logger); + + const result = provider.getMetaSchemaForId('http://json-schema.org/draft-07/schema#'); + assert.strictEqual(result, undefined); + }); }); describe('isSchemaFile', () => { it('should return true for bundled schema URIs', () => { const provider = new BundledSchemaProvider([], true, logger); - assert.strictEqual(provider.isSchemaFile('bundled://schema/test-lang'), true); - assert.strictEqual(provider.isSchemaFile('bundled://schema/other'), true); + assert.strictEqual(provider.isSchemaFile('bundled://schema/test-lang.schema.kson'), true); + assert.strictEqual(provider.isSchemaFile('bundled://schema/other.schema.kson'), true); + }); + + it('should return true for bundled metaschema URIs', () => { + const provider = new BundledSchemaProvider([], true, logger); + + assert.strictEqual(provider.isSchemaFile('bundled://metaschema/draft-07.schema.kson'), true); }); it('should return false for non-bundled URIs', () => { @@ -138,7 +229,7 @@ describe('BundledSchemaProvider', () => { describe('setEnabled', () => { it('should toggle enabled state', () => { const schemas: BundledSchemaConfig[] = [ - { languageId: 'test-lang', schemaContent: '{ "type": "object" }' } + { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } ]; const provider = new BundledSchemaProvider(schemas, true, logger); @@ -148,61 +239,76 @@ describe('BundledSchemaProvider', () => { assert.strictEqual(provider.isEnabled(), false); // Should not return schema when disabled - const schema = provider.getSchemaForDocument('file:///test.kson', 'test-lang'); + const schema = provider.getSchemaForDocument('file:///test.kxt'); assert.strictEqual(schema, undefined); provider.setEnabled(true); assert.strictEqual(provider.isEnabled(), true); // Should return schema when re-enabled - const schema2 = provider.getSchemaForDocument('file:///test.kson', 'test-lang'); + const schema2 = provider.getSchemaForDocument('file:///test.kxt'); assert.ok(schema2); }); + + it('should toggle metaschema availability', () => { + const metaSchemas: BundledMetaSchemaConfig[] = [ + { schemaId: 'http://json-schema.org/draft-07/schema#', name: 'draft-07', schemaContent: '{}' } + ]; + const provider = new BundledSchemaProvider([], true, logger, metaSchemas); + + assert.ok(provider.getMetaSchemaForId('http://json-schema.org/draft-07/schema#')); + + provider.setEnabled(false); + assert.strictEqual(provider.getMetaSchemaForId('http://json-schema.org/draft-07/schema#'), undefined); + + provider.setEnabled(true); + assert.ok(provider.getMetaSchemaForId('http://json-schema.org/draft-07/schema#')); + }); }); describe('reload', () => { it('should be a no-op (bundled schemas are immutable)', () => { const schemas: BundledSchemaConfig[] = [ - { languageId: 'test-lang', schemaContent: '{ "type": "object" }' } + { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } ]; const provider = new BundledSchemaProvider(schemas, true, logger); // reload should not throw or change anything provider.reload(); - assert.strictEqual(provider.getAvailableLanguageIds().length, 1); - assert.ok(provider.hasBundledSchema('test-lang')); + assert.strictEqual(provider.getAvailableFileExtensions().length, 1); + assert.ok(provider.hasBundledSchema('kxt')); }); }); describe('hasBundledSchema', () => { - it('should return true for configured languages', () => { + it('should return true for configured extensions', () => { const schemas: BundledSchemaConfig[] = [ - { languageId: 'lang-a', schemaContent: '{}' }, - { languageId: 'lang-b', schemaContent: '{}' } + { fileExtension: 'ext-a', schemaContent: '{}' }, + { fileExtension: 'ext-b', schemaContent: '{}' } ]; const provider = new BundledSchemaProvider(schemas, true, logger); - assert.strictEqual(provider.hasBundledSchema('lang-a'), true); - assert.strictEqual(provider.hasBundledSchema('lang-b'), true); - assert.strictEqual(provider.hasBundledSchema('lang-c'), false); + assert.strictEqual(provider.hasBundledSchema('ext-a'), true); + assert.strictEqual(provider.hasBundledSchema('ext-b'), true); + assert.strictEqual(provider.hasBundledSchema('ext-c'), false); }); }); - describe('getAvailableLanguageIds', () => { - it('should return all configured language IDs', () => { + describe('getAvailableFileExtensions', () => { + it('should return all configured file extensions', () => { const schemas: BundledSchemaConfig[] = [ - { languageId: 'alpha', schemaContent: '{}' }, - { languageId: 'beta', schemaContent: '{}' }, - { languageId: 'gamma', schemaContent: '{}' } + { fileExtension: 'alpha', schemaContent: '{}' }, + { fileExtension: 'beta', schemaContent: '{}' }, + { fileExtension: 'gamma', schemaContent: '{}' } ]; const provider = new BundledSchemaProvider(schemas, true, logger); - const ids = provider.getAvailableLanguageIds(); - assert.strictEqual(ids.length, 3); - assert.ok(ids.includes('alpha')); - assert.ok(ids.includes('beta')); - assert.ok(ids.includes('gamma')); + const extensions = provider.getAvailableFileExtensions(); + assert.strictEqual(extensions.length, 3); + assert.ok(extensions.includes('alpha')); + assert.ok(extensions.includes('beta')); + assert.ok(extensions.includes('gamma')); }); }); }); diff --git a/tooling/language-server-protocol/src/test/core/schema/CompositeSchemaProvider.test.ts b/tooling/language-server-protocol/src/test/core/schema/CompositeSchemaProvider.test.ts index 280ce8b1..57ef78f5 100644 --- a/tooling/language-server-protocol/src/test/core/schema/CompositeSchemaProvider.test.ts +++ b/tooling/language-server-protocol/src/test/core/schema/CompositeSchemaProvider.test.ts @@ -3,43 +3,35 @@ import * as assert from 'assert'; import {TextDocument} from 'vscode-languageserver-textdocument'; import {DocumentUri} from 'vscode-languageserver'; import {CompositeSchemaProvider} from '../../../core/schema/CompositeSchemaProvider'; +import {BundledSchemaProvider, BundledMetaSchemaConfig} from '../../../core/schema/BundledSchemaProvider'; import {SchemaProvider, NoOpSchemaProvider} from '../../../core/schema/SchemaProvider'; /** - * Mock schema provider that returns a predefined schema for specific URIs. + * Minimal mock that simulates FileSystemSchemaProvider behavior (URI-based lookup). + * Only used because FileSystemSchemaProvider requires disk I/O. */ -class MockSchemaProvider implements SchemaProvider { +class UriSchemaProvider implements SchemaProvider { private schemas: Map = new Map(); - private schemaFiles: Set = new Set(); addSchema(documentUri: string, schema: TextDocument): void { this.schemas.set(documentUri, schema); } - addSchemaByLanguageId(languageId: string, schema: TextDocument): void { - this.schemas.set(`lang:${languageId}`, schema); + getSchemaForDocument(documentUri: DocumentUri): TextDocument | undefined { + return this.schemas.get(documentUri); } - markAsSchemaFile(uri: string): void { - this.schemaFiles.add(uri); - } + reload(): void {} - getSchemaForDocument(documentUri: DocumentUri, languageId?: string): TextDocument | undefined { - // Check by languageId first - if (languageId) { - const langSchema = this.schemas.get(`lang:${languageId}`); - if (langSchema) return langSchema; + isSchemaFile(fileUri: DocumentUri): boolean { + for (const schema of this.schemas.values()) { + if (schema.uri === fileUri) return true; } - // Then check by URI - return this.schemas.get(documentUri); + return false; } - reload(): void { - // No-op for mock - } - - isSchemaFile(fileUri: DocumentUri): boolean { - return this.schemaFiles.has(fileUri); + getMetaSchemaForId(_schemaId: string): TextDocument | undefined { + return undefined; } } @@ -56,10 +48,6 @@ describe('CompositeSchemaProvider', () => { logs = []; }); - function createSchema(uri: string, content: string): TextDocument { - return TextDocument.create(uri, 'kson', 1, content); - } - describe('constructor', () => { it('should create provider with no providers', () => { const provider = new CompositeSchemaProvider([], logger); @@ -68,9 +56,9 @@ describe('CompositeSchemaProvider', () => { }); it('should create provider with multiple providers', () => { - const mock1 = new MockSchemaProvider(); - const mock2 = new MockSchemaProvider(); - const provider = new CompositeSchemaProvider([mock1, mock2], logger); + const p1 = new BundledSchemaProvider([], true, logger); + const p2 = new BundledSchemaProvider([], true, logger); + const provider = new CompositeSchemaProvider([p1, p2], logger); assert.strictEqual(provider.getProviders().length, 2); }); @@ -84,25 +72,25 @@ describe('CompositeSchemaProvider', () => { }); it('should return undefined when no provider has schema', () => { - const mock1 = new MockSchemaProvider(); - const mock2 = new MockSchemaProvider(); - const provider = new CompositeSchemaProvider([mock1, mock2], logger); + const p1 = new BundledSchemaProvider([], true, logger); + const p2 = new BundledSchemaProvider([], true, logger); + const provider = new CompositeSchemaProvider([p1, p2], logger); const schema = provider.getSchemaForDocument('file:///test.kson'); assert.strictEqual(schema, undefined); }); it('should return schema from first matching provider', () => { - const mock1 = new MockSchemaProvider(); - const mock2 = new MockSchemaProvider(); + const uriProvider1 = new UriSchemaProvider(); + const uriProvider2 = new UriSchemaProvider(); - const schema1 = createSchema('file:///schema1.kson', '{ "from": "first" }'); - mock1.addSchema('file:///test.kson', schema1); + const schema1 = TextDocument.create('file:///schema1.kson', 'kson', 1, '{ "from": "first" }'); + uriProvider1.addSchema('file:///test.kson', schema1); - const schema2 = createSchema('file:///schema2.kson', '{ "from": "second" }'); - mock2.addSchema('file:///test.kson', schema2); + const schema2 = TextDocument.create('file:///schema2.kson', 'kson', 1, '{ "from": "second" }'); + uriProvider2.addSchema('file:///test.kson', schema2); - const provider = new CompositeSchemaProvider([mock1, mock2], logger); + const provider = new CompositeSchemaProvider([uriProvider1, uriProvider2], logger); const result = provider.getSchemaForDocument('file:///test.kson'); assert.ok(result); @@ -110,48 +98,46 @@ describe('CompositeSchemaProvider', () => { }); it('should try next provider when first returns undefined', () => { - const mock1 = new MockSchemaProvider(); - const mock2 = new MockSchemaProvider(); + const uriProvider1 = new UriSchemaProvider(); + const uriProvider2 = new UriSchemaProvider(); - // mock1 has no schema for test.kson - const schema2 = createSchema('file:///schema2.kson', '{ "from": "second" }'); - mock2.addSchema('file:///test.kson', schema2); + // uriProvider1 has no schema for test.kson + const schema2 = TextDocument.create('file:///schema2.kson', 'kson', 1, '{ "from": "second" }'); + uriProvider2.addSchema('file:///test.kson', schema2); - const provider = new CompositeSchemaProvider([mock1, mock2], logger); + const provider = new CompositeSchemaProvider([uriProvider1, uriProvider2], logger); const result = provider.getSchemaForDocument('file:///test.kson'); assert.ok(result); assert.strictEqual(result!.getText(), '{ "from": "second" }'); }); - it('should pass languageId to providers', () => { - const mock1 = new MockSchemaProvider(); - const mock2 = new MockSchemaProvider(); + it('should match by file extension via BundledSchemaProvider', () => { + const emptyProvider = new BundledSchemaProvider([], true, logger); + const bundledProvider = new BundledSchemaProvider([ + { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } + ], true, logger); - const schema = createSchema('bundled://schema/test-lang', '{ "type": "object" }'); - mock2.addSchemaByLanguageId('test-lang', schema); - - const provider = new CompositeSchemaProvider([mock1, mock2], logger); - const result = provider.getSchemaForDocument('file:///test.kson', 'test-lang'); + const provider = new CompositeSchemaProvider([emptyProvider, bundledProvider], logger); + const result = provider.getSchemaForDocument('file:///test.kxt'); assert.ok(result); - assert.strictEqual(result!.uri, 'bundled://schema/test-lang'); + assert.strictEqual(result!.uri, 'bundled://schema/kxt.schema.kson'); }); it('should prioritize file system provider over bundled (typical usage)', () => { - // Simulate file system provider (has schema by URI) - const fileSystemProvider = new MockSchemaProvider(); - const fsSchema = createSchema('file:///workspace/schema.kson', '{ "from": "filesystem" }'); - fileSystemProvider.addSchema('file:///test.kson', fsSchema); + // File system provider (has schema by URI) - higher priority + const fileSystemProvider = new UriSchemaProvider(); + const fsSchema = TextDocument.create('file:///workspace/schema.kson', 'kson', 1, '{ "from": "filesystem" }'); + fileSystemProvider.addSchema('file:///test.kxt', fsSchema); - // Simulate bundled provider (has schema by languageId) - const bundledProvider = new MockSchemaProvider(); - const bundledSchema = createSchema('bundled://schema/kson', '{ "from": "bundled" }'); - bundledProvider.addSchemaByLanguageId('kson', bundledSchema); + // Bundled provider (has schema by extension) - lower priority + const bundledProvider = new BundledSchemaProvider([ + { fileExtension: 'kxt', schemaContent: '{ "from": "bundled" }' } + ], true, logger); - // File system first (higher priority) const provider = new CompositeSchemaProvider([fileSystemProvider, bundledProvider], logger); - const result = provider.getSchemaForDocument('file:///test.kson', 'kson'); + const result = provider.getSchemaForDocument('file:///test.kxt'); assert.ok(result); assert.strictEqual(result!.getText(), '{ "from": "filesystem" }'); @@ -159,21 +145,86 @@ describe('CompositeSchemaProvider', () => { it('should fall back to bundled when file system has no schema', () => { // File system provider with no schema - const fileSystemProvider = new MockSchemaProvider(); + const fileSystemProvider = new UriSchemaProvider(); // Bundled provider has schema - const bundledProvider = new MockSchemaProvider(); - const bundledSchema = createSchema('bundled://schema/kson', '{ "from": "bundled" }'); - bundledProvider.addSchemaByLanguageId('kson', bundledSchema); + const bundledProvider = new BundledSchemaProvider([ + { fileExtension: 'kxt', schemaContent: '{ "from": "bundled" }' } + ], true, logger); const provider = new CompositeSchemaProvider([fileSystemProvider, bundledProvider], logger); - const result = provider.getSchemaForDocument('file:///test.kson', 'kson'); + const result = provider.getSchemaForDocument('file:///test.kxt'); assert.ok(result); assert.strictEqual(result!.getText(), '{ "from": "bundled" }'); }); }); + describe('getMetaSchemaForId', () => { + it('should return undefined when no providers', () => { + const provider = new CompositeSchemaProvider([], logger); + const result = provider.getMetaSchemaForId('http://json-schema.org/draft-07/schema#'); + assert.strictEqual(result, undefined); + }); + + it('should return metaschema from first matching provider', () => { + const metaSchemas: BundledMetaSchemaConfig[] = [ + { schemaId: 'http://json-schema.org/draft-07/schema#', name: 'draft-07', schemaContent: '{ "from": "first" }' } + ]; + const provider1 = new BundledSchemaProvider([], true, logger, metaSchemas); + + const metaSchemas2: BundledMetaSchemaConfig[] = [ + { schemaId: 'http://json-schema.org/draft-07/schema#', name: 'draft-07', schemaContent: '{ "from": "second" }' } + ]; + const provider2 = new BundledSchemaProvider([], true, logger, metaSchemas2); + + const composite = new CompositeSchemaProvider([provider1, provider2], logger); + const result = composite.getMetaSchemaForId('http://json-schema.org/draft-07/schema#'); + + assert.ok(result); + assert.strictEqual(result!.getText(), '{ "from": "first" }'); + }); + + it('should try next provider when first has no match', () => { + const provider1 = new BundledSchemaProvider([], true, logger); + + const metaSchemas2: BundledMetaSchemaConfig[] = [ + { schemaId: 'http://json-schema.org/draft-07/schema#', name: 'draft-07', schemaContent: '{ "from": "second" }' } + ]; + const provider2 = new BundledSchemaProvider([], true, logger, metaSchemas2); + + const composite = new CompositeSchemaProvider([provider1, provider2], logger); + const result = composite.getMetaSchemaForId('http://json-schema.org/draft-07/schema#'); + + assert.ok(result); + assert.strictEqual(result!.getText(), '{ "from": "second" }'); + }); + + it('should return undefined when no provider has match', () => { + const provider1 = new BundledSchemaProvider([], true, logger); + const provider2 = new BundledSchemaProvider([], true, logger); + + const composite = new CompositeSchemaProvider([provider1, provider2], logger); + const result = composite.getMetaSchemaForId('http://json-schema.org/draft-07/schema#'); + + assert.strictEqual(result, undefined); + }); + + it('should work with UriSchemaProvider (returns undefined)', () => { + const uriProvider = new UriSchemaProvider(); + const metaSchemas: BundledMetaSchemaConfig[] = [ + { schemaId: 'http://json-schema.org/draft-07/schema#', name: 'draft-07', schemaContent: '{ "metaschema": true }' } + ]; + const bundledProvider = new BundledSchemaProvider([], true, logger, metaSchemas); + + const composite = new CompositeSchemaProvider([uriProvider, bundledProvider], logger); + const result = composite.getMetaSchemaForId('http://json-schema.org/draft-07/schema#'); + + assert.ok(result); + assert.strictEqual(result!.getText(), '{ "metaschema": true }'); + }); + }); + describe('isSchemaFile', () => { it('should return false when no providers', () => { const provider = new CompositeSchemaProvider([], logger); @@ -181,34 +232,42 @@ describe('CompositeSchemaProvider', () => { }); it('should return true when any provider considers it a schema file', () => { - const mock1 = new MockSchemaProvider(); - const mock2 = new MockSchemaProvider(); + const uriProvider = new UriSchemaProvider(); + const bundledProvider = new BundledSchemaProvider([ + { fileExtension: 'kxt', schemaContent: '{}' } + ], true, logger); - mock2.markAsSchemaFile('file:///schema.kson'); + const provider = new CompositeSchemaProvider([uriProvider, bundledProvider], logger); + assert.strictEqual(provider.isSchemaFile('bundled://schema/kxt.schema.kson'), true); + }); - const provider = new CompositeSchemaProvider([mock1, mock2], logger); - assert.strictEqual(provider.isSchemaFile('file:///schema.kson'), true); + it('should return true for metaschema URIs', () => { + const bundledProvider = new BundledSchemaProvider([], true, logger); + + const provider = new CompositeSchemaProvider([bundledProvider], logger); + assert.strictEqual(provider.isSchemaFile('bundled://metaschema/draft-07.schema.kson'), true); }); it('should return false when no provider considers it a schema file', () => { - const mock1 = new MockSchemaProvider(); - const mock2 = new MockSchemaProvider(); + const p1 = new BundledSchemaProvider([], true, logger); + const p2 = new BundledSchemaProvider([], true, logger); - const provider = new CompositeSchemaProvider([mock1, mock2], logger); + const provider = new CompositeSchemaProvider([p1, p2], logger); assert.strictEqual(provider.isSchemaFile('file:///random.kson'), false); }); it('should check all provider types', () => { - const mock1 = new MockSchemaProvider(); - mock1.markAsSchemaFile('file:///schema1.kson'); + const uriProvider = new UriSchemaProvider(); + uriProvider.addSchema('file:///test.kson', TextDocument.create('file:///my-schema.kson', 'kson', 1, '{}')); - const mock2 = new MockSchemaProvider(); - mock2.markAsSchemaFile('bundled://schema/lang'); + const bundledProvider = new BundledSchemaProvider([ + { fileExtension: 'kxt', schemaContent: '{}' } + ], true, logger); - const provider = new CompositeSchemaProvider([mock1, mock2], logger); + const provider = new CompositeSchemaProvider([uriProvider, bundledProvider], logger); - assert.strictEqual(provider.isSchemaFile('file:///schema1.kson'), true); - assert.strictEqual(provider.isSchemaFile('bundled://schema/lang'), true); + assert.strictEqual(provider.isSchemaFile('file:///my-schema.kson'), true); + assert.strictEqual(provider.isSchemaFile('bundled://schema/kxt.schema.kson'), true); assert.strictEqual(provider.isSchemaFile('file:///other.kson'), false); }); }); @@ -221,6 +280,7 @@ describe('CompositeSchemaProvider', () => { getSchemaForDocument(): TextDocument | undefined { return undefined; } reload(): void { reloadCount++; } isSchemaFile(): boolean { return false; } + getMetaSchemaForId(): TextDocument | undefined { return undefined; } } const provider = new CompositeSchemaProvider([ @@ -237,28 +297,36 @@ describe('CompositeSchemaProvider', () => { describe('getProviders', () => { it('should return readonly array of providers', () => { - const mock1 = new MockSchemaProvider(); - const mock2 = new MockSchemaProvider(); - const provider = new CompositeSchemaProvider([mock1, mock2], logger); + const p1 = new BundledSchemaProvider([], true, logger); + const p2 = new BundledSchemaProvider([], true, logger); + const provider = new CompositeSchemaProvider([p1, p2], logger); const providers = provider.getProviders(); assert.strictEqual(providers.length, 2); - assert.strictEqual(providers[0], mock1); - assert.strictEqual(providers[1], mock2); + assert.strictEqual(providers[0], p1); + assert.strictEqual(providers[1], p2); }); }); describe('integration with NoOpSchemaProvider', () => { it('should work with NoOpSchemaProvider as fallback', () => { - const mock = new MockSchemaProvider(); + const bundled = new BundledSchemaProvider([], true, logger); const noOp = new NoOpSchemaProvider(); - const provider = new CompositeSchemaProvider([mock, noOp], logger); + const provider = new CompositeSchemaProvider([bundled, noOp], logger); - // Should return undefined when mock has no schema + // Should return undefined when bundled has no schema const schema = provider.getSchemaForDocument('file:///test.kson'); assert.strictEqual(schema, undefined); }); + + it('should return undefined for metaschema with NoOpSchemaProvider', () => { + const noOp = new NoOpSchemaProvider(); + const provider = new CompositeSchemaProvider([noOp], logger); + + const result = provider.getMetaSchemaForId('http://json-schema.org/draft-07/schema#'); + assert.strictEqual(result, undefined); + }); }); }); diff --git a/tooling/lsp-clients/vscode/schemas/metaschema.draft7.kson b/tooling/lsp-clients/vscode/schemas/metaschema.draft7.kson new file mode 100644 index 00000000..f6dba0c7 --- /dev/null +++ b/tooling/lsp-clients/vscode/schemas/metaschema.draft7.kson @@ -0,0 +1,225 @@ +'$schema': 'http://json-schema.org/draft-07/schema#' +'$id': 'http://json-schema.org/draft-07/schema#' +title: 'Core schema meta-schema' +definitions: + schemaArray: + type: array + minItems: 1 + items: + '$ref': '#' + . + . + nonNegativeInteger: + type: integer + minimum: 0 + . + nonNegativeIntegerDefault0: + allOf: + - '$ref': '#/definitions/nonNegativeInteger' + - default: 0 + . + . + simpleTypes: + enum: + - array + - boolean + - integer + - 'null' + - number + - object + - string + . + stringArray: + type: array + items: + type: string + . + uniqueItems: true + default: <> + . + . +type: + - object + - boolean +properties: + '$id': + type: string + format: 'uri-reference' + . + '$schema': + type: string + format: uri + . + '$ref': + type: string + format: 'uri-reference' + . + '$comment': + type: string + . + title: + type: string + . + description: + type: string + . + default: true + readOnly: + type: boolean + default: false + . + writeOnly: + type: boolean + default: false + . + examples: + type: array + items: true + . + multipleOf: + type: number + exclusiveMinimum: 0 + . + maximum: + type: number + . + exclusiveMaximum: + type: number + . + minimum: + type: number + . + exclusiveMinimum: + type: number + . + maxLength: + '$ref': '#/definitions/nonNegativeInteger' + . + minLength: + '$ref': '#/definitions/nonNegativeIntegerDefault0' + . + pattern: + type: string + format: regex + . + additionalItems: + '$ref': '#' + . + items: + anyOf: + - '$ref': '#' + - '$ref': '#/definitions/schemaArray' + . + default: true + . + maxItems: + '$ref': '#/definitions/nonNegativeInteger' + . + minItems: + '$ref': '#/definitions/nonNegativeIntegerDefault0' + . + uniqueItems: + type: boolean + default: false + . + contains: + '$ref': '#' + . + maxProperties: + '$ref': '#/definitions/nonNegativeInteger' + . + minProperties: + '$ref': '#/definitions/nonNegativeIntegerDefault0' + . + required: + '$ref': '#/definitions/stringArray' + . + additionalProperties: + '$ref': '#' + . + definitions: + type: object + additionalProperties: + '$ref': '#' + . + default: {} + . + properties: + type: object + additionalProperties: + '$ref': '#' + . + default: {} + . + patternProperties: + type: object + additionalProperties: + '$ref': '#' + . + propertyNames: + format: regex + . + default: {} + . + dependencies: + type: object + additionalProperties: + anyOf: + - '$ref': '#' + - '$ref': '#/definitions/stringArray' + . + . + . + propertyNames: + '$ref': '#' + . + const: true + enum: + type: array + items: true + minItems: 1 + uniqueItems: true + . + type: + anyOf: + - '$ref': '#/definitions/simpleTypes' + - type: array + items: + '$ref': '#/definitions/simpleTypes' + . + minItems: 1 + uniqueItems: true + . + . + format: + type: string + . + contentMediaType: + type: string + . + contentEncoding: + type: string + . + if: + '$ref': '#' + . + then: + '$ref': '#' + . + else: + '$ref': '#' + . + allOf: + '$ref': '#/definitions/schemaArray' + . + anyOf: + '$ref': '#/definitions/schemaArray' + . + oneOf: + '$ref': '#/definitions/schemaArray' + . + not: + '$ref': '#' + . + . +default: true diff --git a/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts b/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts index 1abc4ca0..cb96c6df 100644 --- a/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts +++ b/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts @@ -2,7 +2,8 @@ import * as vscode from 'vscode'; import { LanguageClient } from 'vscode-languageclient/browser'; import { createClientOptions } from '../../config/clientOptions'; import { initializeLanguageConfig } from '../../config/languageConfig'; -import { loadBundledSchemas, areBundledSchemasEnabled } from '../../config/bundledSchemaLoader'; +import { loadBundledSchemas, loadBundledMetaSchemas, areBundledSchemasEnabled } from '../../config/bundledSchemaLoader'; +import { registerBundledSchemaContentProvider } from '../common/BundledSchemaContentProvider'; import {deactivate} from '../common/deactivate'; /** @@ -22,16 +23,19 @@ export async function activate(context: vscode.ExtensionContext) { const serverModule = vscode.Uri.joinPath(context.extensionUri, 'dist', 'browserServer.js'); const worker = new Worker(serverModule.toString(true)); - // Load bundled schemas (works in browser via vscode.workspace.fs) - const bundledSchemas = await loadBundledSchemas(context.extensionUri, { - info: (msg) => logOutputChannel.info(msg), - warn: (msg) => logOutputChannel.warn(msg), - error: (msg) => logOutputChannel.error(msg) - }); + // Load bundled schemas and metaschemas (works in browser via vscode.workspace.fs) + const schemaLogger = { + info: (msg: string) => logOutputChannel.info(msg), + warn: (msg: string) => logOutputChannel.warn(msg), + error: (msg: string) => logOutputChannel.error(msg) + }; + const bundledSchemas = await loadBundledSchemas(context.extensionUri, schemaLogger); + const bundledMetaSchemas = await loadBundledMetaSchemas(context.extensionUri, schemaLogger); // Create the language client options with bundled schemas const clientOptions = createClientOptions(logOutputChannel, { bundledSchemas, + bundledMetaSchemas, enableBundledSchemas: areBundledSchemasEnabled() }); @@ -59,6 +63,11 @@ export async function activate(context: vscode.ExtensionContext) { // Start the client and language server await languageClient.start(); + // Register content provider for bundled:// URIs so users can navigate to bundled schemas + context.subscriptions.push( + registerBundledSchemaContentProvider(context, bundledSchemas, bundledMetaSchemas) + ); + logOutputChannel.info('KSON Browser extension activated successfully'); if (bundledSchemas.length > 0) { logOutputChannel.info(`Loaded ${bundledSchemas.length} bundled schemas for browser environment`); diff --git a/tooling/lsp-clients/vscode/src/client/common/BundledSchemaContentProvider.ts b/tooling/lsp-clients/vscode/src/client/common/BundledSchemaContentProvider.ts new file mode 100644 index 00000000..c10d1a5c --- /dev/null +++ b/tooling/lsp-clients/vscode/src/client/common/BundledSchemaContentProvider.ts @@ -0,0 +1,72 @@ +import * as vscode from 'vscode'; +import { BundledSchemaConfig, BundledMetaSchemaConfig } from '../../config/bundledSchemaLoader'; + +/** + * TextDocumentContentProvider for bundled:// URIs. + * + * ## Why this exists + * + * The LSP server creates schema documents with `bundled://schema/{fileExtension}` URIs + * for schemas that are bundled with the extension, and `bundled://metaschema/{name}` URIs + * for metaschemas. When "Go to Definition" returns a location pointing to these URIs, + * VS Code needs to be able to open them. + * + * Without this provider, VS Code would fail with: + * "Unable to resolve resource bundled://schema/xxx" + * + * This provider maps bundled URIs to the pre-loaded schema content, allowing navigation + * to work correctly. + * + * URI formats: + * - bundled://schema/{fileExtension}.schema.kson + * - bundled://metaschema/{name}.schema.kson + * + * @see BundledSchemaConfig in bundledSchemaLoader.ts for architecture discussion + * @see test/suite/bundled-schema.test.ts for integration tests + */ +export class BundledSchemaContentProvider implements vscode.TextDocumentContentProvider { + private contentByKey: Map; + + constructor(bundledSchemas: BundledSchemaConfig[], bundledMetaSchemas: BundledMetaSchemaConfig[] = []) { + this.contentByKey = new Map(); + + // Register extension-based schemas: key = "schema/{ext}.schema.kson" + for (const schema of bundledSchemas) { + const key = `schema/${schema.fileExtension}.schema.kson`; + this.contentByKey.set(key, schema.schemaContent); + } + + // Register metaschemas: key = "metaschema/{name}.schema.kson" + for (const metaSchema of bundledMetaSchemas) { + const key = `metaschema/${metaSchema.name}.schema.kson`; + this.contentByKey.set(key, metaSchema.schemaContent); + } + } + + provideTextDocumentContent(uri: vscode.Uri): string | undefined { + // URI format: bundled://{authority}/{path} + // uri.authority = "schema" or "metaschema" + // uri.path = "/{name}.schema.kson" + const key = `${uri.authority}${uri.path}`; + // Remove leading slash from path: "schema//ext.schema.kson" → "schema/ext.schema.kson" + const normalizedKey = key.replace(/^([^/]+)\/\//, '$1/'); + return this.contentByKey.get(normalizedKey); + } +} + +/** + * Register the bundled schema content provider. + * + * @param context Extension context for disposable registration + * @param bundledSchemas The loaded bundled schemas + * @param bundledMetaSchemas The loaded bundled metaschemas + * @returns The registered disposable + */ +export function registerBundledSchemaContentProvider( + context: vscode.ExtensionContext, + bundledSchemas: BundledSchemaConfig[], + bundledMetaSchemas: BundledMetaSchemaConfig[] = [] +): vscode.Disposable { + const provider = new BundledSchemaContentProvider(bundledSchemas, bundledMetaSchemas); + return vscode.workspace.registerTextDocumentContentProvider('bundled', provider); +} diff --git a/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts b/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts index 695b1c06..1d7b5957 100644 --- a/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts +++ b/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts @@ -12,7 +12,8 @@ import { } from '../../config/clientOptions'; import {StatusBarManager} from '../common/StatusBarManager'; import { isKsonDialect, initializeLanguageConfig } from '../../config/languageConfig'; -import { loadBundledSchemas, areBundledSchemasEnabled } from '../../config/bundledSchemaLoader'; +import { loadBundledSchemas, loadBundledMetaSchemas, areBundledSchemasEnabled } from '../../config/bundledSchemaLoader'; +import { registerBundledSchemaContentProvider } from '../common/BundledSchemaContentProvider'; /** * Node.js-specific activation function for the KSON extension. @@ -41,15 +42,18 @@ export async function activate(context: vscode.ExtensionContext) { debug: {module: serverModule, transport: TransportKind.ipc, options: debugOptions} }; - // Load bundled schemas - const bundledSchemas = await loadBundledSchemas(context.extensionUri, { - info: (msg) => logOutputChannel.info(msg), - warn: (msg) => logOutputChannel.warn(msg), - error: (msg) => logOutputChannel.error(msg) - }); + // Load bundled schemas and metaschemas + const schemaLogger = { + info: (msg: string) => logOutputChannel.info(msg), + warn: (msg: string) => logOutputChannel.warn(msg), + error: (msg: string) => logOutputChannel.error(msg) + }; + const bundledSchemas = await loadBundledSchemas(context.extensionUri, schemaLogger); + const bundledMetaSchemas = await loadBundledMetaSchemas(context.extensionUri, schemaLogger); const clientOptions: LanguageClientOptions = createClientOptions(logOutputChannel, { bundledSchemas, + bundledMetaSchemas, enableBundledSchemas: areBundledSchemasEnabled() }); let languageClient = new LanguageClient("kson", serverOptions, clientOptions, false) @@ -57,6 +61,11 @@ export async function activate(context: vscode.ExtensionContext) { await languageClient.start(); console.log('Kson Language Server started'); + // Register content provider for bundled:// URIs so users can navigate to bundled schemas + context.subscriptions.push( + registerBundledSchemaContentProvider(context, bundledSchemas, bundledMetaSchemas) + ); + // Create status bar manager const statusBarManager = new StatusBarManager(languageClient); context.subscriptions.push(statusBarManager); diff --git a/tooling/lsp-clients/vscode/src/config/bundledSchemaLoader.ts b/tooling/lsp-clients/vscode/src/config/bundledSchemaLoader.ts index 1b6b01fa..7d8f0110 100644 --- a/tooling/lsp-clients/vscode/src/config/bundledSchemaLoader.ts +++ b/tooling/lsp-clients/vscode/src/config/bundledSchemaLoader.ts @@ -1,18 +1,19 @@ import * as vscode from 'vscode'; import { getLanguageConfiguration, BundledSchemaMapping } from './languageConfig'; +import type { BundledSchemaConfig, BundledMetaSchemaConfig } from 'kson-language-server'; +export type { BundledSchemaConfig, BundledMetaSchemaConfig }; + /** - * Configuration for a bundled schema to be passed to the LSP server. + * Path to the metaschema file relative to the extension root. + * Currently uses JSON Schema Draft 7 as the schema for .schema.kson files. */ -export interface BundledSchemaConfig { - /** The language ID this schema applies to */ - languageId: string; - /** The pre-loaded schema content as a string */ - schemaContent: string; -} +const METASCHEMA_PATH = './dist/extension/schemas/metaschema.draft7.kson'; /** - * Load all bundled schemas defined in the language configuration. + * Load all bundled schemas from the language configuration. + * Does NOT include the metaschema — use {@link loadBundledMetaSchemas} for that. + * * Uses vscode.workspace.fs for cross-platform file access (works in both browser and Node.js). * * @param extensionUri The URI of the extension root @@ -23,20 +24,21 @@ export async function loadBundledSchemas( extensionUri: vscode.Uri, logger?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void } ): Promise { - const { bundledSchemas } = getLanguageConfiguration(); const loadedSchemas: BundledSchemaConfig[] = []; + // Load bundled schemas from language configuration + const { bundledSchemas } = getLanguageConfiguration(); for (const mapping of bundledSchemas) { try { const schemaContent = await loadSchemaFile(extensionUri, mapping, logger); if (schemaContent) { loadedSchemas.push({ - languageId: mapping.languageId, + fileExtension: mapping.fileExtension, schemaContent }); } } catch (error) { - logger?.warn(`Failed to load bundled schema for ${mapping.languageId}: ${error}`); + logger?.warn(`Failed to load bundled schema for ${mapping.fileExtension}: ${error}`); } } @@ -44,6 +46,52 @@ export async function loadBundledSchemas( return loadedSchemas; } +/** + * Load bundled metaschemas. + * Metaschemas are matched by a document's $schema field content rather than by file extension. + * + * @param extensionUri The URI of the extension root + * @param logger Optional logger for debugging + * @returns Array of loaded bundled metaschema configurations + */ +export async function loadBundledMetaSchemas( + extensionUri: vscode.Uri, + logger?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void } +): Promise { + const loadedMetaSchemas: BundledMetaSchemaConfig[] = []; + + const metaschema = await loadMetaSchemaFile(extensionUri, logger); + if (metaschema) { + loadedMetaSchemas.push(metaschema); + } + + logger?.info(`Loaded ${loadedMetaSchemas.length} bundled metaschemas`); + return loadedMetaSchemas; +} + +/** + * Load the metaschema file (JSON Schema Draft 7). + */ +async function loadMetaSchemaFile( + extensionUri: vscode.Uri, + logger?: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void } +): Promise { + try { + const schemaUri = vscode.Uri.joinPath(extensionUri, METASCHEMA_PATH); + const schemaBytes = await vscode.workspace.fs.readFile(schemaUri); + const schemaContent = new TextDecoder().decode(schemaBytes); + logger?.info(`Loaded metaschema from ${METASCHEMA_PATH}`); + return { + schemaId: 'http://json-schema.org/draft-07/schema#', + name: 'draft-07', + schemaContent + }; + } catch (error) { + logger?.warn(`Metaschema not found at ${METASCHEMA_PATH}: ${error}`); + return undefined; + } +} + /** * Load a single schema file from the extension. */ @@ -56,10 +104,10 @@ async function loadSchemaFile( const schemaUri = vscode.Uri.joinPath(extensionUri, mapping.schemaPath); const schemaBytes = await vscode.workspace.fs.readFile(schemaUri); const schemaContent = new TextDecoder().decode(schemaBytes); - logger?.info(`Loaded bundled schema for ${mapping.languageId} from ${mapping.schemaPath}`); + logger?.info(`Loaded bundled schema for .${mapping.fileExtension} from ${mapping.schemaPath}`); return schemaContent; } catch (error) { - logger?.warn(`Schema file not found for ${mapping.languageId}: ${mapping.schemaPath}`); + logger?.warn(`Schema file not found for .${mapping.fileExtension}: ${mapping.schemaPath}`); return undefined; } } diff --git a/tooling/lsp-clients/vscode/src/config/clientOptions.ts b/tooling/lsp-clients/vscode/src/config/clientOptions.ts index 19000f15..addba1ac 100644 --- a/tooling/lsp-clients/vscode/src/config/clientOptions.ts +++ b/tooling/lsp-clients/vscode/src/config/clientOptions.ts @@ -6,14 +6,16 @@ import { DocumentSelector, } from 'vscode-languageclient'; import { getLanguageConfiguration } from './languageConfig'; -import { BundledSchemaConfig } from './bundledSchemaLoader'; +import { BundledSchemaConfig, BundledMetaSchemaConfig } from './bundledSchemaLoader'; /** * Initialization options passed to the LSP server. */ export interface KsonInitializationOptions { - /** Bundled schemas to be loaded */ + /** Bundled schemas to be loaded (matched by file extension) */ bundledSchemas: BundledSchemaConfig[]; + /** Bundled metaschemas to be loaded (matched by document $schema content) */ + bundledMetaSchemas?: BundledMetaSchemaConfig[]; /** Whether bundled schemas are enabled */ enableBundledSchemas: boolean; } @@ -32,7 +34,8 @@ export const createClientOptions = ( // Build document selector for all supported language IDs const documentSelector: DocumentSelector = languageIds.flatMap(languageId => [ { scheme: 'file', language: languageId }, - { scheme: 'untitled', language: languageId } + { scheme: 'untitled', language: languageId }, + { scheme: 'bundled', language: languageId } ]); // Build file watcher pattern for all file extensions diff --git a/tooling/lsp-clients/vscode/test/suite/bundled-schema.test.ts b/tooling/lsp-clients/vscode/test/suite/bundled-schema.test.ts index f6fdec8b..fe8cec2e 100644 --- a/tooling/lsp-clients/vscode/test/suite/bundled-schema.test.ts +++ b/tooling/lsp-clients/vscode/test/suite/bundled-schema.test.ts @@ -1,15 +1,14 @@ import * as vscode from 'vscode'; import { assert } from './assert'; import { createTestFile, cleanUp } from './common'; -import { v4 as uuid } from 'uuid'; /** * Tests for bundled schema support. * * These tests verify that: - * 1. Bundled schemas are loaded from package.json configuration - * 2. Language configuration includes bundled schema mappings - * 3. The enableBundledSchemas setting is respected + * 1. The metaschema (draft7) is loaded for files with $schema field + * 2. The enableBundledSchemas setting is respected + * 3. Navigation to bundled schemas works via the bundled:// content provider */ describe('Bundled Schema Support Tests', () => { /** @@ -48,27 +47,6 @@ describe('Bundled Schema Support Tests', () => { ); }).timeout(5000); - it('Should have bundledSchema field in language contributions', async function () { - const extension = getExtension(); - if (!extension) { - this.skip(); - return; - } - - const packageJson = extension.packageJSON; - const languages = packageJson?.contributes?.languages || []; - - assert.ok(languages.length > 0, 'Should have at least one language defined'); - - // The kson language should have bundledSchema field (even if null) - const ksonLanguage = languages.find((lang: any) => lang.id === 'kson'); - assert.ok(ksonLanguage, 'Should have kson language defined'); - assert.ok( - 'bundledSchema' in ksonLanguage, - 'kson language should have bundledSchema field' - ); - }).timeout(5000); - it('Should be able to read enableBundledSchemas setting', async function () { const config = vscode.workspace.getConfiguration('kson'); const enabled = config.get('enableBundledSchemas'); @@ -80,45 +58,6 @@ describe('Bundled Schema Support Tests', () => { }); describe('Schema Loading', () => { - /** - * Detects if we're running in a Node.js environment (vs browser). - * Some tests may behave differently between environments. - */ - function isNodeEnvironment(): boolean { - return typeof process !== 'undefined' && - process.versions != null && - process.versions.node != null; - } - - it('Should create language configuration with bundledSchemas', async function () { - const extension = getExtension(); - if (!extension) { - this.skip(); - return; - } - - // Import and test the language config module - // This verifies the configuration is being parsed correctly - const packageJson = extension.packageJSON; - const languages = packageJson?.contributes?.languages || []; - - const bundledSchemas = languages - .filter((lang: any) => lang.id && lang.bundledSchema) - .map((lang: any) => ({ - languageId: lang.id, - schemaPath: lang.bundledSchema - })); - - // Log what was found - console.log(`Found ${bundledSchemas.length} bundled schema configurations`); - if (bundledSchemas.length > 0) { - console.log('Bundled schemas:', JSON.stringify(bundledSchemas, null, 2)); - } - - // This is a structural test - we're verifying the config format is correct - assert.ok(Array.isArray(bundledSchemas), 'bundledSchemas should be an array'); - }).timeout(5000); - it('Should handle language with no bundled schema', async function () { const extension = getExtension(); if (!extension) { @@ -150,7 +89,6 @@ describe('Bundled Schema Support Tests', () => { describe('Status Bar Integration', () => { it('Should show status bar for KSON files', async function () { - // Skip in browser environment as status bar may behave differently const extension = getExtension(); if (!extension) { this.skip(); @@ -181,52 +119,162 @@ describe('Bundled Schema Support Tests', () => { }).timeout(10000); }); - describe('Bundled Schema Loader', () => { - it('Should export correct types from bundledSchemaLoader', async function () { - // This test verifies the module structure is correct - // We can't import directly in tests, so we verify through package.json + describe('Settings Changes', () => { + it('Should have correct setting scope', async function () { const extension = getExtension(); if (!extension) { this.skip(); return; } - // Verify the language configuration is properly structured const packageJson = extension.packageJSON; - const languages = packageJson?.contributes?.languages || []; - - for (const lang of languages) { - if (lang.bundledSchema) { - // If bundledSchema is defined, it should be a string path - assert.strictEqual( - typeof lang.bundledSchema, - 'string', - `bundledSchema for ${lang.id} should be a string path` - ); - assert.ok( - lang.bundledSchema.includes('/') || lang.bundledSchema.includes('\\'), - `bundledSchema path for ${lang.id} should be a path` - ); - } - } + const properties = packageJson?.contributes?.configuration?.properties; + const setting = properties?.['kson.enableBundledSchemas']; + + assert.ok(setting, 'Setting should exist'); + assert.ok(setting.description, 'Setting should have a description'); + assert.strictEqual(setting.type, 'boolean', 'Setting should be boolean'); }).timeout(5000); }); - describe('Settings Changes', () => { - it('Should have correct setting scope', async function () { + describe('Bundled Schema Navigation', () => { + it('Should have bundled:// content provider registered', async function () { const extension = getExtension(); if (!extension) { this.skip(); return; } - const packageJson = extension.packageJSON; - const properties = packageJson?.contributes?.configuration?.properties; - const setting = properties?.['kson.enableBundledSchemas']; + // Create a bundled:// URI - even if no schemas exist, the provider should be registered + const bundledUri = vscode.Uri.parse('bundled://schema/test-language'); - assert.ok(setting, 'Setting should exist'); - assert.ok(setting.description, 'Setting should have a description'); - assert.strictEqual(setting.type, 'boolean', 'Setting should be boolean'); + // Try to open the document - this will fail with a specific error if the provider + // is not registered vs if the content just doesn't exist + try { + const doc = await vscode.workspace.openTextDocument(bundledUri); + // If we get here with content, great - there's a schema for this language + // If not, the provider returned undefined which is also valid + assert.ok(true, 'Content provider is registered'); + } catch (error: any) { + // Check if the error is "cannot open" (provider not found) vs content not available + const message = error?.message || String(error); + + // If the provider is registered but returns undefined, VS Code may throw + // "cannot open bundled://schema/test-extension.schema.kson" but NOT "Unable to resolve" + // The "Unable to resolve resource" error indicates no provider is registered + if (message.includes('Unable to resolve resource')) { + assert.fail('bundled:// content provider is not registered'); + } + // Other errors (like empty content) are acceptable + assert.ok(true, 'Content provider is registered (returned no content for test extension)'); + } }).timeout(5000); + + it('Should be able to open metaschema via bundled URI', async function () { + const extension = getExtension(); + if (!extension) { + this.skip(); + return; + } + + // The metaschema uses the metaschema authority + const bundledUri = vscode.Uri.parse('bundled://metaschema/draft-07.schema.kson'); + + try { + const doc = await vscode.workspace.openTextDocument(bundledUri); + assert.ok(doc, 'Should be able to open metaschema document'); + assert.ok(doc.getText().length > 0, 'Metaschema should have content'); + console.log(`Successfully opened metaschema, content length: ${doc.getText().length}`); + } catch (error: any) { + const message = error?.message || String(error); + if (message.includes('Unable to resolve resource')) { + assert.fail('bundled:// content provider failed to resolve metaschema'); + } + throw error; + } + }).timeout(10000); + + it('Should navigate to metaschema via Go to Definition on file with $schema', async function () { + const extension = getExtension(); + if (!extension) { + this.skip(); + return; + } + + // Create a .schema.kson test file with $schema declaration + const content = "'$schema': 'http://json-schema.org/draft-07/schema#'\ntype: object"; + const fileName = 'definition-test.schema.kson'; + const [testFileUri, document] = await createTestFile(content, fileName); + + try { + // Verify the document is recognized as kson + assert.strictEqual( + document.languageId, + 'kson', + 'Document should have language ID \'kson\'' + ); + + // Wait for the language server to process the document and associate the schema + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Position the cursor on the "type" property (line 1) + const position = new vscode.Position(1, 1); + + // Execute Go to Definition command + const definitions = await vscode.commands.executeCommand< + vscode.Location[] | vscode.LocationLink[] + >( + 'vscode.executeDefinitionProvider', + document.uri, + position + ); + + // Log results for debugging + console.log('Definition results for file with $schema:', JSON.stringify(definitions, null, 2)); + + // Verify definitions were returned + if (!definitions || definitions.length === 0) { + console.log('No definitions returned - schema may not be associated yet'); + // This isn't necessarily a failure - the schema association might not be complete + // The important thing is that when definitions ARE returned, they work + return; + } + + // Get the definition URI (handle both Location and LocationLink types) + const firstDef = definitions[0]; + const definitionUri = 'uri' in firstDef + ? firstDef.uri + : (firstDef as vscode.LocationLink).targetUri; + + console.log(`Definition URI: ${definitionUri.toString()}`); + + // Verify the definition points to a bundled:// URI + assert.ok( + definitionUri.scheme === 'bundled', + `Definition should point to bundled:// scheme, got: ${definitionUri.scheme}` + ); + + assert.ok( + definitionUri.toString().includes('bundled://metaschema/draft-07.schema.kson'), + `Definition should point to metaschema, got: ${definitionUri.toString()}` + ); + + // Verify we can actually open the bundled schema document + const schemaDoc = await vscode.workspace.openTextDocument(definitionUri); + assert.ok(schemaDoc, 'Should be able to open the metaschema document'); + assert.ok(schemaDoc.getText().length > 0, 'Metaschema document should have content'); + + // Verify the metaschema contains the "type" property definition + const schemaContent = schemaDoc.getText(); + assert.ok( + schemaContent.includes('type'), + 'Metaschema should contain the "type" property definition' + ); + + console.log(`Successfully navigated to metaschema, content length: ${schemaContent.length}`); + } finally { + await cleanUp(testFileUri); + } + }).timeout(15000); }); }); diff --git a/tooling/lsp-clients/vscode/test/suite/status-bar-schema-association.test.ts b/tooling/lsp-clients/vscode/test/suite/status-bar-schema-association.test.ts index 32519472..c3c187dc 100644 --- a/tooling/lsp-clients/vscode/test/suite/status-bar-schema-association.test.ts +++ b/tooling/lsp-clients/vscode/test/suite/status-bar-schema-association.test.ts @@ -39,7 +39,6 @@ describeNode('Status Bar Schema Association Tests', () => { // Create a schema file schemaFileUri = vscode.Uri.joinPath(workspaceFolder.uri, TEST_SCHEMA_FILENAME); const schemaContent = [ - '\'$schema\': \'http://json-schema.org/draft-07/schema#\'', 'type: object', 'title: \'Test Configuration\'', 'description: \'Schema for testing status bar association\'', From 8a77765c15ae58b4a43742052cec0e839cc9cda8 Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Wed, 4 Feb 2026 12:42:12 +0100 Subject: [PATCH 04/18] refactor: use file extension for bundled schema matching Change bundled schema URIs from bundled://schema/{languageId} to bundled://schema/{languageId}.schema.kson. This enables VS Code to recognize bundled schemas as KSON files and apply syntax highlighting when navigating to definitions. - Match schemas by file extension instead of languageId - Deduplicate BundledSchemaConfig interface across packages --- .../src/core/schema/CompositeSchemaProvider.ts | 5 ++--- .../src/core/schema/FileSystemSchemaProvider.ts | 3 +-- .../src/core/schema/SchemaProvider.ts | 5 ++--- .../vscode/src/client/common/StatusBarManager.ts | 6 ++++-- .../lsp-clients/vscode/src/config/languageConfig.ts | 12 ++++++------ .../vscode/test/suite/language-config.test.ts | 8 ++++---- tooling/lsp-clients/vscode/test/test-files.json | 3 ++- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/tooling/language-server-protocol/src/core/schema/CompositeSchemaProvider.ts b/tooling/language-server-protocol/src/core/schema/CompositeSchemaProvider.ts index 5d6f9e44..fcccd675 100644 --- a/tooling/language-server-protocol/src/core/schema/CompositeSchemaProvider.ts +++ b/tooling/language-server-protocol/src/core/schema/CompositeSchemaProvider.ts @@ -35,12 +35,11 @@ export class CompositeSchemaProvider implements SchemaProvider { * Returns the first non-undefined result. * * @param documentUri The URI of the KSON document - * @param languageId Optional language ID for bundled schema lookup * @returns TextDocument containing the schema, or undefined if no provider has a schema */ - getSchemaForDocument(documentUri: DocumentUri, languageId?: string): TextDocument | undefined { + getSchemaForDocument(documentUri: DocumentUri): TextDocument | undefined { for (const provider of this.providers) { - const schema = provider.getSchemaForDocument(documentUri, languageId); + const schema = provider.getSchemaForDocument(documentUri); if (schema) { return schema; } diff --git a/tooling/language-server-protocol/src/core/schema/FileSystemSchemaProvider.ts b/tooling/language-server-protocol/src/core/schema/FileSystemSchemaProvider.ts index c000a0a6..d7ee39a8 100644 --- a/tooling/language-server-protocol/src/core/schema/FileSystemSchemaProvider.ts +++ b/tooling/language-server-protocol/src/core/schema/FileSystemSchemaProvider.ts @@ -71,10 +71,9 @@ export class FileSystemSchemaProvider implements SchemaProvider { * Get the schema for a given document URI. * * @param documentUri The URI of the KSON document - * @param _languageId Optional language ID (unused by file system provider) * @returns TextDocument containing the schema, or undefined if no schema is configured */ - getSchemaForDocument(documentUri: DocumentUri, _languageId?: string): TextDocument | undefined { + getSchemaForDocument(documentUri: DocumentUri): TextDocument | undefined { if (!this.config || !this.workspaceRoot) { return undefined; } diff --git a/tooling/language-server-protocol/src/core/schema/SchemaProvider.ts b/tooling/language-server-protocol/src/core/schema/SchemaProvider.ts index f0fcc8a8..228a901e 100644 --- a/tooling/language-server-protocol/src/core/schema/SchemaProvider.ts +++ b/tooling/language-server-protocol/src/core/schema/SchemaProvider.ts @@ -10,10 +10,9 @@ export interface SchemaProvider { * Get the schema document for a given KSON document URI. * * @param documentUri The URI of the KSON document - * @param languageId Optional language ID for bundled schema lookup * @returns TextDocument containing the schema, or undefined if no schema is available */ - getSchemaForDocument(documentUri: DocumentUri, languageId?: string): TextDocument | undefined; + getSchemaForDocument(documentUri: DocumentUri): TextDocument | undefined; /** * Reload the schema configuration. @@ -44,7 +43,7 @@ export interface SchemaProvider { * Used as a fallback when no schema configuration is available. */ export class NoOpSchemaProvider implements SchemaProvider { - getSchemaForDocument(_documentUri: DocumentUri, _languageId?: string): TextDocument | undefined { + getSchemaForDocument(_documentUri: DocumentUri): TextDocument | undefined { return undefined; } diff --git a/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts b/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts index 515cc9e7..57b26942 100644 --- a/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts +++ b/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts @@ -93,11 +93,13 @@ export class StatusBarManager { /** * Extract a display name from a bundled schema URI. - * Bundled schema URIs have the format: bundled://schema/{languageId} + * Bundled schema URIs have the format: bundled://schema/{fileExtension}.schema.kson */ private extractBundledSchemaName(schemaPath: string): string { if (schemaPath.startsWith('bundled://schema/')) { - return schemaPath.replace('bundled://schema/', ''); + return schemaPath + .replace('bundled://schema/', '') + .replace(/\.schema\.kson$/, ''); } return path.basename(schemaPath); } diff --git a/tooling/lsp-clients/vscode/src/config/languageConfig.ts b/tooling/lsp-clients/vscode/src/config/languageConfig.ts index 64699dfe..c091435f 100644 --- a/tooling/lsp-clients/vscode/src/config/languageConfig.ts +++ b/tooling/lsp-clients/vscode/src/config/languageConfig.ts @@ -1,9 +1,9 @@ /** - * Configuration for bundled schemas mapped by language ID. + * Configuration for bundled schemas mapped by file extension. */ export interface BundledSchemaMapping { - /** Language ID this schema applies to */ - languageId: string; + /** File extension this schema applies to (without leading dot) */ + fileExtension: string; /** Relative path to the bundled schema file (from extension root) */ schemaPath: string; } @@ -41,11 +41,11 @@ export function isKsonDialect(languageId: string): boolean { export function initializeLanguageConfig(packageJson: any): void { const languages = packageJson?.contributes?.languages || []; - // Extract bundled schema mappings + // Extract bundled schema mappings using file extension from lang.extensions[0] const bundledSchemas: BundledSchemaMapping[] = languages - .filter((lang: any) => lang.id && lang.bundledSchema) + .filter((lang: any) => lang.extensions?.[0] && lang.bundledSchema) .map((lang: any) => ({ - languageId: lang.id, + fileExtension: lang.extensions[0].replace(/^\./, ''), schemaPath: lang.bundledSchema })); diff --git a/tooling/lsp-clients/vscode/test/suite/language-config.test.ts b/tooling/lsp-clients/vscode/test/suite/language-config.test.ts index 3aa31afd..36216a2d 100644 --- a/tooling/lsp-clients/vscode/test/suite/language-config.test.ts +++ b/tooling/lsp-clients/vscode/test/suite/language-config.test.ts @@ -71,7 +71,7 @@ describe('Language Configuration Tests', () => { }); describe('bundledSchemas', () => { - it('Should extract bundled schema mappings', () => { + it('Should extract bundled schema mappings using file extension', () => { initWithLanguages([ { id: 'kson', extensions: ['.kson'], bundledSchema: null }, { id: 'kxt', extensions: ['.kxt'], bundledSchema: './dist/extension/schemas/kxt.schema.kson' } @@ -80,7 +80,7 @@ describe('Language Configuration Tests', () => { assert.ok(config.bundledSchemas, 'bundledSchemas should be defined'); assert.strictEqual(config.bundledSchemas.length, 1, 'Should have 1 bundled schema'); - assert.strictEqual(config.bundledSchemas[0].languageId, 'kxt'); + assert.strictEqual(config.bundledSchemas[0].fileExtension, 'kxt'); assert.strictEqual(config.bundledSchemas[0].schemaPath, './dist/extension/schemas/kxt.schema.kson'); }); @@ -113,8 +113,8 @@ describe('Language Configuration Tests', () => { const config = getLanguageConfiguration(); assert.strictEqual(config.bundledSchemas.length, 2, 'Should have 2 bundled schemas'); - assert.ok(config.bundledSchemas.some(s => s.languageId === 'kxt')); - assert.ok(config.bundledSchemas.some(s => s.languageId === 'config')); + assert.ok(config.bundledSchemas.some(s => s.fileExtension === 'kxt')); + assert.ok(config.bundledSchemas.some(s => s.fileExtension === 'config')); }); }); }); diff --git a/tooling/lsp-clients/vscode/test/test-files.json b/tooling/lsp-clients/vscode/test/test-files.json index f2017d4f..3f2df41b 100644 --- a/tooling/lsp-clients/vscode/test/test-files.json +++ b/tooling/lsp-clients/vscode/test/test-files.json @@ -7,6 +7,7 @@ "formatting-settings.test", "editing.test", "dialect-support.test", - "diagnostics.test" + "diagnostics.test", + "bundled-schema.test" ] } \ No newline at end of file From 468c3377449693d38833c939886d50766af6f0e9 Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Wed, 4 Feb 2026 16:22:22 +0100 Subject: [PATCH 05/18] fix: remove deactivate function The deactivate() function was being called immediately at module load instead of being exported for VS Code to call during extension deactivation. This caused "command already exists" errors when files with bundled schemas were opened, as the language client tried to re-register commands without the previous client being properly cleaned up. By adding it to the subscriptions this deactivation is handled by VSCode --- .../vscode/src/client/browser/ksonClientMain.ts | 12 +++++------- .../vscode/src/client/common/deactivate.ts | 13 ------------- .../vscode/src/client/node/ksonClientMain.ts | 12 +++++------- 3 files changed, 10 insertions(+), 27 deletions(-) delete mode 100644 tooling/lsp-clients/vscode/src/client/common/deactivate.ts diff --git a/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts b/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts index cb96c6df..a6b47e94 100644 --- a/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts +++ b/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts @@ -4,7 +4,6 @@ import { createClientOptions } from '../../config/clientOptions'; import { initializeLanguageConfig } from '../../config/languageConfig'; import { loadBundledSchemas, loadBundledMetaSchemas, areBundledSchemasEnabled } from '../../config/bundledSchemaLoader'; import { registerBundledSchemaContentProvider } from '../common/BundledSchemaContentProvider'; -import {deactivate} from '../common/deactivate'; /** * Browser-specific activation function for the KSON extension. @@ -53,7 +52,7 @@ export async function activate(context: vscode.ExtensionContext) { ]; } - let languageClient = new LanguageClient( + const languageClient = new LanguageClient( 'kson-browser', 'KSON Language Server (Browser)', clientOptions, @@ -63,6 +62,9 @@ export async function activate(context: vscode.ExtensionContext) { // Start the client and language server await languageClient.start(); + // Add the client to subscriptions so it gets disposed on deactivation + context.subscriptions.push(languageClient); + // Register content provider for bundled:// URIs so users can navigate to bundled schemas context.subscriptions.push( registerBundledSchemaContentProvider(context, bundledSchemas, bundledMetaSchemas) @@ -81,8 +83,4 @@ export async function activate(context: vscode.ExtensionContext) { logOutputChannel.error(`Failed to activate KSON Browser extension: ${message}`); vscode.window.showErrorMessage('Failed to activate KSON language support.'); } -} - -deactivate().catch(error => { - console.error('Deactivation failed:', error); -}); \ No newline at end of file +} \ No newline at end of file diff --git a/tooling/lsp-clients/vscode/src/client/common/deactivate.ts b/tooling/lsp-clients/vscode/src/client/common/deactivate.ts deleted file mode 100644 index 2acee4b2..00000000 --- a/tooling/lsp-clients/vscode/src/client/common/deactivate.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {BaseLanguageClient} from "vscode-languageclient"; - -export let languageClient: BaseLanguageClient; - -/** - * Deactivation function for language client. - */ -export async function deactivate(): Promise { - if (languageClient) { - await languageClient.stop(); - languageClient = undefined; - } -} \ No newline at end of file diff --git a/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts b/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts index 1d7b5957..c225deeb 100644 --- a/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts +++ b/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts @@ -1,6 +1,5 @@ import * as vscode from 'vscode'; import * as path from 'path'; -import {deactivate} from '../common/deactivate'; import { ServerOptions, TransportKind, @@ -56,9 +55,12 @@ export async function activate(context: vscode.ExtensionContext) { bundledMetaSchemas, enableBundledSchemas: areBundledSchemasEnabled() }); - let languageClient = new LanguageClient("kson", serverOptions, clientOptions, false) + const languageClient = new LanguageClient("kson", serverOptions, clientOptions, false) await languageClient.start(); + + // Add the client to subscriptions so it gets disposed on deactivation + context.subscriptions.push(languageClient); console.log('Kson Language Server started'); // Register content provider for bundled:// URIs so users can navigate to bundled schemas @@ -202,8 +204,4 @@ export async function activate(context: vscode.ExtensionContext) { logOutputChannel.error(`Failed to activate KSON Node.js extension: ${message}`); vscode.window.showErrorMessage('Failed to activate KSON language support.'); } -} - -deactivate().catch(error => { - console.error('Deactivation failed:', error); -}); \ No newline at end of file +} \ No newline at end of file From 45baf22351170f673ceef063887a4d9463a22a99 Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Mon, 9 Feb 2026 16:59:37 +0100 Subject: [PATCH 06/18] fix: avoid duplicate diagnostics when schema is present Schema validation already includes parse errors, so use it exclusively when available instead of combining both sources. --- .../src/core/features/DiagnosticService.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tooling/language-server-protocol/src/core/features/DiagnosticService.ts b/tooling/language-server-protocol/src/core/features/DiagnosticService.ts index d3711285..0af63d50 100644 --- a/tooling/language-server-protocol/src/core/features/DiagnosticService.ts +++ b/tooling/language-server-protocol/src/core/features/DiagnosticService.ts @@ -22,17 +22,21 @@ export class DiagnosticService { } private getDiagnostics(document: KsonDocument): Diagnostic[] { - const schema = document.getSchemaDocument() + // Schema validation already includes parse errors, so use it exclusively when + // available to avoid duplicate diagnostics. + const messages = this.getSchemaValidationMessages(document) + ?? document.getAnalysisResult().errors.asJsReadonlyArrayView(); + return this.loggedMessagesToDiagnostics(messages); + } - const schemaMessages = schema ? (() => { - const parsedSchema = Kson.getInstance().parseSchema(schema.getText()) - if(parsedSchema instanceof SchemaResult.Success){ - return parsedSchema.schemaValidator.validate(document.getText(), document.uri).asJsReadonlyArrayView() - } - return [] - })() : [] - const documentMessages = document.getAnalysisResult().errors.asJsReadonlyArrayView() - return this.loggedMessagesToDiagnostics([...schemaMessages, ...documentMessages]); + private getSchemaValidationMessages(document: KsonDocument): readonly Message[] | null { + const schema = document.getSchemaDocument(); + if (!schema) return null; + + const parsedSchema = Kson.getInstance().parseSchema(schema.getText()); + if (!(parsedSchema instanceof SchemaResult.Success)) return null; + + return parsedSchema.schemaValidator.validate(document.getText(), document.uri).asJsReadonlyArrayView(); } /** From 43ca55ad5364f0acf3f38ac3b1b2d225f62ce93e Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Tue, 10 Feb 2026 16:46:27 +0100 Subject: [PATCH 07/18] refactor: export KsonInitialization --- tooling/language-server-protocol/src/index.ts | 1 + .../src/startKsonServer.ts | 2 +- .../lsp-clients/vscode/src/config/clientOptions.ts | 14 +------------- 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/tooling/language-server-protocol/src/index.ts b/tooling/language-server-protocol/src/index.ts index 5e0619a5..3a47a1ff 100644 --- a/tooling/language-server-protocol/src/index.ts +++ b/tooling/language-server-protocol/src/index.ts @@ -1,2 +1,3 @@ // Common exports for kson-language-server package export type { BundledSchemaConfig, BundledMetaSchemaConfig } from './core/schema/BundledSchemaProvider.js'; +export type { KsonInitializationOptions } from './startKsonServer.js'; diff --git a/tooling/language-server-protocol/src/startKsonServer.ts b/tooling/language-server-protocol/src/startKsonServer.ts index 43a3c413..37a9521c 100644 --- a/tooling/language-server-protocol/src/startKsonServer.ts +++ b/tooling/language-server-protocol/src/startKsonServer.ts @@ -20,7 +20,7 @@ import {CommandExecutorFactory} from "./core/commands/CommandExecutorFactory"; /** * Initialization options passed from the VSCode client. */ -interface KsonInitializationOptions { +export interface KsonInitializationOptions { /** Bundled schemas to be loaded (matched by file extension) */ bundledSchemas?: BundledSchemaConfig[]; /** Bundled metaschemas to be loaded (matched by document $schema content) */ diff --git a/tooling/lsp-clients/vscode/src/config/clientOptions.ts b/tooling/lsp-clients/vscode/src/config/clientOptions.ts index addba1ac..9e79f717 100644 --- a/tooling/lsp-clients/vscode/src/config/clientOptions.ts +++ b/tooling/lsp-clients/vscode/src/config/clientOptions.ts @@ -6,19 +6,7 @@ import { DocumentSelector, } from 'vscode-languageclient'; import { getLanguageConfiguration } from './languageConfig'; -import { BundledSchemaConfig, BundledMetaSchemaConfig } from './bundledSchemaLoader'; - -/** - * Initialization options passed to the LSP server. - */ -export interface KsonInitializationOptions { - /** Bundled schemas to be loaded (matched by file extension) */ - bundledSchemas: BundledSchemaConfig[]; - /** Bundled metaschemas to be loaded (matched by document $schema content) */ - bundledMetaSchemas?: BundledMetaSchemaConfig[]; - /** Whether bundled schemas are enabled */ - enableBundledSchemas: boolean; -} +import type { KsonInitializationOptions } from 'kson-language-server'; /** * Create shared client options. From c91e14ec05bdb96888d5a6c1b66d300307fbcf85 Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Tue, 10 Feb 2026 17:10:15 +0100 Subject: [PATCH 08/18] refactor: extract schema resolution helper and remove unused parameter Extract duplicated schema resolution logic in KsonDocumentsManager into a resolveSchema() helper. Remove unused context parameter from registerBundledSchemaContentProvider. --- .../src/core/document/KsonDocumentsManager.ts | 54 +++++++------------ .../src/client/browser/ksonClientMain.ts | 2 +- .../common/BundledSchemaContentProvider.ts | 2 - .../vscode/src/client/node/ksonClientMain.ts | 2 +- 4 files changed, 20 insertions(+), 40 deletions(-) diff --git a/tooling/language-server-protocol/src/core/document/KsonDocumentsManager.ts b/tooling/language-server-protocol/src/core/document/KsonDocumentsManager.ts index 97a3c8b7..bd96f12d 100644 --- a/tooling/language-server-protocol/src/core/document/KsonDocumentsManager.ts +++ b/tooling/language-server-protocol/src/core/document/KsonDocumentsManager.ts @@ -23,6 +23,20 @@ function extractSchemaId(analysis: Analysis): string | undefined { return (schemaValue as KsonValue.KsonString).value; } +/** + * Resolve a schema for a document by trying URI-based resolution first, + * then falling back to content-based metaschema resolution via $schema. + */ +function resolveSchema(provider: SchemaProvider, uri: DocumentUri, analysis: Analysis): TextDocument | undefined { + const schema = provider.getSchemaForDocument(uri); + if (schema) return schema; + + const schemaId = extractSchemaId(analysis); + if (schemaId) return provider.getMetaSchemaForId(schemaId); + + return undefined; +} + /** * Document management for the Kson Language Server. * The {@link KsonDocumentsManager} keeps track of all {@link KsonDocument}'s that @@ -44,20 +58,8 @@ export class KsonDocumentsManager extends TextDocuments { content: string ): KsonDocument => { const textDocument = TextDocument.create(uri, languageId, version, content); - - // Try to get schema from provider based on file extension/URI - let schemaDocument = provider.getSchemaForDocument(uri); - const parseResult = Kson.getInstance().analyze(content, uri); - - // If no schema from URI-based resolution, try content-based metaschema resolution - if (!schemaDocument) { - const schemaId = extractSchemaId(parseResult); - if (schemaId) { - schemaDocument = provider.getMetaSchemaForId(schemaId); - } - } - + const schemaDocument = resolveSchema(provider, uri, parseResult); return new KsonDocument(textDocument, parseResult, schemaDocument); }, update: ( @@ -71,21 +73,8 @@ export class KsonDocumentsManager extends TextDocuments { version ); const parseResult = Kson.getInstance().analyze(textDocument.getText(), ksonDocument.uri); - - // Try URI-based resolution first, then content-based metaschema resolution - let schemaDocument = provider.getSchemaForDocument(ksonDocument.uri); - if (!schemaDocument) { - const schemaId = extractSchemaId(parseResult); - if (schemaId) { - schemaDocument = provider.getMetaSchemaForId(schemaId); - } - } - - return new KsonDocument( - textDocument, - parseResult, - schemaDocument - ); + const schemaDocument = resolveSchema(provider, ksonDocument.uri, parseResult); + return new KsonDocument(textDocument, parseResult, schemaDocument); } }); @@ -122,14 +111,7 @@ export class KsonDocumentsManager extends TextDocuments { const textDocument = doc.textDocument; const parseResult = doc.getAnalysisResult(); - // Try URI-based resolution first, then content-based metaschema resolution - let updatedSchema = this.schemaProvider.getSchemaForDocument(doc.uri); - if (!updatedSchema) { - const schemaId = extractSchemaId(parseResult); - if (schemaId) { - updatedSchema = this.schemaProvider.getMetaSchemaForId(schemaId); - } - } + const updatedSchema = resolveSchema(this.schemaProvider, doc.uri, parseResult); // Create new document instance with updated schema const updatedDoc = new KsonDocument(textDocument, parseResult, updatedSchema); diff --git a/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts b/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts index a6b47e94..2f2a86cc 100644 --- a/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts +++ b/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts @@ -67,7 +67,7 @@ export async function activate(context: vscode.ExtensionContext) { // Register content provider for bundled:// URIs so users can navigate to bundled schemas context.subscriptions.push( - registerBundledSchemaContentProvider(context, bundledSchemas, bundledMetaSchemas) + registerBundledSchemaContentProvider(bundledSchemas, bundledMetaSchemas) ); logOutputChannel.info('KSON Browser extension activated successfully'); diff --git a/tooling/lsp-clients/vscode/src/client/common/BundledSchemaContentProvider.ts b/tooling/lsp-clients/vscode/src/client/common/BundledSchemaContentProvider.ts index c10d1a5c..2dda1650 100644 --- a/tooling/lsp-clients/vscode/src/client/common/BundledSchemaContentProvider.ts +++ b/tooling/lsp-clients/vscode/src/client/common/BundledSchemaContentProvider.ts @@ -57,13 +57,11 @@ export class BundledSchemaContentProvider implements vscode.TextDocumentContentP /** * Register the bundled schema content provider. * - * @param context Extension context for disposable registration * @param bundledSchemas The loaded bundled schemas * @param bundledMetaSchemas The loaded bundled metaschemas * @returns The registered disposable */ export function registerBundledSchemaContentProvider( - context: vscode.ExtensionContext, bundledSchemas: BundledSchemaConfig[], bundledMetaSchemas: BundledMetaSchemaConfig[] = [] ): vscode.Disposable { diff --git a/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts b/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts index c225deeb..9eabaaf0 100644 --- a/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts +++ b/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts @@ -65,7 +65,7 @@ export async function activate(context: vscode.ExtensionContext) { // Register content provider for bundled:// URIs so users can navigate to bundled schemas context.subscriptions.push( - registerBundledSchemaContentProvider(context, bundledSchemas, bundledMetaSchemas) + registerBundledSchemaContentProvider(bundledSchemas, bundledMetaSchemas) ); // Create status bar manager From 47e10f826d8239a3a6d3db654b18a8e89f3eeb27 Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Wed, 11 Feb 2026 15:59:21 +0100 Subject: [PATCH 09/18] add node to pixi for the `npmInstallProductionLibrary` task --- kson-lib/pixi.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/kson-lib/pixi.toml b/kson-lib/pixi.toml index 0bf3f120..854a71d7 100644 --- a/kson-lib/pixi.toml +++ b/kson-lib/pixi.toml @@ -15,3 +15,6 @@ version = "0.1.0" [target.win-64.dependencies] vs2022_win-64 = ">=19.44.35207,<20" + +[dependencies] +nodejs = ">=18" \ No newline at end of file From 78396a2d530fbfb2bc59b2493d873ef841502776 Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Wed, 11 Feb 2026 16:44:19 +0100 Subject: [PATCH 10/18] refactor: introduce KsonSchemaDocument Add KsonSchemaDocument extending KsonDocument to encode the domain rule that only schema files get metaschema resolution. Move extractSchemaId into KsonDocument, replace resolveSchema with type-aware resolveDocument, and update all services to explicitly check document type at each call site. --- .../src/core/document/KsonDocument.ts | 30 +++- .../src/core/document/KsonDocumentsManager.ts | 59 ++++---- .../src/core/document/KsonSchemaDocument.ts | 33 +++++ .../src/core/features/DefinitionService.ts | 5 +- .../src/core/features/DiagnosticService.ts | 5 +- .../src/core/features/HoverService.ts | 5 +- .../src/core/schema/SchemaProvider.ts | 26 ++-- .../src/startKsonServer.ts | 6 +- .../core/document/KsonSchemaDocument.test.ts | 138 ++++++++++++++++++ .../core/features/DefinitionService.test.ts | 11 +- .../schema/CompositeSchemaProvider.test.ts | 9 +- 11 files changed, 266 insertions(+), 61 deletions(-) create mode 100644 tooling/language-server-protocol/src/core/document/KsonSchemaDocument.ts create mode 100644 tooling/language-server-protocol/src/test/core/document/KsonSchemaDocument.test.ts diff --git a/tooling/language-server-protocol/src/core/document/KsonDocument.ts b/tooling/language-server-protocol/src/core/document/KsonDocument.ts index be6e2c2b..dde43671 100644 --- a/tooling/language-server-protocol/src/core/document/KsonDocument.ts +++ b/tooling/language-server-protocol/src/core/document/KsonDocument.ts @@ -1,4 +1,4 @@ -import {Analysis} from 'kson'; +import {Analysis, KsonValue, KsonValueType} from 'kson'; import {DocumentUri, TextDocuments, Range, Position} from "vscode-languageserver"; import {TextDocument} from "vscode-languageserver-textdocument"; import {IndexedDocumentSymbols} from "../features/IndexedDocumentSymbols"; @@ -93,4 +93,32 @@ export class KsonDocument implements TextDocument { getSchemaDocument(): TextDocument | undefined { return this.schemaDocument; } + + /** + * Extract the $schema field value from this document's parse analysis. + * + * @returns The $schema string value, or undefined if not present or not a string + */ + getSchemaId(): string | undefined { + return KsonDocument.extractSchemaId(this.parseAnalysis); + } + + /** + * Extract the $schema field value from a parsed KSON analysis result. + * + * @param analysis The KSON analysis result + * @returns The $schema string value, or undefined if not present or not a string + */ + static extractSchemaId(analysis: Analysis): string | undefined { + const ksonValue = analysis.ksonValue; + if (!ksonValue || ksonValue.type !== KsonValueType.OBJECT) { + return undefined; + } + const obj = ksonValue as KsonValue.KsonObject; + const schemaValue = obj.properties.asJsReadonlyMapView().get('$schema'); + if (!schemaValue || schemaValue.type !== KsonValueType.STRING) { + return undefined; + } + return (schemaValue as KsonValue.KsonString).value; + } } \ No newline at end of file diff --git a/tooling/language-server-protocol/src/core/document/KsonDocumentsManager.ts b/tooling/language-server-protocol/src/core/document/KsonDocumentsManager.ts index bd96f12d..571dfc63 100644 --- a/tooling/language-server-protocol/src/core/document/KsonDocumentsManager.ts +++ b/tooling/language-server-protocol/src/core/document/KsonDocumentsManager.ts @@ -1,40 +1,36 @@ import {TextDocument} from 'vscode-languageserver-textdocument'; -import {Analysis, Kson, KsonValue, KsonValueType} from 'kson'; +import {Kson} from 'kson'; import {KsonDocument} from "./KsonDocument.js"; +import {KsonSchemaDocument} from "./KsonSchemaDocument.js"; import {DocumentUri, TextDocuments, TextDocumentContentChangeEvent} from "vscode-languageserver"; import {SchemaProvider, NoOpSchemaProvider} from "../schema/SchemaProvider.js"; /** - * Extract the $schema field value from a parsed KSON analysis result. + * Resolve the appropriate KsonDocument type for a given document. * - * @param analysis The KSON analysis result - * @returns The $schema string value, or undefined if not present or not a string + * First tries URI-based schema resolution. If that fails, tries content-based + * metaschema resolution via $schema — but only returns a KsonSchemaDocument + * in that case, encoding the domain rule that only schema files have metaschemas. */ -function extractSchemaId(analysis: Analysis): string | undefined { - const ksonValue = analysis.ksonValue; - if (!ksonValue || ksonValue.type !== KsonValueType.OBJECT) { - return undefined; +function resolveDocument( + provider: SchemaProvider, + textDocument: TextDocument, + parseResult: ReturnType['analyze']> +): KsonDocument { + const schema = provider.getSchemaForDocument(textDocument.uri); + if (schema) { + return new KsonDocument(textDocument, parseResult, schema); } - const obj = ksonValue as KsonValue.KsonObject; - const schemaValue = obj.properties.asJsReadonlyMapView().get('$schema'); - if (!schemaValue || schemaValue.type !== KsonValueType.STRING) { - return undefined; - } - return (schemaValue as KsonValue.KsonString).value; -} - -/** - * Resolve a schema for a document by trying URI-based resolution first, - * then falling back to content-based metaschema resolution via $schema. - */ -function resolveSchema(provider: SchemaProvider, uri: DocumentUri, analysis: Analysis): TextDocument | undefined { - const schema = provider.getSchemaForDocument(uri); - if (schema) return schema; - const schemaId = extractSchemaId(analysis); - if (schemaId) return provider.getMetaSchemaForId(schemaId); + const schemaId = KsonDocument.extractSchemaId(parseResult); + if (schemaId) { + const metaSchema = provider.getMetaSchemaForId(schemaId); + if (metaSchema) { + return new KsonSchemaDocument(textDocument, parseResult, metaSchema); + } + } - return undefined; + return new KsonDocument(textDocument, parseResult); } /** @@ -59,8 +55,7 @@ export class KsonDocumentsManager extends TextDocuments { ): KsonDocument => { const textDocument = TextDocument.create(uri, languageId, version, content); const parseResult = Kson.getInstance().analyze(content, uri); - const schemaDocument = resolveSchema(provider, uri, parseResult); - return new KsonDocument(textDocument, parseResult, schemaDocument); + return resolveDocument(provider, textDocument, parseResult); }, update: ( ksonDocument: KsonDocument, @@ -73,8 +68,7 @@ export class KsonDocumentsManager extends TextDocuments { version ); const parseResult = Kson.getInstance().analyze(textDocument.getText(), ksonDocument.uri); - const schemaDocument = resolveSchema(provider, ksonDocument.uri, parseResult); - return new KsonDocument(textDocument, parseResult, schemaDocument); + return resolveDocument(provider, textDocument, parseResult); } }); @@ -111,10 +105,7 @@ export class KsonDocumentsManager extends TextDocuments { const textDocument = doc.textDocument; const parseResult = doc.getAnalysisResult(); - const updatedSchema = resolveSchema(this.schemaProvider, doc.uri, parseResult); - - // Create new document instance with updated schema - const updatedDoc = new KsonDocument(textDocument, parseResult, updatedSchema); + const updatedDoc = resolveDocument(this.schemaProvider, textDocument, parseResult); // Replace in the internal document cache // Access the protected _syncedDocuments property from parent class diff --git a/tooling/language-server-protocol/src/core/document/KsonSchemaDocument.ts b/tooling/language-server-protocol/src/core/document/KsonSchemaDocument.ts new file mode 100644 index 00000000..cbbde6ae --- /dev/null +++ b/tooling/language-server-protocol/src/core/document/KsonSchemaDocument.ts @@ -0,0 +1,33 @@ +import {Analysis} from 'kson'; +import {TextDocument} from 'vscode-languageserver-textdocument'; +import {KsonDocument} from './KsonDocument.js'; + +/** + * A KsonDocument that represents a schema file. + * + * Schema documents have a metaschema (resolved via their $schema field) + * rather than a regular schema association. This distinction prevents + * metaschema resolution from being attempted on regular KSON documents. + */ +export class KsonSchemaDocument extends KsonDocument { + private metaSchemaDocument?: TextDocument; + + constructor(textDocument: TextDocument, parseAnalysis: Analysis, metaSchemaDocument?: TextDocument) { + super(textDocument, parseAnalysis); + this.metaSchemaDocument = metaSchemaDocument; + } + + /** + * Get the metaschema document for this schema document, if one is configured. + */ + getMetaSchemaDocument(): TextDocument | undefined { + return this.metaSchemaDocument; + } +} + +/** + * Type guard to check if a KsonDocument is a KsonSchemaDocument. + */ +export function isKsonSchemaDocument(doc: KsonDocument): doc is KsonSchemaDocument { + return doc instanceof KsonSchemaDocument; +} diff --git a/tooling/language-server-protocol/src/core/features/DefinitionService.ts b/tooling/language-server-protocol/src/core/features/DefinitionService.ts index 1bfacca9..61b25e3f 100644 --- a/tooling/language-server-protocol/src/core/features/DefinitionService.ts +++ b/tooling/language-server-protocol/src/core/features/DefinitionService.ts @@ -1,5 +1,6 @@ import {DefinitionLink, Position, Range} from 'vscode-languageserver'; import {KsonDocument} from '../document/KsonDocument.js'; +import {isKsonSchemaDocument} from '../document/KsonSchemaDocument.js'; import {KsonTooling} from 'kson-tooling'; /** @@ -30,7 +31,9 @@ export class DefinitionService { results.push(...this.convertRangesToDefinitionLinks(refLocations, document.uri)); // Try document-to-schema navigation (if schema is configured) - const schemaDocument = document.getSchemaDocument(); + const schemaDocument = isKsonSchemaDocument(document) + ? document.getMetaSchemaDocument() + : document.getSchemaDocument(); if (schemaDocument) { const locations = tooling.getSchemaLocationAtLocation( document.getText(), diff --git a/tooling/language-server-protocol/src/core/features/DiagnosticService.ts b/tooling/language-server-protocol/src/core/features/DiagnosticService.ts index 0af63d50..5704d037 100644 --- a/tooling/language-server-protocol/src/core/features/DiagnosticService.ts +++ b/tooling/language-server-protocol/src/core/features/DiagnosticService.ts @@ -6,6 +6,7 @@ import { RelatedFullDocumentDiagnosticReport } from 'vscode-languageserver'; import {KsonDocument} from '../document/KsonDocument'; +import {isKsonSchemaDocument} from '../document/KsonSchemaDocument'; import {Message, Kson, SchemaResult} from 'kson'; /** @@ -30,7 +31,9 @@ export class DiagnosticService { } private getSchemaValidationMessages(document: KsonDocument): readonly Message[] | null { - const schema = document.getSchemaDocument(); + const schema = isKsonSchemaDocument(document) + ? document.getMetaSchemaDocument() + : document.getSchemaDocument(); if (!schema) return null; const parsedSchema = Kson.getInstance().parseSchema(schema.getText()); diff --git a/tooling/language-server-protocol/src/core/features/HoverService.ts b/tooling/language-server-protocol/src/core/features/HoverService.ts index 37a1767f..605244ff 100644 --- a/tooling/language-server-protocol/src/core/features/HoverService.ts +++ b/tooling/language-server-protocol/src/core/features/HoverService.ts @@ -1,5 +1,6 @@ import {Hover, Position, MarkupKind} from 'vscode-languageserver'; import {KsonDocument} from '../document/KsonDocument.js'; +import {isKsonSchemaDocument} from '../document/KsonSchemaDocument.js'; import {KsonTooling} from 'kson-tooling'; /** @@ -16,7 +17,9 @@ export class HoverService { */ getHover(document: KsonDocument, position: Position): Hover | null { // Get the schema for this document - const schemaDocument = document.getSchemaDocument(); + const schemaDocument = isKsonSchemaDocument(document) + ? document.getMetaSchemaDocument() + : document.getSchemaDocument(); if (!schemaDocument) { // No schema configured, no hover info available return null; diff --git a/tooling/language-server-protocol/src/core/schema/SchemaProvider.ts b/tooling/language-server-protocol/src/core/schema/SchemaProvider.ts index 228a901e..93dcf286 100644 --- a/tooling/language-server-protocol/src/core/schema/SchemaProvider.ts +++ b/tooling/language-server-protocol/src/core/schema/SchemaProvider.ts @@ -14,6 +14,15 @@ export interface SchemaProvider { */ getSchemaForDocument(documentUri: DocumentUri): TextDocument | undefined; + /** + * Get a bundled metaschema by its schema ID (e.g., the $id field value). + * Used for content-based schema resolution when a document declares $schema. + * + * @param schemaId The $id of the metaschema to look up + * @returns TextDocument containing the metaschema, or undefined if no match + */ + getMetaSchemaForId(schemaId: string): TextDocument | undefined; + /** * Reload the schema configuration. * Should be called when configuration changes are detected. @@ -27,15 +36,6 @@ export interface SchemaProvider { * @returns True if the file is a schema file */ isSchemaFile(fileUri: DocumentUri): boolean; - - /** - * Get a bundled metaschema by its schema ID (e.g., the $id field value). - * Used for content-based schema resolution when a document declares $schema. - * - * @param schemaId The $id of the metaschema to look up - * @returns TextDocument containing the metaschema, or undefined if no match - */ - getMetaSchemaForId(schemaId: string): TextDocument | undefined; } /** @@ -47,6 +47,10 @@ export class NoOpSchemaProvider implements SchemaProvider { return undefined; } + getMetaSchemaForId(_schemaId: string): TextDocument | undefined { + return undefined; + } + reload(): void { // No-op } @@ -54,8 +58,4 @@ export class NoOpSchemaProvider implements SchemaProvider { isSchemaFile(_fileUri: DocumentUri): boolean { return false; } - - getMetaSchemaForId(_schemaId: string): TextDocument | undefined { - return undefined; - } } diff --git a/tooling/language-server-protocol/src/startKsonServer.ts b/tooling/language-server-protocol/src/startKsonServer.ts index 37a9521c..bf65dd8f 100644 --- a/tooling/language-server-protocol/src/startKsonServer.ts +++ b/tooling/language-server-protocol/src/startKsonServer.ts @@ -7,6 +7,7 @@ import { } from 'vscode-languageserver'; import { URI } from 'vscode-uri' import {KsonDocumentsManager} from './core/document/KsonDocumentsManager.js'; +import {isKsonSchemaDocument} from './core/document/KsonSchemaDocument.js'; import {KsonTextDocumentService} from './core/services/KsonTextDocumentService.js'; import {KSON_LEGEND} from './core/features/SemanticTokensService.js'; import {getAllCommandIds} from './core/commands/CommandType.js'; @@ -178,7 +179,10 @@ export function startKsonServer( // Handle custom request to get schema information for a document connection.onRequest('kson/getDocumentSchema', (params: { uri: string }) => { try { - const schemaDocument = documentManager.get(params.uri)?.getSchemaDocument(); + const doc = documentManager.get(params.uri); + const schemaDocument = doc + ? isKsonSchemaDocument(doc) ? doc.getMetaSchemaDocument() : doc.getSchemaDocument() + : undefined; if (schemaDocument) { const schemaUri = schemaDocument.uri; // Check if this is a bundled schema (uses bundled:// scheme) diff --git a/tooling/language-server-protocol/src/test/core/document/KsonSchemaDocument.test.ts b/tooling/language-server-protocol/src/test/core/document/KsonSchemaDocument.test.ts new file mode 100644 index 00000000..7c88fe5b --- /dev/null +++ b/tooling/language-server-protocol/src/test/core/document/KsonSchemaDocument.test.ts @@ -0,0 +1,138 @@ +import {describe, it} from 'mocha'; +import * as assert from 'assert'; +import {Kson} from 'kson'; +import {TextDocument} from 'vscode-languageserver-textdocument'; +import {KsonDocument} from '../../../core/document/KsonDocument.js'; +import {KsonSchemaDocument, isKsonSchemaDocument} from '../../../core/document/KsonSchemaDocument.js'; + +describe('KsonSchemaDocument', () => { + const metaSchemaContent = `{ + "$id": "http://json-schema.org/draft-07/schema#", + "type": "object" +}`; + const metaSchemaDocument = TextDocument.create( + 'bundled://metaschema/draft-07.schema.kson', 'kson', 1, metaSchemaContent + ); + + const schemaContent = `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "name": { "type": "string" } + } +}`; + + function createSchemaDocument(metaSchema?: TextDocument): KsonSchemaDocument { + const textDoc = TextDocument.create('file:///my-schema.kson', 'kson', 1, schemaContent); + const analysis = Kson.getInstance().analyze(schemaContent); + return new KsonSchemaDocument(textDoc, analysis, metaSchema); + } + + it('should return the metaschema document', () => { + const doc = createSchemaDocument(metaSchemaDocument); + assert.strictEqual(doc.getMetaSchemaDocument(), metaSchemaDocument); + }); + + it('should return undefined metaschema when none provided', () => { + const doc = createSchemaDocument(undefined); + assert.strictEqual(doc.getMetaSchemaDocument(), undefined); + }); + + it('should return undefined for getSchemaDocument (no schema passed to parent)', () => { + const doc = createSchemaDocument(metaSchemaDocument); + assert.strictEqual(doc.getSchemaDocument(), undefined); + }); + + it('should have all KsonDocument functionality', () => { + const doc = createSchemaDocument(metaSchemaDocument); + + assert.strictEqual(doc.uri, 'file:///my-schema.kson'); + assert.strictEqual(doc.languageId, 'kson'); + assert.strictEqual(doc.version, 1); + assert.ok(doc.getText().includes('"$schema"')); + assert.ok(doc.getAnalysisResult()); + assert.ok(doc.getFullDocumentRange()); + assert.ok(doc.lineCount > 0); + }); + + describe('isKsonSchemaDocument type guard', () => { + it('should return true for KsonSchemaDocument', () => { + const doc = createSchemaDocument(metaSchemaDocument); + assert.strictEqual(isKsonSchemaDocument(doc), true); + }); + + it('should return false for plain KsonDocument', () => { + const content = '{ "name": "test" }'; + const textDoc = TextDocument.create('file:///test.kson', 'kson', 1, content); + const analysis = Kson.getInstance().analyze(content); + const doc = new KsonDocument(textDoc, analysis); + assert.strictEqual(isKsonSchemaDocument(doc), false); + }); + + it('should return false for KsonDocument with schema', () => { + const content = '{ "name": "test" }'; + const textDoc = TextDocument.create('file:///test.kson', 'kson', 1, content); + const analysis = Kson.getInstance().analyze(content); + const schemaDoc = TextDocument.create('file:///schema.kson', 'kson', 1, '{ "type": "object" }'); + const doc = new KsonDocument(textDoc, analysis, schemaDoc); + assert.strictEqual(isKsonSchemaDocument(doc), false); + }); + }); +}); + +describe('KsonDocument.getSchemaId', () => { + it('should extract $schema from document with $schema field', () => { + const content = `{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object" +}`; + const textDoc = TextDocument.create('file:///test.kson', 'kson', 1, content); + const analysis = Kson.getInstance().analyze(content); + const doc = new KsonDocument(textDoc, analysis); + + assert.strictEqual(doc.getSchemaId(), 'http://json-schema.org/draft-07/schema#'); + }); + + it('should return undefined when no $schema field', () => { + const content = '{ "type": "object" }'; + const textDoc = TextDocument.create('file:///test.kson', 'kson', 1, content); + const analysis = Kson.getInstance().analyze(content); + const doc = new KsonDocument(textDoc, analysis); + + assert.strictEqual(doc.getSchemaId(), undefined); + }); + + it('should return undefined for non-object document', () => { + const content = '"just a string"'; + const textDoc = TextDocument.create('file:///test.kson', 'kson', 1, content); + const analysis = Kson.getInstance().analyze(content); + const doc = new KsonDocument(textDoc, analysis); + + assert.strictEqual(doc.getSchemaId(), undefined); + }); + + it('should return undefined when $schema is not a string', () => { + const content = '{ "$schema": 42 }'; + const textDoc = TextDocument.create('file:///test.kson', 'kson', 1, content); + const analysis = Kson.getInstance().analyze(content); + const doc = new KsonDocument(textDoc, analysis); + + assert.strictEqual(doc.getSchemaId(), undefined); + }); +}); + +describe('KsonDocument.extractSchemaId (static)', () => { + it('should extract $schema from analysis', () => { + const content = `{ "$schema": "http://json-schema.org/draft-07/schema#" }`; + const analysis = Kson.getInstance().analyze(content); + + assert.strictEqual(KsonDocument.extractSchemaId(analysis), 'http://json-schema.org/draft-07/schema#'); + }); + + it('should return undefined for analysis without $schema', () => { + const content = '{ "type": "object" }'; + const analysis = Kson.getInstance().analyze(content); + + assert.strictEqual(KsonDocument.extractSchemaId(analysis), undefined); + }); +}); diff --git a/tooling/language-server-protocol/src/test/core/features/DefinitionService.test.ts b/tooling/language-server-protocol/src/test/core/features/DefinitionService.test.ts index 4348eaac..cbae3435 100644 --- a/tooling/language-server-protocol/src/test/core/features/DefinitionService.test.ts +++ b/tooling/language-server-protocol/src/test/core/features/DefinitionService.test.ts @@ -3,6 +3,7 @@ import * as assert from 'assert'; import {Kson} from 'kson'; import {DefinitionService} from '../../../core/features/DefinitionService.js'; import {KsonDocument} from '../../../core/document/KsonDocument.js'; +import {KsonSchemaDocument} from '../../../core/document/KsonSchemaDocument.js'; import {TextDocument} from 'vscode-languageserver-textdocument'; import {Position} from 'vscode-languageserver'; @@ -149,7 +150,7 @@ describe('DefinitionService', () => { }`; const textDoc = TextDocument.create('file:///schema.kson', 'kson', 1, schemaContent); const analysis = Kson.getInstance().analyze(schemaContent); - const document = new KsonDocument(textDoc, analysis, metaSchemaDocument); + const document = new KsonSchemaDocument(textDoc, analysis, metaSchemaDocument); // Position on the $ref value "#/$defs/User" const position: Position = {line: 5, character: 24}; // Inside the ref string @@ -188,7 +189,7 @@ describe('DefinitionService', () => { }`; const textDoc = TextDocument.create('file:///schema.kson', 'kson', 1, schemaContent); const analysis = Kson.getInstance().analyze(schemaContent); - const document = new KsonDocument(textDoc, analysis, metaSchemaDocument); + const document = new KsonSchemaDocument(textDoc, analysis, metaSchemaDocument); // Position on the $ref value const position: Position = {line: 5, character: 25}; // Inside the ref string @@ -218,7 +219,7 @@ describe('DefinitionService', () => { }`; const textDoc = TextDocument.create('file:///schema.kson', 'kson', 1, schemaContent); const analysis = Kson.getInstance().analyze(schemaContent); - const document = new KsonDocument(textDoc, analysis, metaSchemaDocument); + const document = new KsonSchemaDocument(textDoc, analysis, metaSchemaDocument); // Position on the $ref value "#" const position: Position = {line: 5, character: 21}; // Inside the ref string @@ -329,7 +330,7 @@ describe('DefinitionService', () => { }`; const textDoc = TextDocument.create('file:///schema.kson', 'kson', 1, schemaContent); const analysis = Kson.getInstance().analyze(schemaContent); - const document = new KsonDocument(textDoc, analysis, metaSchemaDocument); + const document = new KsonSchemaDocument(textDoc, analysis, metaSchemaDocument); // Position on the $ref value const position: Position = {line: 5, character: 25}; // Inside the ref string @@ -367,7 +368,7 @@ describe('DefinitionService', () => { }`; const textDoc = TextDocument.create('file:///schema.kson', 'kson', 1, schemaContent); const analysis = Kson.getInstance().analyze(schemaContent); - const document = new KsonDocument(textDoc, analysis, metaSchemaDocument); + const document = new KsonSchemaDocument(textDoc, analysis, metaSchemaDocument); // Position on the first $ref value const position: Position = {line: 5, character: 23}; // Inside the ref string diff --git a/tooling/language-server-protocol/src/test/core/schema/CompositeSchemaProvider.test.ts b/tooling/language-server-protocol/src/test/core/schema/CompositeSchemaProvider.test.ts index 57ef78f5..393ae191 100644 --- a/tooling/language-server-protocol/src/test/core/schema/CompositeSchemaProvider.test.ts +++ b/tooling/language-server-protocol/src/test/core/schema/CompositeSchemaProvider.test.ts @@ -21,6 +21,10 @@ class UriSchemaProvider implements SchemaProvider { return this.schemas.get(documentUri); } + getMetaSchemaForId(_schemaId: string): TextDocument | undefined { + return undefined; + } + reload(): void {} isSchemaFile(fileUri: DocumentUri): boolean { @@ -30,9 +34,6 @@ class UriSchemaProvider implements SchemaProvider { return false; } - getMetaSchemaForId(_schemaId: string): TextDocument | undefined { - return undefined; - } } describe('CompositeSchemaProvider', () => { @@ -278,9 +279,9 @@ describe('CompositeSchemaProvider', () => { class CountingProvider implements SchemaProvider { getSchemaForDocument(): TextDocument | undefined { return undefined; } + getMetaSchemaForId(): TextDocument | undefined { return undefined; } reload(): void { reloadCount++; } isSchemaFile(): boolean { return false; } - getMetaSchemaForId(): TextDocument | undefined { return undefined; } } const provider = new CompositeSchemaProvider([ From 9d1748658ebf663c5433f4e1f1044bd67b93580b Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Wed, 11 Feb 2026 16:47:01 +0100 Subject: [PATCH 11/18] refactor: extract notifySchemaChange helper in startKsonServer The same 3-line pattern (refreshDocumentSchemas, sendNotification, sendRequest) was duplicated in onDidChangeWatchedFiles, onDidChangeConfiguration, and onNotification handlers. Extract into a single notifySchemaChange() helper. --- .../src/startKsonServer.ts | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/tooling/language-server-protocol/src/startKsonServer.ts b/tooling/language-server-protocol/src/startKsonServer.ts index bf65dd8f..687e1b1c 100644 --- a/tooling/language-server-protocol/src/startKsonServer.ts +++ b/tooling/language-server-protocol/src/startKsonServer.ts @@ -213,6 +213,16 @@ export function startKsonServer( } }); + /** + * Refresh all documents with updated schemas, notify the client, + * and trigger diagnostic refresh. + */ + function notifySchemaChange(): void { + documentManager.refreshDocumentSchemas(); + connection.sendNotification('kson/schemaConfigurationChanged'); + connection.sendRequest('workspace/diagnostic/refresh'); + } + // Handle changes to watched files connection.onDidChangeWatchedFiles((params) => { const schemaProvider = documentManager.getSchemaProvider(); @@ -231,12 +241,7 @@ export function startKsonServer( } if (schemaChanged) { - // Refresh all open documents with the updated schemas - documentManager.refreshDocumentSchemas(); - // Notify client that schema configuration changed so it can update UI (e.g., status bar) - connection.sendNotification('kson/schemaConfigurationChanged'); - // Rerun diagnostics for open files, so we immediately see errors of schema - connection.sendRequest('workspace/diagnostic/refresh'); + notifySchemaChange(); } }); @@ -250,12 +255,7 @@ export function startKsonServer( if (bundledSchemaProvider && change.settings?.kson?.enableBundledSchemas !== undefined) { const enabled = change.settings.kson.enableBundledSchemas; bundledSchemaProvider.setEnabled(enabled); - // Refresh all documents to apply the change - documentManager.refreshDocumentSchemas(); - // Notify client that schema configuration changed - connection.sendNotification('kson/schemaConfigurationChanged'); - // Refresh diagnostics - connection.sendRequest('workspace/diagnostic/refresh'); + notifySchemaChange(); } connection.console.info('Configuration updated'); @@ -265,12 +265,7 @@ export function startKsonServer( connection.onNotification('kson/updateBundledSchemaSettings', (params: { enabled: boolean }) => { if (bundledSchemaProvider) { bundledSchemaProvider.setEnabled(params.enabled); - // Refresh all documents to apply the change - documentManager.refreshDocumentSchemas(); - // Notify client that schema configuration changed - connection.sendNotification('kson/schemaConfigurationChanged'); - // Refresh diagnostics - connection.sendRequest('workspace/diagnostic/refresh'); + notifySchemaChange(); logger.info(`Bundled schemas ${params.enabled ? 'enabled' : 'disabled'}`); } }); From db7d9422bc26e6263e5c9eabd0d47e5a1349521b Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Wed, 11 Feb 2026 16:48:57 +0100 Subject: [PATCH 12/18] refactor: remove redundant kson/updateBundledSchemaSettings handler The onNotification handler duplicated the onDidChangeConfiguration handler's behavior for the enableBundledSchemas setting, and no client code sends this notification. Remove the redundant code path. --- tooling/language-server-protocol/src/startKsonServer.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tooling/language-server-protocol/src/startKsonServer.ts b/tooling/language-server-protocol/src/startKsonServer.ts index 687e1b1c..76a74278 100644 --- a/tooling/language-server-protocol/src/startKsonServer.ts +++ b/tooling/language-server-protocol/src/startKsonServer.ts @@ -261,15 +261,6 @@ export function startKsonServer( connection.console.info('Configuration updated'); }); - // Handle notification to update bundled schema settings - connection.onNotification('kson/updateBundledSchemaSettings', (params: { enabled: boolean }) => { - if (bundledSchemaProvider) { - bundledSchemaProvider.setEnabled(params.enabled); - notifySchemaChange(); - logger.info(`Bundled schemas ${params.enabled ? 'enabled' : 'disabled'}`); - } - }); - // Start listening for requests connection.listen(); connection.console.info('Kson Language Server started and listening'); From dd4e91923184b47a4882f8e576ce4a5aed6d5afb Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Wed, 11 Feb 2026 16:52:32 +0100 Subject: [PATCH 13/18] refactor: use options object for BundledSchemaProvider constructor Replace fragile positional parameters with a named options object. The old constructor had metaSchemas as 4th parameter after an optional logger, requiring callers to pass logger explicitly to reach it. --- .../src/core/schema/BundledSchemaProvider.ts | 40 ++++++----- .../src/startKsonServer.ts | 7 +- .../core/schema/BundledSchemaProvider.test.ts | 48 ++++++------- .../schema/CompositeSchemaProvider.test.ts | 71 ++++++++++--------- 4 files changed, 90 insertions(+), 76 deletions(-) diff --git a/tooling/language-server-protocol/src/core/schema/BundledSchemaProvider.ts b/tooling/language-server-protocol/src/core/schema/BundledSchemaProvider.ts index 2c9876fe..f0d9c7af 100644 --- a/tooling/language-server-protocol/src/core/schema/BundledSchemaProvider.ts +++ b/tooling/language-server-protocol/src/core/schema/BundledSchemaProvider.ts @@ -25,6 +25,24 @@ export interface BundledMetaSchemaConfig { schemaContent: string; } +/** + * Options for creating a BundledSchemaProvider. + */ +export interface BundledSchemaProviderOptions { + /** Array of bundled schema configurations (matched by file extension) */ + schemas: BundledSchemaConfig[]; + /** Array of bundled metaschema configurations (matched by $schema content) */ + metaSchemas?: BundledMetaSchemaConfig[]; + /** Whether bundled schemas are enabled (default: true) */ + enabled?: boolean; + /** Optional logger for warnings and errors */ + logger?: { + info: (message: string) => void; + warn: (message: string) => void; + error: (message: string) => void; + }; +} + /** * Schema provider for bundled schemas that are shipped with the extension. * Works in both browser and Node.js environments since it doesn't require file system access. @@ -42,26 +60,12 @@ export class BundledSchemaProvider implements SchemaProvider { private schemas: Map; private metaSchemas: Map; private enabled: boolean; + private logger?: BundledSchemaProviderOptions['logger']; - /** - * Creates a new BundledSchemaProvider. - * - * @param schemas Array of bundled schema configurations (matched by file extension) - * @param enabled Whether bundled schemas are enabled (default: true) - * @param logger Optional logger for warnings and errors - * @param metaSchemas Array of bundled metaschema configurations (matched by $schema content) - */ - constructor( - schemas: BundledSchemaConfig[], - enabled: boolean = true, - private logger?: { - info: (message: string) => void; - warn: (message: string) => void; - error: (message: string) => void; - }, - metaSchemas: BundledMetaSchemaConfig[] = [] - ) { + constructor(options: BundledSchemaProviderOptions) { + const { schemas, metaSchemas = [], enabled = true, logger } = options; this.enabled = enabled; + this.logger = logger; this.schemas = new Map(); this.metaSchemas = new Map(); diff --git a/tooling/language-server-protocol/src/startKsonServer.ts b/tooling/language-server-protocol/src/startKsonServer.ts index 76a74278..14eb0ecb 100644 --- a/tooling/language-server-protocol/src/startKsonServer.ts +++ b/tooling/language-server-protocol/src/startKsonServer.ts @@ -82,7 +82,12 @@ export function startKsonServer( // Create bundled schema provider if schemas or metaschemas are configured let schemaProvider: SchemaProvider | undefined; if (bundledSchemas.length > 0 || bundledMetaSchemas.length > 0) { - bundledSchemaProvider = new BundledSchemaProvider(bundledSchemas, enableBundledSchemas, logger, bundledMetaSchemas); + bundledSchemaProvider = new BundledSchemaProvider({ + schemas: bundledSchemas, + metaSchemas: bundledMetaSchemas, + enabled: enableBundledSchemas, + logger + }); // Create composite provider: file system takes priority over bundled const providers: SchemaProvider[] = []; diff --git a/tooling/language-server-protocol/src/test/core/schema/BundledSchemaProvider.test.ts b/tooling/language-server-protocol/src/test/core/schema/BundledSchemaProvider.test.ts index 0846539e..146c5db8 100644 --- a/tooling/language-server-protocol/src/test/core/schema/BundledSchemaProvider.test.ts +++ b/tooling/language-server-protocol/src/test/core/schema/BundledSchemaProvider.test.ts @@ -17,7 +17,7 @@ describe('BundledSchemaProvider', () => { describe('constructor', () => { it('should create provider with no schemas', () => { - const provider = new BundledSchemaProvider([], true, logger); + const provider = new BundledSchemaProvider({ schemas: [], logger }); assert.ok(provider); assert.strictEqual(provider.getAvailableFileExtensions().length, 0); assert.ok(logs.some(msg => msg.includes('initialized with 0 schemas and 0 metaschemas'))); @@ -27,7 +27,7 @@ describe('BundledSchemaProvider', () => { const schemas: BundledSchemaConfig[] = [ { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } ]; - const provider = new BundledSchemaProvider(schemas, true, logger); + const provider = new BundledSchemaProvider({ schemas, logger }); assert.ok(provider); assert.strictEqual(provider.getAvailableFileExtensions().length, 1); @@ -40,7 +40,7 @@ describe('BundledSchemaProvider', () => { { fileExtension: 'ext-a', schemaContent: '{ "type": "object" }' }, { fileExtension: 'ext-b', schemaContent: '{ "type": "array" }' } ]; - const provider = new BundledSchemaProvider(schemas, true, logger); + const provider = new BundledSchemaProvider({ schemas, logger }); assert.strictEqual(provider.getAvailableFileExtensions().length, 2); assert.ok(provider.hasBundledSchema('ext-a')); @@ -51,7 +51,7 @@ describe('BundledSchemaProvider', () => { const metaSchemas: BundledMetaSchemaConfig[] = [ { schemaId: 'http://json-schema.org/draft-07/schema#', name: 'draft-07', schemaContent: '{ "type": "object" }' } ]; - const provider = new BundledSchemaProvider([], true, logger, metaSchemas); + const provider = new BundledSchemaProvider({ schemas: [], metaSchemas, logger }); assert.ok(provider); assert.ok(logs.some(msg => msg.includes('Loaded bundled metaschema: draft-07'))); @@ -62,7 +62,7 @@ describe('BundledSchemaProvider', () => { const schemas: BundledSchemaConfig[] = [ { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } ]; - const provider = new BundledSchemaProvider(schemas, false, logger); + const provider = new BundledSchemaProvider({ schemas, enabled: false, logger }); assert.strictEqual(provider.isEnabled(), false); assert.ok(logs.some(msg => msg.includes('enabled: false'))); @@ -74,7 +74,7 @@ describe('BundledSchemaProvider', () => { const schemas: BundledSchemaConfig[] = [ { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } ]; - const provider = new BundledSchemaProvider(schemas, false, logger); + const provider = new BundledSchemaProvider({ schemas, enabled: false, logger }); const schema = provider.getSchemaForDocument('file:///test.kxt'); assert.strictEqual(schema, undefined); @@ -84,7 +84,7 @@ describe('BundledSchemaProvider', () => { const schemas: BundledSchemaConfig[] = [ { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } ]; - const provider = new BundledSchemaProvider(schemas, true, logger); + const provider = new BundledSchemaProvider({ schemas, logger }); const schema = provider.getSchemaForDocument('file:///test'); assert.strictEqual(schema, undefined); @@ -94,7 +94,7 @@ describe('BundledSchemaProvider', () => { const schemas: BundledSchemaConfig[] = [ { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } ]; - const provider = new BundledSchemaProvider(schemas, true, logger); + const provider = new BundledSchemaProvider({ schemas, logger }); const schema = provider.getSchemaForDocument('file:///test.unknown'); assert.strictEqual(schema, undefined); @@ -105,7 +105,7 @@ describe('BundledSchemaProvider', () => { const schemas: BundledSchemaConfig[] = [ { fileExtension: 'kxt', schemaContent } ]; - const provider = new BundledSchemaProvider(schemas, true, logger); + const provider = new BundledSchemaProvider({ schemas, logger }); const schema = provider.getSchemaForDocument('file:///test.kxt'); assert.ok(schema); @@ -118,7 +118,7 @@ describe('BundledSchemaProvider', () => { const schemas: BundledSchemaConfig[] = [ { fileExtension: 'kxt', schemaContent } ]; - const provider = new BundledSchemaProvider(schemas, true, logger); + const provider = new BundledSchemaProvider({ schemas, logger }); const schema1 = provider.getSchemaForDocument('file:///a.kxt'); const schema2 = provider.getSchemaForDocument('file:///b.kxt'); @@ -135,7 +135,7 @@ describe('BundledSchemaProvider', () => { { fileExtension: 'kson', schemaContent: ksonSchema }, { fileExtension: 'orchestra.kson', schemaContent: orchestraSchema } ]; - const provider = new BundledSchemaProvider(schemas, true, logger); + const provider = new BundledSchemaProvider({ schemas, logger }); // Simple .kson file should match 'kson' extension const simpleSchema = provider.getSchemaForDocument('file:///test.kson'); @@ -153,7 +153,7 @@ describe('BundledSchemaProvider', () => { { fileExtension: 'kson', schemaContent: '{ "short": true }' }, { fileExtension: 'config.kson', schemaContent: '{ "long": true }' } ]; - const provider = new BundledSchemaProvider(schemas, true, logger); + const provider = new BundledSchemaProvider({ schemas, logger }); // File ending in .config.kson should match the longer extension const schema = provider.getSchemaForDocument('file:///app.config.kson'); @@ -168,7 +168,7 @@ describe('BundledSchemaProvider', () => { const metaSchemas: BundledMetaSchemaConfig[] = [ { schemaId: 'http://json-schema.org/draft-07/schema#', name: 'draft-07', schemaContent: metaSchemaContent } ]; - const provider = new BundledSchemaProvider([], true, logger, metaSchemas); + const provider = new BundledSchemaProvider({ schemas: [], metaSchemas, logger }); const result = provider.getMetaSchemaForId('http://json-schema.org/draft-07/schema#'); assert.ok(result); @@ -180,7 +180,7 @@ describe('BundledSchemaProvider', () => { const metaSchemas: BundledMetaSchemaConfig[] = [ { schemaId: 'http://json-schema.org/draft-07/schema#', name: 'draft-07', schemaContent: '{}' } ]; - const provider = new BundledSchemaProvider([], true, logger, metaSchemas); + const provider = new BundledSchemaProvider({ schemas: [], metaSchemas, logger }); const result = provider.getMetaSchemaForId('http://json-schema.org/draft-04/schema#'); assert.strictEqual(result, undefined); @@ -190,14 +190,14 @@ describe('BundledSchemaProvider', () => { const metaSchemas: BundledMetaSchemaConfig[] = [ { schemaId: 'http://json-schema.org/draft-07/schema#', name: 'draft-07', schemaContent: '{}' } ]; - const provider = new BundledSchemaProvider([], false, logger, metaSchemas); + const provider = new BundledSchemaProvider({ schemas: [], metaSchemas, enabled: false, logger }); const result = provider.getMetaSchemaForId('http://json-schema.org/draft-07/schema#'); assert.strictEqual(result, undefined); }); it('should return undefined when no metaschemas configured', () => { - const provider = new BundledSchemaProvider([], true, logger); + const provider = new BundledSchemaProvider({ schemas: [], logger }); const result = provider.getMetaSchemaForId('http://json-schema.org/draft-07/schema#'); assert.strictEqual(result, undefined); @@ -206,20 +206,20 @@ describe('BundledSchemaProvider', () => { describe('isSchemaFile', () => { it('should return true for bundled schema URIs', () => { - const provider = new BundledSchemaProvider([], true, logger); + const provider = new BundledSchemaProvider({ schemas: [], logger }); assert.strictEqual(provider.isSchemaFile('bundled://schema/test-lang.schema.kson'), true); assert.strictEqual(provider.isSchemaFile('bundled://schema/other.schema.kson'), true); }); it('should return true for bundled metaschema URIs', () => { - const provider = new BundledSchemaProvider([], true, logger); + const provider = new BundledSchemaProvider({ schemas: [], logger }); assert.strictEqual(provider.isSchemaFile('bundled://metaschema/draft-07.schema.kson'), true); }); it('should return false for non-bundled URIs', () => { - const provider = new BundledSchemaProvider([], true, logger); + const provider = new BundledSchemaProvider({ schemas: [], logger }); assert.strictEqual(provider.isSchemaFile('file:///test.kson'), false); assert.strictEqual(provider.isSchemaFile('untitled:///test.kson'), false); @@ -231,7 +231,7 @@ describe('BundledSchemaProvider', () => { const schemas: BundledSchemaConfig[] = [ { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } ]; - const provider = new BundledSchemaProvider(schemas, true, logger); + const provider = new BundledSchemaProvider({ schemas, logger }); assert.strictEqual(provider.isEnabled(), true); @@ -254,7 +254,7 @@ describe('BundledSchemaProvider', () => { const metaSchemas: BundledMetaSchemaConfig[] = [ { schemaId: 'http://json-schema.org/draft-07/schema#', name: 'draft-07', schemaContent: '{}' } ]; - const provider = new BundledSchemaProvider([], true, logger, metaSchemas); + const provider = new BundledSchemaProvider({ schemas: [], metaSchemas, logger }); assert.ok(provider.getMetaSchemaForId('http://json-schema.org/draft-07/schema#')); @@ -271,7 +271,7 @@ describe('BundledSchemaProvider', () => { const schemas: BundledSchemaConfig[] = [ { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } ]; - const provider = new BundledSchemaProvider(schemas, true, logger); + const provider = new BundledSchemaProvider({ schemas, logger }); // reload should not throw or change anything provider.reload(); @@ -287,7 +287,7 @@ describe('BundledSchemaProvider', () => { { fileExtension: 'ext-a', schemaContent: '{}' }, { fileExtension: 'ext-b', schemaContent: '{}' } ]; - const provider = new BundledSchemaProvider(schemas, true, logger); + const provider = new BundledSchemaProvider({ schemas, logger }); assert.strictEqual(provider.hasBundledSchema('ext-a'), true); assert.strictEqual(provider.hasBundledSchema('ext-b'), true); @@ -302,7 +302,7 @@ describe('BundledSchemaProvider', () => { { fileExtension: 'beta', schemaContent: '{}' }, { fileExtension: 'gamma', schemaContent: '{}' } ]; - const provider = new BundledSchemaProvider(schemas, true, logger); + const provider = new BundledSchemaProvider({ schemas, logger }); const extensions = provider.getAvailableFileExtensions(); assert.strictEqual(extensions.length, 3); diff --git a/tooling/language-server-protocol/src/test/core/schema/CompositeSchemaProvider.test.ts b/tooling/language-server-protocol/src/test/core/schema/CompositeSchemaProvider.test.ts index 393ae191..f1b9f112 100644 --- a/tooling/language-server-protocol/src/test/core/schema/CompositeSchemaProvider.test.ts +++ b/tooling/language-server-protocol/src/test/core/schema/CompositeSchemaProvider.test.ts @@ -57,8 +57,8 @@ describe('CompositeSchemaProvider', () => { }); it('should create provider with multiple providers', () => { - const p1 = new BundledSchemaProvider([], true, logger); - const p2 = new BundledSchemaProvider([], true, logger); + const p1 = new BundledSchemaProvider({ schemas: [], logger }); + const p2 = new BundledSchemaProvider({ schemas: [], logger }); const provider = new CompositeSchemaProvider([p1, p2], logger); assert.strictEqual(provider.getProviders().length, 2); @@ -73,8 +73,8 @@ describe('CompositeSchemaProvider', () => { }); it('should return undefined when no provider has schema', () => { - const p1 = new BundledSchemaProvider([], true, logger); - const p2 = new BundledSchemaProvider([], true, logger); + const p1 = new BundledSchemaProvider({ schemas: [], logger }); + const p2 = new BundledSchemaProvider({ schemas: [], logger }); const provider = new CompositeSchemaProvider([p1, p2], logger); const schema = provider.getSchemaForDocument('file:///test.kson'); @@ -114,10 +114,11 @@ describe('CompositeSchemaProvider', () => { }); it('should match by file extension via BundledSchemaProvider', () => { - const emptyProvider = new BundledSchemaProvider([], true, logger); - const bundledProvider = new BundledSchemaProvider([ - { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } - ], true, logger); + const emptyProvider = new BundledSchemaProvider({ schemas: [], logger }); + const bundledProvider = new BundledSchemaProvider({ + schemas: [{ fileExtension: 'kxt', schemaContent: '{ "type": "object" }' }], + logger + }); const provider = new CompositeSchemaProvider([emptyProvider, bundledProvider], logger); const result = provider.getSchemaForDocument('file:///test.kxt'); @@ -133,9 +134,10 @@ describe('CompositeSchemaProvider', () => { fileSystemProvider.addSchema('file:///test.kxt', fsSchema); // Bundled provider (has schema by extension) - lower priority - const bundledProvider = new BundledSchemaProvider([ - { fileExtension: 'kxt', schemaContent: '{ "from": "bundled" }' } - ], true, logger); + const bundledProvider = new BundledSchemaProvider({ + schemas: [{ fileExtension: 'kxt', schemaContent: '{ "from": "bundled" }' }], + logger + }); const provider = new CompositeSchemaProvider([fileSystemProvider, bundledProvider], logger); const result = provider.getSchemaForDocument('file:///test.kxt'); @@ -149,9 +151,10 @@ describe('CompositeSchemaProvider', () => { const fileSystemProvider = new UriSchemaProvider(); // Bundled provider has schema - const bundledProvider = new BundledSchemaProvider([ - { fileExtension: 'kxt', schemaContent: '{ "from": "bundled" }' } - ], true, logger); + const bundledProvider = new BundledSchemaProvider({ + schemas: [{ fileExtension: 'kxt', schemaContent: '{ "from": "bundled" }' }], + logger + }); const provider = new CompositeSchemaProvider([fileSystemProvider, bundledProvider], logger); const result = provider.getSchemaForDocument('file:///test.kxt'); @@ -172,12 +175,12 @@ describe('CompositeSchemaProvider', () => { const metaSchemas: BundledMetaSchemaConfig[] = [ { schemaId: 'http://json-schema.org/draft-07/schema#', name: 'draft-07', schemaContent: '{ "from": "first" }' } ]; - const provider1 = new BundledSchemaProvider([], true, logger, metaSchemas); + const provider1 = new BundledSchemaProvider({ schemas: [], metaSchemas, logger }); const metaSchemas2: BundledMetaSchemaConfig[] = [ { schemaId: 'http://json-schema.org/draft-07/schema#', name: 'draft-07', schemaContent: '{ "from": "second" }' } ]; - const provider2 = new BundledSchemaProvider([], true, logger, metaSchemas2); + const provider2 = new BundledSchemaProvider({ schemas: [], metaSchemas: metaSchemas2, logger }); const composite = new CompositeSchemaProvider([provider1, provider2], logger); const result = composite.getMetaSchemaForId('http://json-schema.org/draft-07/schema#'); @@ -187,12 +190,12 @@ describe('CompositeSchemaProvider', () => { }); it('should try next provider when first has no match', () => { - const provider1 = new BundledSchemaProvider([], true, logger); + const provider1 = new BundledSchemaProvider({ schemas: [], logger }); const metaSchemas2: BundledMetaSchemaConfig[] = [ { schemaId: 'http://json-schema.org/draft-07/schema#', name: 'draft-07', schemaContent: '{ "from": "second" }' } ]; - const provider2 = new BundledSchemaProvider([], true, logger, metaSchemas2); + const provider2 = new BundledSchemaProvider({ schemas: [], metaSchemas: metaSchemas2, logger }); const composite = new CompositeSchemaProvider([provider1, provider2], logger); const result = composite.getMetaSchemaForId('http://json-schema.org/draft-07/schema#'); @@ -202,8 +205,8 @@ describe('CompositeSchemaProvider', () => { }); it('should return undefined when no provider has match', () => { - const provider1 = new BundledSchemaProvider([], true, logger); - const provider2 = new BundledSchemaProvider([], true, logger); + const provider1 = new BundledSchemaProvider({ schemas: [], logger }); + const provider2 = new BundledSchemaProvider({ schemas: [], logger }); const composite = new CompositeSchemaProvider([provider1, provider2], logger); const result = composite.getMetaSchemaForId('http://json-schema.org/draft-07/schema#'); @@ -216,7 +219,7 @@ describe('CompositeSchemaProvider', () => { const metaSchemas: BundledMetaSchemaConfig[] = [ { schemaId: 'http://json-schema.org/draft-07/schema#', name: 'draft-07', schemaContent: '{ "metaschema": true }' } ]; - const bundledProvider = new BundledSchemaProvider([], true, logger, metaSchemas); + const bundledProvider = new BundledSchemaProvider({ schemas: [], metaSchemas, logger }); const composite = new CompositeSchemaProvider([uriProvider, bundledProvider], logger); const result = composite.getMetaSchemaForId('http://json-schema.org/draft-07/schema#'); @@ -234,24 +237,25 @@ describe('CompositeSchemaProvider', () => { it('should return true when any provider considers it a schema file', () => { const uriProvider = new UriSchemaProvider(); - const bundledProvider = new BundledSchemaProvider([ - { fileExtension: 'kxt', schemaContent: '{}' } - ], true, logger); + const bundledProvider = new BundledSchemaProvider({ + schemas: [{ fileExtension: 'kxt', schemaContent: '{}' }], + logger + }); const provider = new CompositeSchemaProvider([uriProvider, bundledProvider], logger); assert.strictEqual(provider.isSchemaFile('bundled://schema/kxt.schema.kson'), true); }); it('should return true for metaschema URIs', () => { - const bundledProvider = new BundledSchemaProvider([], true, logger); + const bundledProvider = new BundledSchemaProvider({ schemas: [], logger }); const provider = new CompositeSchemaProvider([bundledProvider], logger); assert.strictEqual(provider.isSchemaFile('bundled://metaschema/draft-07.schema.kson'), true); }); it('should return false when no provider considers it a schema file', () => { - const p1 = new BundledSchemaProvider([], true, logger); - const p2 = new BundledSchemaProvider([], true, logger); + const p1 = new BundledSchemaProvider({ schemas: [], logger }); + const p2 = new BundledSchemaProvider({ schemas: [], logger }); const provider = new CompositeSchemaProvider([p1, p2], logger); assert.strictEqual(provider.isSchemaFile('file:///random.kson'), false); @@ -261,9 +265,10 @@ describe('CompositeSchemaProvider', () => { const uriProvider = new UriSchemaProvider(); uriProvider.addSchema('file:///test.kson', TextDocument.create('file:///my-schema.kson', 'kson', 1, '{}')); - const bundledProvider = new BundledSchemaProvider([ - { fileExtension: 'kxt', schemaContent: '{}' } - ], true, logger); + const bundledProvider = new BundledSchemaProvider({ + schemas: [{ fileExtension: 'kxt', schemaContent: '{}' }], + logger + }); const provider = new CompositeSchemaProvider([uriProvider, bundledProvider], logger); @@ -298,8 +303,8 @@ describe('CompositeSchemaProvider', () => { describe('getProviders', () => { it('should return readonly array of providers', () => { - const p1 = new BundledSchemaProvider([], true, logger); - const p2 = new BundledSchemaProvider([], true, logger); + const p1 = new BundledSchemaProvider({ schemas: [], logger }); + const p2 = new BundledSchemaProvider({ schemas: [], logger }); const provider = new CompositeSchemaProvider([p1, p2], logger); const providers = provider.getProviders(); @@ -312,7 +317,7 @@ describe('CompositeSchemaProvider', () => { describe('integration with NoOpSchemaProvider', () => { it('should work with NoOpSchemaProvider as fallback', () => { - const bundled = new BundledSchemaProvider([], true, logger); + const bundled = new BundledSchemaProvider({ schemas: [], logger }); const noOp = new NoOpSchemaProvider(); const provider = new CompositeSchemaProvider([bundled, noOp], logger); From 6c78a7be569dc6849523aab8ae688fdb1c92b56c Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Wed, 11 Feb 2026 17:01:58 +0100 Subject: [PATCH 14/18] refactor: derive isBundled on client side from URI scheme Remove isBundled from the kson/getDocumentSchema server response. The client already has the schema URI and can derive the bundled status from the bundled:// scheme directly. --- .../language-server-protocol/src/startKsonServer.ts | 11 +++-------- .../vscode/src/client/common/StatusBarManager.ts | 12 ++++++------ 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/tooling/language-server-protocol/src/startKsonServer.ts b/tooling/language-server-protocol/src/startKsonServer.ts index 14eb0ecb..1ba9b56e 100644 --- a/tooling/language-server-protocol/src/startKsonServer.ts +++ b/tooling/language-server-protocol/src/startKsonServer.ts @@ -190,30 +190,25 @@ export function startKsonServer( : undefined; if (schemaDocument) { const schemaUri = schemaDocument.uri; - // Check if this is a bundled schema (uses bundled:// scheme) - const isBundled = schemaUri.startsWith('bundled://'); // Extract readable path from URI const schemaPath = schemaUri.startsWith('file://') ? URI.parse(schemaUri).fsPath : schemaUri; return { schemaUri, schemaPath, - hasSchema: true, - isBundled + hasSchema: true }; } return { schemaUri: undefined, schemaPath: undefined, - hasSchema: false, - isBundled: false + hasSchema: false }; } catch (error) { logger.error(`Error getting schema for document: ${error}`); return { schemaUri: undefined, schemaPath: undefined, - hasSchema: false, - isBundled: false + hasSchema: false }; } }); diff --git a/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts b/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts index 57b26942..65d825e9 100644 --- a/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts +++ b/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts @@ -10,8 +10,6 @@ interface SchemaInfo { schemaUri?: string; schemaPath?: string; hasSchema: boolean; - /** Whether the schema is bundled with the extension */ - isBundled: boolean; } /** @@ -50,17 +48,19 @@ export class StatusBarManager { ); if (schemaInfo.hasSchema && schemaInfo.schemaPath) { + const isBundled = schemaInfo.schemaUri?.startsWith('bundled://') ?? false; + // Extract just the filename for display - const schemaFileName = schemaInfo.isBundled + const schemaFileName = isBundled ? this.extractBundledSchemaName(schemaInfo.schemaPath) : path.basename(schemaInfo.schemaPath); // Show bundled indicator - const bundledSuffix = schemaInfo.isBundled ? ' (bundled)' : ''; - const icon = schemaInfo.isBundled ? '$(package)' : '$(file-code)'; + const bundledSuffix = isBundled ? ' (bundled)' : ''; + const icon = isBundled ? '$(package)' : '$(file-code)'; this.statusBarItem.text = `${icon} Schema: ${schemaFileName}${bundledSuffix}`; - this.statusBarItem.tooltip = schemaInfo.isBundled + this.statusBarItem.tooltip = isBundled ? `Bundled schema for this language\nClick to override with custom schema` : `Schema: ${schemaInfo.schemaPath}\nClick to change schema`; } else { From b186c375037d42b59b6c21625478151467fc85c1 Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Wed, 11 Feb 2026 17:02:10 +0100 Subject: [PATCH 15/18] fix: code quality improvements for bundled schema support - Move .gitkeep instructions to schemas/README.md (conventionally empty) - Add trailing newlines to both ksonClientMain.ts files - Fix brittle regex key normalization in BundledSchemaContentProvider with explicit path handling - Remove backslash handling from findMatchingExtension (LSP URIs always use forward slashes) - Add validation warning for common fileExtension format mistakes (leading dot, glob patterns) --- .../src/core/schema/BundledSchemaProvider.ts | 8 +++++++- tooling/lsp-clients/vscode/schemas/.gitkeep | 10 ---------- tooling/lsp-clients/vscode/schemas/README.md | 16 ++++++++++++++++ .../vscode/src/client/browser/ksonClientMain.ts | 2 +- .../common/BundledSchemaContentProvider.ts | 9 ++++----- .../vscode/src/client/node/ksonClientMain.ts | 2 +- 6 files changed, 29 insertions(+), 18 deletions(-) create mode 100644 tooling/lsp-clients/vscode/schemas/README.md diff --git a/tooling/language-server-protocol/src/core/schema/BundledSchemaProvider.ts b/tooling/language-server-protocol/src/core/schema/BundledSchemaProvider.ts index f0d9c7af..63051d3d 100644 --- a/tooling/language-server-protocol/src/core/schema/BundledSchemaProvider.ts +++ b/tooling/language-server-protocol/src/core/schema/BundledSchemaProvider.ts @@ -71,6 +71,12 @@ export class BundledSchemaProvider implements SchemaProvider { // Create TextDocuments from the provided schema content for (const config of schemas) { + if (config.fileExtension.startsWith('.') || config.fileExtension.includes('*')) { + this.logger?.warn( + `Bundled schema fileExtension "${config.fileExtension}" looks incorrect ` + + `(should be e.g. "kson" not ".kson" or "*.kson")` + ); + } try { // Include .schema.kson suffix so VS Code recognizes it as KSON for syntax highlighting const schemaUri = `bundled://schema/${config.fileExtension}.schema.kson`; @@ -154,7 +160,7 @@ export class BundledSchemaProvider implements SchemaProvider { */ private findMatchingExtension(uri: string): string | undefined { // Extract just the filename part (after last slash) - const lastSlash = Math.max(uri.lastIndexOf('/'), uri.lastIndexOf('\\')); + const lastSlash = uri.lastIndexOf('/'); const filename = lastSlash >= 0 ? uri.substring(lastSlash + 1) : uri; // Find all extensions that match the end of the filename diff --git a/tooling/lsp-clients/vscode/schemas/.gitkeep b/tooling/lsp-clients/vscode/schemas/.gitkeep index 3f0551a3..e69de29b 100644 --- a/tooling/lsp-clients/vscode/schemas/.gitkeep +++ b/tooling/lsp-clients/vscode/schemas/.gitkeep @@ -1,10 +0,0 @@ -# This directory contains bundled schema files for KSON dialects. -# To add a bundled schema for a language: -# 1. Add the schema file here (e.g., my-dialect.schema.kson) -# 2. Update package.json to add bundledSchema field to the language contribution: -# { -# "id": "my-dialect", -# "extensions": [".md"], -# "bundledSchema": "./dist/extension/schemas/my-dialect.schema.kson" -# } -# 3. The schema will be automatically bundled and loaded for that language ID. diff --git a/tooling/lsp-clients/vscode/schemas/README.md b/tooling/lsp-clients/vscode/schemas/README.md new file mode 100644 index 00000000..cef88626 --- /dev/null +++ b/tooling/lsp-clients/vscode/schemas/README.md @@ -0,0 +1,16 @@ +# Bundled Schema Files + +This directory contains bundled schema files for KSON dialects. + +## Adding a bundled schema for a language + +1. Add the schema file here (e.g., `my-dialect.schema.kson`) +2. Update `package.json` to add a `bundledSchema` field to the language contribution: + ```json + { + "id": "my-dialect", + "extensions": [".md"], + "bundledSchema": "./dist/extension/schemas/my-dialect.schema.kson" + } + ``` +3. The schema will be automatically bundled and loaded for that language ID. diff --git a/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts b/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts index 2f2a86cc..cd17138f 100644 --- a/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts +++ b/tooling/lsp-clients/vscode/src/client/browser/ksonClientMain.ts @@ -83,4 +83,4 @@ export async function activate(context: vscode.ExtensionContext) { logOutputChannel.error(`Failed to activate KSON Browser extension: ${message}`); vscode.window.showErrorMessage('Failed to activate KSON language support.'); } -} \ No newline at end of file +} diff --git a/tooling/lsp-clients/vscode/src/client/common/BundledSchemaContentProvider.ts b/tooling/lsp-clients/vscode/src/client/common/BundledSchemaContentProvider.ts index 2dda1650..24a83d85 100644 --- a/tooling/lsp-clients/vscode/src/client/common/BundledSchemaContentProvider.ts +++ b/tooling/lsp-clients/vscode/src/client/common/BundledSchemaContentProvider.ts @@ -46,11 +46,10 @@ export class BundledSchemaContentProvider implements vscode.TextDocumentContentP provideTextDocumentContent(uri: vscode.Uri): string | undefined { // URI format: bundled://{authority}/{path} // uri.authority = "schema" or "metaschema" - // uri.path = "/{name}.schema.kson" - const key = `${uri.authority}${uri.path}`; - // Remove leading slash from path: "schema//ext.schema.kson" → "schema/ext.schema.kson" - const normalizedKey = key.replace(/^([^/]+)\/\//, '$1/'); - return this.contentByKey.get(normalizedKey); + // uri.path = "/{name}.schema.kson" (VS Code adds a leading slash) + const path = uri.path.startsWith('/') ? uri.path.substring(1) : uri.path; + const key = `${uri.authority}/${path}`; + return this.contentByKey.get(key); } } diff --git a/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts b/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts index 9eabaaf0..419f8593 100644 --- a/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts +++ b/tooling/lsp-clients/vscode/src/client/node/ksonClientMain.ts @@ -204,4 +204,4 @@ export async function activate(context: vscode.ExtensionContext) { logOutputChannel.error(`Failed to activate KSON Node.js extension: ${message}`); vscode.window.showErrorMessage('Failed to activate KSON language support.'); } -} \ No newline at end of file +} From ee031c492fde06c38cd766cb73db7e58f823bcc5 Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Wed, 11 Feb 2026 17:04:50 +0100 Subject: [PATCH 16/18] test: tighten DefinitionService test assertions Add exact definition.length assertions to tests that use .find() to locate results. This catches regressions where one source unexpectedly returns too many or too few results. --- .../core/features/DefinitionService.test.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tooling/language-server-protocol/src/test/core/features/DefinitionService.test.ts b/tooling/language-server-protocol/src/test/core/features/DefinitionService.test.ts index cbae3435..9ea15425 100644 --- a/tooling/language-server-protocol/src/test/core/features/DefinitionService.test.ts +++ b/tooling/language-server-protocol/src/test/core/features/DefinitionService.test.ts @@ -156,9 +156,9 @@ describe('DefinitionService', () => { const position: Position = {line: 5, character: 24}; // Inside the ref string const definition = definitionService.getDefinition(document, position); - // Should return a definition pointing to the User definition + // Should return definitions from both $ref resolution and schema navigation assert.ok(Array.isArray(definition), 'Definition should be an array'); - assert.ok(definition.length > 0, 'Definition array should not be empty'); + assert.strictEqual(definition.length, 2, 'Should have results from both $ref resolution and schema navigation'); const refDef = definition.find(d => d.targetUri === textDoc.uri); assert.ok(refDef, 'Should have a $ref resolution result pointing to same document'); @@ -195,9 +195,9 @@ describe('DefinitionService', () => { const position: Position = {line: 5, character: 25}; // Inside the ref string const definition = definitionService.getDefinition(document, position); - // Should return a definition pointing to the name property + // Should return definitions from both $ref resolution and schema navigation assert.ok(Array.isArray(definition), 'Definition should be an array'); - assert.ok(definition.length > 0, 'Definition array should not be empty'); + assert.strictEqual(definition.length, 2, 'Should have results from both $ref resolution and schema navigation'); const refDef = definition.find(d => d.targetUri === textDoc.uri); assert.ok(refDef, 'Should have a $ref resolution result pointing to same document'); @@ -225,9 +225,9 @@ describe('DefinitionService', () => { const position: Position = {line: 5, character: 21}; // Inside the ref string const definition = definitionService.getDefinition(document, position); - // Should return a definition pointing to the root + // Should return definitions from both $ref resolution and schema navigation assert.ok(Array.isArray(definition), 'Definition should be an array'); - assert.ok(definition.length > 0, 'Definition array should not be empty'); + assert.strictEqual(definition.length, 2, 'Should have results from both $ref resolution and schema navigation'); const refDef = definition.find(d => d.targetUri === textDoc.uri); assert.ok(refDef, 'Should have a $ref resolution result pointing to same document'); @@ -336,9 +336,9 @@ describe('DefinitionService', () => { const position: Position = {line: 5, character: 25}; // Inside the ref string const definition = definitionService.getDefinition(document, position); - // Should return a definition pointing to the Item definition + // Should return definitions from both $ref resolution and schema navigation assert.ok(Array.isArray(definition), 'Definition should be an array'); - assert.ok(definition.length > 0, 'Definition array should not be empty'); + assert.strictEqual(definition.length, 2, 'Should have results from both $ref resolution and schema navigation'); const refDef = definition.find(d => d.targetUri === textDoc.uri); assert.ok(refDef, 'Should have a $ref resolution result pointing to same document'); @@ -374,9 +374,9 @@ describe('DefinitionService', () => { const position: Position = {line: 5, character: 23}; // Inside the ref string const definition = definitionService.getDefinition(document, position); - // Should return a definition pointing to the Alias (not following the transitive ref) + // Should return definitions from both $ref resolution and schema navigation assert.ok(Array.isArray(definition), 'Definition should be an array'); - assert.ok(definition.length > 0, 'Definition array should not be empty'); + assert.strictEqual(definition.length, 2, 'Should have results from both $ref resolution and schema navigation'); const refDef = definition.find(d => d.targetUri === textDoc.uri); assert.ok(refDef, 'Should have a $ref resolution result pointing to same document'); From 43078e98f6a80e3833377a583e37c04ec83691c0 Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Wed, 11 Feb 2026 17:07:56 +0100 Subject: [PATCH 17/18] test: replace setTimeout waits with polling Replace fixed setTimeout delays with waitForDiagnostics and waitForDefinitions polling helpers. This eliminates timing-dependent false passes (the Go-to-Definition test had an early return that silently passed when definitions weren't ready yet). --- .../vscode/test/suite/bundled-schema.test.ts | 77 ++++++++++++------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/tooling/lsp-clients/vscode/test/suite/bundled-schema.test.ts b/tooling/lsp-clients/vscode/test/suite/bundled-schema.test.ts index fe8cec2e..d31fdd39 100644 --- a/tooling/lsp-clients/vscode/test/suite/bundled-schema.test.ts +++ b/tooling/lsp-clients/vscode/test/suite/bundled-schema.test.ts @@ -18,6 +18,50 @@ describe('Bundled Schema Support Tests', () => { return vscode.extensions.getExtension('kson.kson'); } + /** + * Poll until diagnostics reach the expected count, or timeout. + */ + async function waitForDiagnostics(uri: vscode.Uri, expectedCount: number, timeout: number = 5000): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + const diagnostics = vscode.languages.getDiagnostics(uri); + if (diagnostics.length === expectedCount) { + return diagnostics; + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + + throw new Error(`Timeout waiting for ${expectedCount} diagnostics, found ${vscode.languages.getDiagnostics(uri).length}`); + } + + /** + * Poll until Go to Definition returns results, or timeout. + */ + async function waitForDefinitions( + uri: vscode.Uri, + position: vscode.Position, + timeout: number = 5000 + ): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + const definitions = await vscode.commands.executeCommand< + vscode.Location[] | vscode.LocationLink[] + >( + 'vscode.executeDefinitionProvider', + uri, + position + ); + if (definitions && definitions.length > 0) { + return definitions; + } + await new Promise(resolve => setTimeout(resolve, 200)); + } + + throw new Error(`Timeout waiting for definitions at ${uri.toString()}:${position.line}:${position.character}`); + } + describe('Configuration', () => { it('Should have enableBundledSchemas setting defined', async function () { const extension = getExtension(); @@ -74,12 +118,8 @@ describe('Bundled Schema Support Tests', () => { assert.ok(document, 'Document should be created'); assert.strictEqual(document.languageId, 'kson', 'Should have kson language ID'); - // Wait a bit for the language server to process - await new Promise(resolve => setTimeout(resolve, 500)); - - // No errors should occur - this is a basic sanity check - const diagnostics = vscode.languages.getDiagnostics(document.uri); - // Should have 0 diagnostics for valid KSON + // Wait for diagnostics to settle (0 expected for valid KSON) + const diagnostics = await waitForDiagnostics(document.uri, 0); assert.strictEqual(diagnostics.length, 0, 'Valid KSON should have no diagnostics'); } finally { await cleanUp(testFileUri); @@ -102,8 +142,8 @@ describe('Bundled Schema Support Tests', () => { // Make sure the document is shown await vscode.window.showTextDocument(document); - // Wait for status bar to update - await new Promise(resolve => setTimeout(resolve, 500)); + // Wait for diagnostics to settle (ensures server has processed) + await waitForDiagnostics(document.uri, 0); // We can't directly test the status bar content from tests, // but we verify the document is properly set up @@ -214,32 +254,15 @@ describe('Bundled Schema Support Tests', () => { 'Document should have language ID \'kson\'' ); - // Wait for the language server to process the document and associate the schema - await new Promise(resolve => setTimeout(resolve, 1000)); - // Position the cursor on the "type" property (line 1) const position = new vscode.Position(1, 1); - // Execute Go to Definition command - const definitions = await vscode.commands.executeCommand< - vscode.Location[] | vscode.LocationLink[] - >( - 'vscode.executeDefinitionProvider', - document.uri, - position - ); + // Poll until definitions become available (schema association may take time) + const definitions = await waitForDefinitions(document.uri, position, 10000); // Log results for debugging console.log('Definition results for file with $schema:', JSON.stringify(definitions, null, 2)); - // Verify definitions were returned - if (!definitions || definitions.length === 0) { - console.log('No definitions returned - schema may not be associated yet'); - // This isn't necessarily a failure - the schema association might not be complete - // The important thing is that when definitions ARE returned, they work - return; - } - // Get the definition URI (handle both Location and LocationLink types) const firstDef = definitions[0]; const definitionUri = 'uri' in firstDef From 5f293d072dc56fe3ad46e20665ac0aaa62e587a1 Mon Sep 17 00:00:00 2001 From: Bart Dubbeldam Date: Thu, 12 Feb 2026 12:36:20 +0100 Subject: [PATCH 18/18] refactor: extract waitFor helpers Deduplicate waitForDiagnostics, waitForDefinitions, waitForHover, and waitForCompletions from 5 test files into common.ts, backed by a generic pollUntil helper. --- .../vscode/test/suite/bundled-schema.test.ts | 46 +--------- .../lsp-clients/vscode/test/suite/common.ts | 84 +++++++++++++++++++ .../vscode/test/suite/diagnostics.test.ts | 16 +--- .../vscode/test/suite/dialect-support.test.ts | 16 +--- .../vscode/test/suite/schema-loading.test.ts | 73 +--------------- .../status-bar-schema-association.test.ts | 56 +------------ 6 files changed, 89 insertions(+), 202 deletions(-) diff --git a/tooling/lsp-clients/vscode/test/suite/bundled-schema.test.ts b/tooling/lsp-clients/vscode/test/suite/bundled-schema.test.ts index d31fdd39..f6c61c2d 100644 --- a/tooling/lsp-clients/vscode/test/suite/bundled-schema.test.ts +++ b/tooling/lsp-clients/vscode/test/suite/bundled-schema.test.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { assert } from './assert'; -import { createTestFile, cleanUp } from './common'; +import { createTestFile, cleanUp, waitForDiagnostics, waitForDefinitions } from './common'; /** * Tests for bundled schema support. @@ -18,50 +18,6 @@ describe('Bundled Schema Support Tests', () => { return vscode.extensions.getExtension('kson.kson'); } - /** - * Poll until diagnostics reach the expected count, or timeout. - */ - async function waitForDiagnostics(uri: vscode.Uri, expectedCount: number, timeout: number = 5000): Promise { - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - const diagnostics = vscode.languages.getDiagnostics(uri); - if (diagnostics.length === expectedCount) { - return diagnostics; - } - await new Promise(resolve => setTimeout(resolve, 100)); - } - - throw new Error(`Timeout waiting for ${expectedCount} diagnostics, found ${vscode.languages.getDiagnostics(uri).length}`); - } - - /** - * Poll until Go to Definition returns results, or timeout. - */ - async function waitForDefinitions( - uri: vscode.Uri, - position: vscode.Position, - timeout: number = 5000 - ): Promise { - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - const definitions = await vscode.commands.executeCommand< - vscode.Location[] | vscode.LocationLink[] - >( - 'vscode.executeDefinitionProvider', - uri, - position - ); - if (definitions && definitions.length > 0) { - return definitions; - } - await new Promise(resolve => setTimeout(resolve, 200)); - } - - throw new Error(`Timeout waiting for definitions at ${uri.toString()}:${position.line}:${position.character}`); - } - describe('Configuration', () => { it('Should have enableBundledSchemas setting defined', async function () { const extension = getExtension(); diff --git a/tooling/lsp-clients/vscode/test/suite/common.ts b/tooling/lsp-clients/vscode/test/suite/common.ts index 8cdc3f82..3262dce4 100644 --- a/tooling/lsp-clients/vscode/test/suite/common.ts +++ b/tooling/lsp-clients/vscode/test/suite/common.ts @@ -37,4 +37,88 @@ function getWorkspaceFolder(): vscode.WorkspaceFolder { throw new Error('No workspace folder open'); } return workspaceFolders[0]; +} + +/** + * Generic polling helper. Repeatedly calls `fn` until `predicate` returns true, or timeout. + */ +export async function pollUntil( + fn: () => T | PromiseLike, + predicate: (result: T) => boolean, + options: { timeout?: number; interval?: number; message?: string } = {} +): Promise { + const { timeout = 5000, interval = 100, message = 'Condition not met' } = options; + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + const result = await fn(); + if (predicate(result)) { + return result; + } + await new Promise(resolve => setTimeout(resolve, interval)); + } + + throw new Error(`Timeout: ${message}`); +} + +/** + * Poll until diagnostics reach the expected count. + */ +export function waitForDiagnostics(uri: vscode.Uri, expectedCount: number, timeout: number = 5000): Promise { + return pollUntil( + () => vscode.languages.getDiagnostics(uri), + diagnostics => diagnostics.length === expectedCount, + { timeout, message: `Expected ${expectedCount} diagnostics, found ${vscode.languages.getDiagnostics(uri).length}` } + ); +} + +/** + * Poll until Go to Definition returns results. + */ +export function waitForDefinitions( + uri: vscode.Uri, + position: vscode.Position, + timeout: number = 5000 +): Promise { + return pollUntil( + () => vscode.commands.executeCommand( + 'vscode.executeDefinitionProvider', uri, position + ), + definitions => !!(definitions && definitions.length > 0), + { timeout, interval: 200, message: `No definitions at ${uri.toString()}:${position.line}:${position.character}` } + ); +} + +/** + * Poll until hover information is available. + */ +export function waitForHover( + document: vscode.TextDocument, + position: vscode.Position, + timeout: number = 5000 +): Promise { + return pollUntil( + () => vscode.commands.executeCommand( + 'vscode.executeHoverProvider', document.uri, position + ), + hovers => !!(hovers && hovers.length > 0), + { timeout, message: `No hover at position ${position.line}:${position.character}` } + ); +} + +/** + * Poll until completion items are available. + */ +export function waitForCompletions( + document: vscode.TextDocument, + position: vscode.Position, + timeout: number = 5000 +): Promise { + return pollUntil( + () => vscode.commands.executeCommand( + 'vscode.executeCompletionItemProvider', document.uri, position + ), + completions => !!(completions && completions.items.length > 0), + { timeout, message: `No completions at position ${position.line}:${position.character}` } + ); } \ No newline at end of file diff --git a/tooling/lsp-clients/vscode/test/suite/diagnostics.test.ts b/tooling/lsp-clients/vscode/test/suite/diagnostics.test.ts index 2008a975..cd3640ac 100644 --- a/tooling/lsp-clients/vscode/test/suite/diagnostics.test.ts +++ b/tooling/lsp-clients/vscode/test/suite/diagnostics.test.ts @@ -1,25 +1,11 @@ import * as vscode from 'vscode'; import { assert } from './assert'; -import {createTestFile, cleanUp} from './common'; +import {createTestFile, cleanUp, waitForDiagnostics} from './common'; import {DiagnosticSeverity} from "vscode"; describe('Diagnostic Tests', () => { - async function waitForDiagnostics(uri: vscode.Uri, expectedCount: number, timeout: number = 5000): Promise { - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - const diagnostics = vscode.languages.getDiagnostics(uri); - if (diagnostics.length === expectedCount) { - return diagnostics; - } - await new Promise(resolve => setTimeout(resolve, 100)); - } - - throw new Error(`Timeout waiting for ${expectedCount} diagnostics, found ${vscode.languages.getDiagnostics(uri).length}`); - } - it('Should report errors for an invalid Kson file', async () => { const errorContent = 'key: "value" extraValue'; const [testFileUri, document] = await createTestFile(errorContent); diff --git a/tooling/lsp-clients/vscode/test/suite/dialect-support.test.ts b/tooling/lsp-clients/vscode/test/suite/dialect-support.test.ts index 247e5fbd..b0095473 100644 --- a/tooling/lsp-clients/vscode/test/suite/dialect-support.test.ts +++ b/tooling/lsp-clients/vscode/test/suite/dialect-support.test.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { assert } from './assert'; -import { createTestFile, cleanUp } from './common'; +import { createTestFile, cleanUp, waitForDiagnostics } from './common'; /** * Tests for KSON dialect support. @@ -10,20 +10,6 @@ import { createTestFile, cleanUp } from './common'; */ describe('Dialect Support Tests', () => { - async function waitForDiagnostics(uri: vscode.Uri, expectedCount: number, timeout: number = 5000): Promise { - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - const diagnostics = vscode.languages.getDiagnostics(uri); - if (diagnostics.length === expectedCount) { - return diagnostics; - } - await new Promise(resolve => setTimeout(resolve, 100)); - } - - throw new Error(`Timeout waiting for ${expectedCount} diagnostics, found ${vscode.languages.getDiagnostics(uri).length}`); - } - it('Should report diagnostics for invalid dialect file', async function() { // Skip this test if no dialects are configured const extension = vscode.extensions.getExtension('kson.kson'); diff --git a/tooling/lsp-clients/vscode/test/suite/schema-loading.test.ts b/tooling/lsp-clients/vscode/test/suite/schema-loading.test.ts index 2173e358..5199c606 100644 --- a/tooling/lsp-clients/vscode/test/suite/schema-loading.test.ts +++ b/tooling/lsp-clients/vscode/test/suite/schema-loading.test.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { assert } from './assert'; -import {createTestFile, cleanUp} from './common'; +import {createTestFile, cleanUp, waitForDiagnostics, waitForHover, waitForCompletions} from './common'; import { v4 as uuid } from 'uuid'; @@ -148,77 +148,6 @@ describeNode('Schema Loading Tests', () => { await cleanUpSchemaFiles(); }); - /** - * Wait for hover information to be available at a specific position. - */ - async function waitForHover( - document: vscode.TextDocument, - position: vscode.Position, - timeout: number = 5000 - ): Promise { - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - const hovers = await vscode.commands.executeCommand( - 'vscode.executeHoverProvider', - document.uri, - position - ); - - if (hovers && hovers.length > 0) { - return hovers; - } - - await new Promise(resolve => setTimeout(resolve, 100)); - } - - throw new Error(`Timeout waiting for hover information at position ${position.line}:${position.character}`); - } - - /** - * Wait for completion items to be available at a specific position. - */ - async function waitForCompletions( - document: vscode.TextDocument, - position: vscode.Position, - timeout: number = 5000 - ): Promise { - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - const completions = await vscode.commands.executeCommand( - 'vscode.executeCompletionItemProvider', - document.uri, - position - ); - - if (completions && completions.items.length > 0) { - return completions; - } - - await new Promise(resolve => setTimeout(resolve, 100)); - } - - throw new Error(`Timeout waiting for completions at position ${position.line}:${position.character}`); - } - - /** - * Wait for diagnostics to be available. - */ - async function waitForDiagnostics(uri: vscode.Uri, expectedCount: number, timeout: number = 5000): Promise { - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - const diagnostics = vscode.languages.getDiagnostics(uri); - if (diagnostics.length === expectedCount) { - return diagnostics; - } - await new Promise(resolve => setTimeout(resolve, 100)); - } - - throw new Error(`Timeout waiting for ${expectedCount} diagnostics, found ${vscode.languages.getDiagnostics(uri).length}`); - } - it('Should load schema for files matching fileMatch pattern', async () => { // Create a test file that matches the pattern "**/*.config.kson" const content = [ diff --git a/tooling/lsp-clients/vscode/test/suite/status-bar-schema-association.test.ts b/tooling/lsp-clients/vscode/test/suite/status-bar-schema-association.test.ts index c3c187dc..943359a0 100644 --- a/tooling/lsp-clients/vscode/test/suite/status-bar-schema-association.test.ts +++ b/tooling/lsp-clients/vscode/test/suite/status-bar-schema-association.test.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { assert } from './assert'; -import { createTestFile, cleanUp } from './common'; +import { createTestFile, cleanUp, waitForCompletions, waitForHover } from './common'; import { v4 as uuid } from 'uuid'; const TEST_SCHEMA_FILENAME = `${uuid()}.schema.kson`; @@ -110,60 +110,6 @@ describeNode('Status Bar Schema Association Tests', () => { } } - /** - * Wait for completion items to be available at a specific position. - */ - async function waitForCompletions( - document: vscode.TextDocument, - position: vscode.Position, - timeout: number = 5000 - ): Promise { - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - const completions = await vscode.commands.executeCommand( - 'vscode.executeCompletionItemProvider', - document.uri, - position - ); - - if (completions && completions.items.length > 0) { - return completions; - } - - await new Promise(resolve => setTimeout(resolve, 100)); - } - - throw new Error(`Timeout waiting for completions at position ${position.line}:${position.character}`); - } - - /** - * Wait for hover information to be available at a specific position. - */ - async function waitForHover( - document: vscode.TextDocument, - position: vscode.Position, - timeout: number = 5000 - ): Promise { - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - const hovers = await vscode.commands.executeCommand( - 'vscode.executeHoverProvider', - document.uri, - position - ); - - if (hovers && hovers.length > 0) { - return hovers; - } - - await new Promise(resolve => setTimeout(resolve, 100)); - } - - throw new Error(`Timeout waiting for hover information at position ${position.line}:${position.character}`); - } - /** * Associate a schema with the test document via the status bar command. * This simulates the user clicking the status bar and selecting a schema file.