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 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 ec1b118b..571dfc63 100644 --- a/tooling/language-server-protocol/src/core/document/KsonDocumentsManager.ts +++ b/tooling/language-server-protocol/src/core/document/KsonDocumentsManager.ts @@ -1,9 +1,38 @@ import {TextDocument} from 'vscode-languageserver-textdocument'; 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"; +/** + * Resolve the appropriate KsonDocument type for a given document. + * + * 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 resolveDocument( + provider: SchemaProvider, + textDocument: TextDocument, + parseResult: ReturnType['analyze']> +): KsonDocument { + const schema = provider.getSchemaForDocument(textDocument.uri); + if (schema) { + return new KsonDocument(textDocument, parseResult, schema); + } + + const schemaId = KsonDocument.extractSchemaId(parseResult); + if (schemaId) { + const metaSchema = provider.getMetaSchemaForId(schemaId); + if (metaSchema) { + return new KsonSchemaDocument(textDocument, parseResult, metaSchema); + } + } + + return new KsonDocument(textDocument, parseResult); +} + /** * Document management for the Kson Language Server. * The {@link KsonDocumentsManager} keeps track of all {@link KsonDocument}'s that @@ -25,12 +54,8 @@ export class KsonDocumentsManager extends TextDocuments { content: string ): KsonDocument => { const textDocument = TextDocument.create(uri, languageId, version, content); - - // Try to get schema from provider - let schemaDocument = provider.getSchemaForDocument(uri); - const parseResult = Kson.getInstance().analyze(content, uri); - return new KsonDocument(textDocument, parseResult, schemaDocument); + return resolveDocument(provider, textDocument, parseResult); }, update: ( ksonDocument: KsonDocument, @@ -43,11 +68,7 @@ export class KsonDocumentsManager extends TextDocuments { version ); const parseResult = Kson.getInstance().analyze(textDocument.getText(), ksonDocument.uri); - return new KsonDocument( - textDocument, - parseResult, - provider.getSchemaForDocument(ksonDocument.uri) - ); + return resolveDocument(provider, textDocument, parseResult); } }); @@ -83,10 +104,8 @@ 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); - // 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 251a0b1e..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'; /** @@ -19,11 +20,20 @@ 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 = isKsonSchemaDocument(document) + ? document.getMetaSchemaDocument() + : document.getSchemaDocument(); if (schemaDocument) { const locations = tooling.getSchemaLocationAtLocation( document.getText(), @@ -31,17 +41,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/features/DiagnosticService.ts b/tooling/language-server-protocol/src/core/features/DiagnosticService.ts index d3711285..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'; /** @@ -22,17 +23,23 @@ 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 = isKsonSchemaDocument(document) + ? document.getMetaSchemaDocument() + : 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(); } /** 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/BundledSchemaProvider.ts b/tooling/language-server-protocol/src/core/schema/BundledSchemaProvider.ts new file mode 100644 index 00000000..63051d3d --- /dev/null +++ b/tooling/language-server-protocol/src/core/schema/BundledSchemaProvider.ts @@ -0,0 +1,230 @@ +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 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; +} + +/** + * 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. + * + * 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; + private logger?: BundledSchemaProviderOptions['logger']; + + constructor(options: BundledSchemaProviderOptions) { + const { schemas, metaSchemas = [], enabled = true, logger } = options; + this.enabled = enabled; + this.logger = logger; + this.schemas = new Map(); + this.metaSchemas = new Map(); + + // 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`; + const schemaDocument = TextDocument.create( + schemaUri, + 'kson', + 1, + config.schemaContent + ); + 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.fileExtension}: ${error}`); + } + } + + // 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 file extension. + * + * @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): TextDocument | undefined { + if (!this.enabled) { + return undefined; + } + + 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 = 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; + } + + /** + * 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 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/') || fileUri.startsWith('bundled://metaschema/')) + && fileUri.endsWith('.schema.kson'); + } + + /** + * 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 file extensions that have bundled schemas. + */ + getAvailableFileExtensions(): string[] { + return Array.from(this.schemas.keys()); + } + + /** + * Check if a bundled schema exists for a given file extension. + */ + 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 new file mode 100644 index 00000000..fcccd675 --- /dev/null +++ b/tooling/language-server-protocol/src/core/schema/CompositeSchemaProvider.ts @@ -0,0 +1,92 @@ +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 + * @returns TextDocument containing the schema, or undefined if no provider has a schema + */ + getSchemaForDocument(documentUri: DocumentUri): TextDocument | undefined { + for (const provider of this.providers) { + const schema = provider.getSchemaForDocument(documentUri); + if (schema) { + return schema; + } + } + return undefined; + } + + /** + * Reload all providers' configurations. + */ + reload(): void { + for (const provider of this.providers) { + provider.reload(); + } + } + + /** + * 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. + * + * @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..d7ee39a8 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 2c4ffbf6..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. @@ -38,6 +47,10 @@ export class NoOpSchemaProvider implements SchemaProvider { return undefined; } + getMetaSchemaForId(_schemaId: string): TextDocument | undefined { + return undefined; + } + reload(): void { // No-op } diff --git a/tooling/language-server-protocol/src/index.ts b/tooling/language-server-protocol/src/index.ts new file mode 100644 index 00000000..3a47a1ff --- /dev/null +++ b/tooling/language-server-protocol/src/index.ts @@ -0,0 +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 e40cda79..1ba9b56e 100644 --- a/tooling/language-server-protocol/src/startKsonServer.ts +++ b/tooling/language-server-protocol/src/startKsonServer.ts @@ -7,14 +7,29 @@ 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'; import { ksonSettingsWithDefaults } from './core/KsonSettings.js'; import {SchemaProvider} from './core/schema/SchemaProvider.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"; +/** + * Initialization options passed from the VSCode client. + */ +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; +} + type SchemaProviderFactory = ( workspaceRootUri: URI | undefined, logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void } @@ -45,6 +60,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 +70,37 @@ 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 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 or metaschemas are configured + let schemaProvider: SchemaProvider | undefined; + if (bundledSchemas.length > 0 || bundledMetaSchemas.length > 0) { + bundledSchemaProvider = new BundledSchemaProvider({ + schemas: bundledSchemas, + metaSchemas: bundledMetaSchemas, + enabled: 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); @@ -139,7 +184,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; // Extract readable path from URI @@ -165,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(); @@ -183,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(); } }); @@ -198,6 +251,13 @@ 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); + notifySchemaChange(); + } + connection.console.info('Configuration updated'); }); 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 e9aba205..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 @@ -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'; @@ -104,9 +105,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 +150,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 KsonSchemaDocument(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 + // 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 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 +189,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 KsonSchemaDocument(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 + // 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 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 +219,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 KsonSchemaDocument(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 + // 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 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 +264,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 +286,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 +310,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 +330,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 KsonSchemaDocument(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 + // 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 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 +368,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 KsonSchemaDocument(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) + // 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 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 new file mode 100644 index 00000000..146c5db8 --- /dev/null +++ b/tooling/language-server-protocol/src/test/core/schema/BundledSchemaProvider.test.ts @@ -0,0 +1,314 @@ +import {describe, it} from 'mocha'; +import * as assert from 'assert'; +import {BundledSchemaProvider, BundledSchemaConfig, BundledMetaSchemaConfig} 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({ 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'))); + }); + + it('should create provider with schemas', () => { + const schemas: BundledSchemaConfig[] = [ + { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } + ]; + const provider = new BundledSchemaProvider({ schemas, logger }); + + assert.ok(provider); + 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[] = [ + { fileExtension: 'ext-a', schemaContent: '{ "type": "object" }' }, + { fileExtension: 'ext-b', schemaContent: '{ "type": "array" }' } + ]; + const provider = new BundledSchemaProvider({ schemas, logger }); + + 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({ schemas: [], metaSchemas, logger }); + + 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[] = [ + { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } + ]; + const provider = new BundledSchemaProvider({ schemas, enabled: 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[] = [ + { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } + ]; + const provider = new BundledSchemaProvider({ schemas, enabled: false, logger }); + + const schema = provider.getSchemaForDocument('file:///test.kxt'); + assert.strictEqual(schema, undefined); + }); + + it('should return undefined when no file extension in URI', () => { + const schemas: BundledSchemaConfig[] = [ + { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } + ]; + const provider = new BundledSchemaProvider({ schemas, logger }); + + const schema = provider.getSchemaForDocument('file:///test'); + assert.strictEqual(schema, undefined); + }); + + it('should return undefined for unknown file extension', () => { + const schemas: BundledSchemaConfig[] = [ + { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } + ]; + const provider = new BundledSchemaProvider({ schemas, logger }); + + const schema = provider.getSchemaForDocument('file:///test.unknown'); + assert.strictEqual(schema, undefined); + }); + + it('should return schema for matching file extension', () => { + const schemaContent = '{ "type": "object" }'; + const schemas: BundledSchemaConfig[] = [ + { fileExtension: 'kxt', schemaContent } + ]; + const provider = new BundledSchemaProvider({ schemas, logger }); + + const schema = provider.getSchemaForDocument('file:///test.kxt'); + assert.ok(schema); + assert.strictEqual(schema!.getText(), schemaContent); + assert.strictEqual(schema!.uri, 'bundled://schema/kxt.schema.kson'); + }); + + it('should return same schema for different paths with same extension', () => { + const schemaContent = '{ "type": "object" }'; + const schemas: BundledSchemaConfig[] = [ + { fileExtension: 'kxt', schemaContent } + ]; + const provider = new BundledSchemaProvider({ schemas, logger }); + + 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, 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, 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({ schemas: [], metaSchemas, logger }); + + 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({ schemas: [], metaSchemas, logger }); + + 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({ 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({ schemas: [], 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({ 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({ 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({ schemas: [], 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[] = [ + { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } + ]; + const provider = new BundledSchemaProvider({ schemas, 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.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.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({ schemas: [], metaSchemas, logger }); + + 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[] = [ + { fileExtension: 'kxt', schemaContent: '{ "type": "object" }' } + ]; + const provider = new BundledSchemaProvider({ schemas, logger }); + + // reload should not throw or change anything + provider.reload(); + + assert.strictEqual(provider.getAvailableFileExtensions().length, 1); + assert.ok(provider.hasBundledSchema('kxt')); + }); + }); + + describe('hasBundledSchema', () => { + it('should return true for configured extensions', () => { + const schemas: BundledSchemaConfig[] = [ + { fileExtension: 'ext-a', schemaContent: '{}' }, + { fileExtension: 'ext-b', schemaContent: '{}' } + ]; + const provider = new BundledSchemaProvider({ schemas, logger }); + + assert.strictEqual(provider.hasBundledSchema('ext-a'), true); + assert.strictEqual(provider.hasBundledSchema('ext-b'), true); + assert.strictEqual(provider.hasBundledSchema('ext-c'), false); + }); + }); + + describe('getAvailableFileExtensions', () => { + it('should return all configured file extensions', () => { + const schemas: BundledSchemaConfig[] = [ + { fileExtension: 'alpha', schemaContent: '{}' }, + { fileExtension: 'beta', schemaContent: '{}' }, + { fileExtension: 'gamma', schemaContent: '{}' } + ]; + const provider = new BundledSchemaProvider({ schemas, logger }); + + 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 new file mode 100644 index 00000000..f1b9f112 --- /dev/null +++ b/tooling/language-server-protocol/src/test/core/schema/CompositeSchemaProvider.test.ts @@ -0,0 +1,338 @@ +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 {BundledSchemaProvider, BundledMetaSchemaConfig} from '../../../core/schema/BundledSchemaProvider'; +import {SchemaProvider, NoOpSchemaProvider} from '../../../core/schema/SchemaProvider'; + +/** + * Minimal mock that simulates FileSystemSchemaProvider behavior (URI-based lookup). + * Only used because FileSystemSchemaProvider requires disk I/O. + */ +class UriSchemaProvider implements SchemaProvider { + private schemas: Map = new Map(); + + addSchema(documentUri: string, schema: TextDocument): void { + this.schemas.set(documentUri, schema); + } + + getSchemaForDocument(documentUri: DocumentUri): TextDocument | undefined { + return this.schemas.get(documentUri); + } + + getMetaSchemaForId(_schemaId: string): TextDocument | undefined { + return undefined; + } + + reload(): void {} + + isSchemaFile(fileUri: DocumentUri): boolean { + for (const schema of this.schemas.values()) { + if (schema.uri === fileUri) return true; + } + return false; + } + +} + +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 = []; + }); + + 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 p1 = new BundledSchemaProvider({ schemas: [], logger }); + const p2 = new BundledSchemaProvider({ schemas: [], logger }); + const provider = new CompositeSchemaProvider([p1, p2], 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 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'); + assert.strictEqual(schema, undefined); + }); + + it('should return schema from first matching provider', () => { + const uriProvider1 = new UriSchemaProvider(); + const uriProvider2 = new UriSchemaProvider(); + + const schema1 = TextDocument.create('file:///schema1.kson', 'kson', 1, '{ "from": "first" }'); + uriProvider1.addSchema('file:///test.kson', schema1); + + const schema2 = TextDocument.create('file:///schema2.kson', 'kson', 1, '{ "from": "second" }'); + uriProvider2.addSchema('file:///test.kson', schema2); + + const provider = new CompositeSchemaProvider([uriProvider1, uriProvider2], 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 uriProvider1 = new UriSchemaProvider(); + const uriProvider2 = new UriSchemaProvider(); + + // 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([uriProvider1, uriProvider2], logger); + const result = provider.getSchemaForDocument('file:///test.kson'); + + assert.ok(result); + assert.strictEqual(result!.getText(), '{ "from": "second" }'); + }); + + it('should match by file extension via BundledSchemaProvider', () => { + 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'); + + assert.ok(result); + assert.strictEqual(result!.uri, 'bundled://schema/kxt.schema.kson'); + }); + + it('should prioritize file system provider over bundled (typical usage)', () => { + // 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); + + // Bundled provider (has schema by extension) - lower priority + 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'); + + 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 UriSchemaProvider(); + + // Bundled provider has schema + 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'); + + 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({ schemas: [], metaSchemas, logger }); + + const metaSchemas2: BundledMetaSchemaConfig[] = [ + { schemaId: 'http://json-schema.org/draft-07/schema#', name: 'draft-07', schemaContent: '{ "from": "second" }' } + ]; + 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#'); + + assert.ok(result); + assert.strictEqual(result!.getText(), '{ "from": "first" }'); + }); + + it('should try next provider when first has no match', () => { + 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({ schemas: [], metaSchemas: metaSchemas2, logger }); + + 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({ 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#'); + + 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({ schemas: [], metaSchemas, logger }); + + 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); + assert.strictEqual(provider.isSchemaFile('file:///schema.kson'), false); + }); + + it('should return true when any provider considers it a schema file', () => { + const uriProvider = new UriSchemaProvider(); + 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({ 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({ schemas: [], logger }); + const p2 = new BundledSchemaProvider({ schemas: [], logger }); + + const provider = new CompositeSchemaProvider([p1, p2], logger); + assert.strictEqual(provider.isSchemaFile('file:///random.kson'), false); + }); + + it('should check all provider types', () => { + const uriProvider = new UriSchemaProvider(); + uriProvider.addSchema('file:///test.kson', TextDocument.create('file:///my-schema.kson', 'kson', 1, '{}')); + + const bundledProvider = new BundledSchemaProvider({ + schemas: [{ fileExtension: 'kxt', schemaContent: '{}' }], + logger + }); + + const provider = new CompositeSchemaProvider([uriProvider, bundledProvider], logger); + + 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); + }); + }); + + describe('reload', () => { + it('should reload all providers', () => { + let reloadCount = 0; + + class CountingProvider implements SchemaProvider { + getSchemaForDocument(): TextDocument | undefined { return undefined; } + getMetaSchemaForId(): 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 p1 = new BundledSchemaProvider({ schemas: [], logger }); + const p2 = new BundledSchemaProvider({ schemas: [], logger }); + const provider = new CompositeSchemaProvider([p1, p2], logger); + + const providers = provider.getProviders(); + + assert.strictEqual(providers.length, 2); + assert.strictEqual(providers[0], p1); + assert.strictEqual(providers[1], p2); + }); + }); + + describe('integration with NoOpSchemaProvider', () => { + it('should work with NoOpSchemaProvider as fallback', () => { + const bundled = new BundledSchemaProvider({ schemas: [], logger }); + const noOp = new NoOpSchemaProvider(); + + const provider = new CompositeSchemaProvider([bundled, noOp], logger); + + // 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/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..e69de29b 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/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 e5157bb6..cd17138f 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 {deactivate} from '../common/deactivate'; +import { loadBundledSchemas, loadBundledMetaSchemas, areBundledSchemasEnabled } from '../../config/bundledSchemaLoader'; +import { registerBundledSchemaContentProvider } from '../common/BundledSchemaContentProvider'; /** * Browser-specific activation function for the KSON extension. @@ -21,8 +22,21 @@ 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 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() + }); // In test environments, we need to support the vscode-test-web scheme // This is only needed for the test runner, not in production @@ -38,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, @@ -48,8 +62,20 @@ 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(bundledSchemas, bundledMetaSchemas) + ); + 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) { @@ -58,7 +84,3 @@ export async function activate(context: vscode.ExtensionContext) { vscode.window.showErrorMessage('Failed to activate KSON language support.'); } } - -deactivate().catch(error => { - console.error('Deactivation failed:', error); -}); \ 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 new file mode 100644 index 00000000..24a83d85 --- /dev/null +++ b/tooling/lsp-clients/vscode/src/client/common/BundledSchemaContentProvider.ts @@ -0,0 +1,69 @@ +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" (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); + } +} + +/** + * Register the bundled schema content provider. + * + * @param bundledSchemas The loaded bundled schemas + * @param bundledMetaSchemas The loaded bundled metaschemas + * @returns The registered disposable + */ +export function registerBundledSchemaContentProvider( + 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/common/StatusBarManager.ts b/tooling/lsp-clients/vscode/src/client/common/StatusBarManager.ts index b004ee3e..65d825e9 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 @@ -33,7 +34,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; } @@ -47,10 +48,21 @@ export class StatusBarManager { ); if (schemaInfo.hasSchema && schemaInfo.schemaPath) { + const isBundled = schemaInfo.schemaUri?.startsWith('bundled://') ?? false; + // 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 = isBundled + ? this.extractBundledSchemaName(schemaInfo.schemaPath) + : path.basename(schemaInfo.schemaPath); + + // Show bundled indicator + const bundledSuffix = isBundled ? ' (bundled)' : ''; + const icon = isBundled ? '$(package)' : '$(file-code)'; + + this.statusBarItem.text = `${icon} Schema: ${schemaFileName}${bundledSuffix}`; + this.statusBarItem.tooltip = 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,19 @@ export class StatusBarManager { this.statusBarItem.hide(); } + /** + * Extract a display name from a bundled schema URI. + * 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/', '') + .replace(/\.schema\.kson$/, ''); + } + return path.basename(schemaPath); + } + /** * Clean up resources */ 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 c37b320c..419f8593 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, @@ -12,6 +11,8 @@ import { } from '../../config/clientOptions'; import {StatusBarManager} from '../common/StatusBarManager'; import { isKsonDialect, initializeLanguageConfig } from '../../config/languageConfig'; +import { loadBundledSchemas, loadBundledMetaSchemas, areBundledSchemasEnabled } from '../../config/bundledSchemaLoader'; +import { registerBundledSchemaContentProvider } from '../common/BundledSchemaContentProvider'; /** * Node.js-specific activation function for the KSON extension. @@ -39,12 +40,34 @@ 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) - let languageClient = new LanguageClient("kson", serverOptions, clientOptions, false) + + // 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() + }); + 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 + context.subscriptions.push( + registerBundledSchemaContentProvider(bundledSchemas, bundledMetaSchemas) + ); + // Create status bar manager const statusBarManager = new StatusBarManager(languageClient); context.subscriptions.push(statusBarManager); @@ -182,7 +205,3 @@ export async function activate(context: vscode.ExtensionContext) { vscode.window.showErrorMessage('Failed to activate KSON language support.'); } } - -deactivate().catch(error => { - console.error('Deactivation failed:', error); -}); \ No newline at end of file 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..7d8f0110 --- /dev/null +++ b/tooling/lsp-clients/vscode/src/config/bundledSchemaLoader.ts @@ -0,0 +1,121 @@ +import * as vscode from 'vscode'; +import { getLanguageConfiguration, BundledSchemaMapping } from './languageConfig'; + +import type { BundledSchemaConfig, BundledMetaSchemaConfig } from 'kson-language-server'; +export type { BundledSchemaConfig, BundledMetaSchemaConfig }; + +/** + * Path to the metaschema file relative to the extension root. + * Currently uses JSON Schema Draft 7 as the schema for .schema.kson files. + */ +const METASCHEMA_PATH = './dist/extension/schemas/metaschema.draft7.kson'; + +/** + * 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 + * @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 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({ + fileExtension: mapping.fileExtension, + schemaContent + }); + } + } catch (error) { + logger?.warn(`Failed to load bundled schema for ${mapping.fileExtension}: ${error}`); + } + } + + logger?.info(`Loaded ${loadedSchemas.length} bundled schemas`); + 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. + */ +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.fileExtension} from ${mapping.schemaPath}`); + return schemaContent; + } catch (error) { + logger?.warn(`Schema file not found for .${mapping.fileExtension}: ${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..9e79f717 100644 --- a/tooling/lsp-clients/vscode/src/config/clientOptions.ts +++ b/tooling/lsp-clients/vscode/src/config/clientOptions.ts @@ -6,15 +6,24 @@ import { DocumentSelector, } from 'vscode-languageclient'; import { getLanguageConfiguration } from './languageConfig'; +import type { KsonInitializationOptions } from 'kson-language-server'; -// Create shared client options -export const createClientOptions = (outputChannel: vscode.OutputChannel): LanguageClientOptions => { +/** + * 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 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 @@ -24,6 +33,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..c091435f 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 file extension. + */ +export interface BundledSchemaMapping { + /** File extension this schema applies to (without leading dot) */ + fileExtension: 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 using file extension from lang.extensions[0] + const bundledSchemas: BundledSchemaMapping[] = languages + .filter((lang: any) => lang.extensions?.[0] && lang.bundledSchema) + .map((lang: any) => ({ + fileExtension: lang.extensions[0].replace(/^\./, ''), + 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 }; } 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..f6c61c2d --- /dev/null +++ b/tooling/lsp-clients/vscode/test/suite/bundled-schema.test.ts @@ -0,0 +1,259 @@ +import * as vscode from 'vscode'; +import { assert } from './assert'; +import { createTestFile, cleanUp, waitForDiagnostics, waitForDefinitions } from './common'; + +/** + * Tests for bundled schema support. + * + * These tests verify that: + * 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', () => { + /** + * 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 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', () => { + 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 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); + } + }).timeout(10000); + }); + + describe('Status Bar Integration', () => { + it('Should show status bar for KSON files', async function () { + 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 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 + 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('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); + }); + + describe('Bundled Schema Navigation', () => { + it('Should have bundled:// content provider registered', async function () { + const extension = getExtension(); + if (!extension) { + this.skip(); + return; + } + + // Create a bundled:// URI - even if no schemas exist, the provider should be registered + const bundledUri = vscode.Uri.parse('bundled://schema/test-language'); + + // 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\'' + ); + + // Position the cursor on the "type" property (line 1) + const position = new vscode.Position(1, 1); + + // 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)); + + // 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/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/language-config.test.ts b/tooling/lsp-clients/vscode/test/suite/language-config.test.ts index 9a966ea0..36216a2d 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 using file extension', () => { + 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].fileExtension, '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.fileExtension === 'kxt')); + assert.ok(config.bundledSchemas.some(s => s.fileExtension === 'config')); + }); + }); }); 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 32519472..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`; @@ -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\'', @@ -111,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. 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