diff --git a/tooling/language-server-protocol/src/core/commands/CommandExecutor.node.ts b/tooling/language-server-protocol/src/core/commands/CommandExecutor.node.ts index d7f249133..29e2b88a0 100644 --- a/tooling/language-server-protocol/src/core/commands/CommandExecutor.node.ts +++ b/tooling/language-server-protocol/src/core/commands/CommandExecutor.node.ts @@ -3,6 +3,7 @@ import {CommandParameters} from './CommandParameters.js'; import {CommandType} from './CommandType.js'; import {AssociateSchemaCommand} from './AssociateSchemaCommand.js'; import {RemoveSchemaCommand} from './RemoveSchemaCommand.js'; +import {notifySchemaChange} from '../schema/notifySchemaChange.js'; /** * Node.js implementation of CommandExecutor with file system support @@ -19,6 +20,7 @@ export class CommandExecutor extends CommandExecutorBase { }); if (result.success) { + this.applySchemaConfigurationChange(); this.connection.window.showInformationMessage(result.message); } else { this.connection.window.showErrorMessage(result.message); @@ -37,6 +39,7 @@ export class CommandExecutor extends CommandExecutorBase { }); if (result.success) { + this.applySchemaConfigurationChange(); this.connection.window.showInformationMessage(result.message); } else { this.connection.window.showErrorMessage(result.message); @@ -44,4 +47,12 @@ export class CommandExecutor extends CommandExecutorBase { return result; } -} \ No newline at end of file + + /** + * Ensure latest schema configuration change is reloaded. + */ + private applySchemaConfigurationChange(): void { + this.documentManager.reloadSchemaConfiguration(); + notifySchemaChange(this.connection, this.documentManager); + } +} diff --git a/tooling/language-server-protocol/src/core/schema/notifySchemaChange.ts b/tooling/language-server-protocol/src/core/schema/notifySchemaChange.ts new file mode 100644 index 000000000..299ff076c --- /dev/null +++ b/tooling/language-server-protocol/src/core/schema/notifySchemaChange.ts @@ -0,0 +1,8 @@ +import {Connection} from 'vscode-languageserver'; +import {KsonDocumentsManager} from '../document/KsonDocumentsManager.js'; + +export function notifySchemaChange(connection: Connection, documentManager: KsonDocumentsManager): void { + documentManager.refreshDocumentSchemas(); + connection.sendNotification('kson/schemaConfigurationChanged'); + connection.sendRequest('workspace/diagnostic/refresh'); +} diff --git a/tooling/language-server-protocol/src/startKsonServer.ts b/tooling/language-server-protocol/src/startKsonServer.ts index 99e118abf..632831293 100644 --- a/tooling/language-server-protocol/src/startKsonServer.ts +++ b/tooling/language-server-protocol/src/startKsonServer.ts @@ -17,6 +17,7 @@ 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 {notifySchemaChange} from "./core/schema/notifySchemaChange.js"; import {CommandExecutorFactory} from "./core/commands/CommandExecutorFactory"; /** @@ -256,16 +257,6 @@ 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(); @@ -284,7 +275,7 @@ export function startKsonServer( } if (schemaChanged) { - notifySchemaChange(); + notifySchemaChange(connection, documentManager); } }); @@ -302,7 +293,7 @@ export function startKsonServer( if (bundledSchemaProvider && scoped?.enableBundledSchemas !== undefined) { bundledSchemaProvider.setEnabled(scoped.enableBundledSchemas); - notifySchemaChange(); + notifySchemaChange(connection, documentManager); } connection.console.info('Configuration updated'); diff --git a/tooling/language-server-protocol/src/test/core/commands/CommandExecutor.test.ts b/tooling/language-server-protocol/src/test/core/commands/CommandExecutor.test.ts index 25ba2cf52..2c43fb9b8 100644 --- a/tooling/language-server-protocol/src/test/core/commands/CommandExecutor.test.ts +++ b/tooling/language-server-protocol/src/test/core/commands/CommandExecutor.test.ts @@ -1,5 +1,5 @@ import {TextDocument} from 'vscode-languageserver-textdocument'; -import {beforeEach, describe, it} from 'mocha'; +import {afterEach, beforeEach, describe, it} from 'mocha'; import assert from "assert"; import {ConnectionStub} from "../../ConnectionStub"; import {KsonTextDocumentService} from "../../../core/services/KsonTextDocumentService"; @@ -9,13 +9,21 @@ import { ExecuteCommandParams, ApplyWorkspaceEditParams, TextEdit, - ApplyWorkspaceEditResult, Range + ApplyWorkspaceEditResult, Range, + CompletionList } from "vscode-languageserver"; import {CommandType, toWireCommandId} from "../../../core/commands/CommandType"; import {FormattingStyle} from "kson"; import {FormattingStyleId, formattingStyleId} from "../../../core/formattingStyle"; import {RemoteWorkspace} from "vscode-languageserver/lib/common/server"; import {createCommandExecutor} from "../../../core/commands/createCommandExecutor.node.js"; +import {SchemaProvider} from "../../../core/schema/SchemaProvider"; +import {FileSystemSchemaProvider} from "../../../core/schema/FileSystemSchemaProvider"; +import {pos} from "../../TestHelpers.js"; +import {URI} from "vscode-uri"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; class WorkspaceConnectionStub extends ConnectionStub { private capturedEdits: ApplyWorkspaceEditParams | undefined; @@ -36,12 +44,43 @@ class WorkspaceConnectionStub extends ConnectionStub { } } +/** + * Captures the client-facing traffic a schema change produces: the + * `kson/schemaConfigurationChanged` notification, the diagnostic refresh + * request, and the window messages the schema commands report with. + */ +class SchemaConnectionStub extends ConnectionStub { + readonly notifications: string[] = []; + readonly requests: string[] = []; + + constructor() { + super(); + this.window = { + showInformationMessage: () => Promise.resolve(undefined), + showErrorMessage: () => Promise.resolve(undefined) + } as any; + } + + override sendNotification(method: unknown): Promise { + this.notifications.push(String(method)); + return Promise.resolve(); + } + + override sendRequest(method: unknown): Promise { + this.requests.push(String(method)); + return Promise.resolve(undefined as R); + } +} + const TEST_DISTRIBUTION_ID = 'test-ns'; -function createTestSetup() { - const connection = new ConnectionStub(); - const documentsManager = new KsonDocumentsManager(); - const service = new KsonTextDocumentService(documentsManager, createCommandExecutor, null, TEST_DISTRIBUTION_ID); +function createTestSetup( + connection: ConnectionStub = new ConnectionStub(), + workspaceRoot: string | null = null, + schemaProvider?: SchemaProvider +) { + const documentsManager = new KsonDocumentsManager(schemaProvider); + const service = new KsonTextDocumentService(documentsManager, createCommandExecutor, workspaceRoot, TEST_DISTRIBUTION_ID); documentsManager.listen(connection); service.connect(connection); @@ -167,3 +206,93 @@ describe('KSON Command Executor', () => { }); }); + +/** + * The schema commands write `.kson-schema.kson` themselves, so the server has to + * pick the change up on its own. + */ +describe('KSON Command Executor schema association', () => { + const SCHEMA_FILENAME = 'status.schema.kson'; + const SCHEMA_CONTENT = [ + '{', + ' type: object', + ' properties: {', + ' status: {', + ' type: string', + ' enum: ["active", "inactive", "pending"]', + ' }', + ' }', + '}' + ].join('\n'); + const DOCUMENT_CONTENT = '{\n status: "ac"\n}'; + // Caret between `ac` and the closing quote: still authoring, so enum completions apply. + const VALUE_POSITION = pos(1, 15); + const SCHEMA_CHANGED_NOTIFICATION = 'kson/schemaConfigurationChanged'; + + let workspaceRoot: string; + let documentUri: string; + let connection: SchemaConnectionStub; + let documentsManager: KsonDocumentsManager; + + beforeEach(() => { + workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'kson-test-')); + fs.writeFileSync(path.join(workspaceRoot, SCHEMA_FILENAME), SCHEMA_CONTENT, 'utf-8'); + documentUri = URI.file(path.join(workspaceRoot, 'settings.kson')).toString(); + + connection = new SchemaConnectionStub(); + documentsManager = createTestSetup( + connection, + workspaceRoot, + new FileSystemSchemaProvider(URI.file(workspaceRoot)) + ).documentsManager; + + connection.didOpenHandler(createDidOpenParams(documentUri, DOCUMENT_CONTENT)); + }); + + afterEach(() => { + fs.rmSync(workspaceRoot, {recursive: true, force: true}); + }); + + function executeSchemaCommand(command: CommandType, args: object = {}): Promise { + return connection.executeCommandHandler( + { + command: toWireCommandId(command, TEST_DISTRIBUTION_ID), + arguments: [{documentUri, ...args}] + }, + {} as any, {} as any, undefined + ); + } + + async function completionLabels(): Promise { + // The handler's declared type allows a ResponseError; the completion path never returns one. + const completions = await connection.requestCompletion(documentUri, VALUE_POSITION) as CompletionList | null; + return completions?.items.map(item => item.label); + } + + it('should apply an associated schema', async () => { + const result = await executeSchemaCommand(CommandType.ASSOCIATE_SCHEMA, {schemaPath: SCHEMA_FILENAME}); + + assert.strictEqual(result.success, true, result.message); + assert.ok( + documentsManager.get(documentUri)?.getSchemaDocument(), + 'the open document should carry the schema it was just associated with' + ); + assert.deepStrictEqual(await completionLabels(), ['active', 'inactive', 'pending']); + assert.deepStrictEqual(connection.notifications, [SCHEMA_CHANGED_NOTIFICATION]); + assert.deepStrictEqual(connection.requests, ['workspace/diagnostic/refresh']); + }); + + it('should drop the schema when the association is removed', async () => { + await executeSchemaCommand(CommandType.ASSOCIATE_SCHEMA, {schemaPath: SCHEMA_FILENAME}); + + const result = await executeSchemaCommand(CommandType.REMOVE_SCHEMA); + + assert.strictEqual(result.success, true, result.message); + assert.strictEqual(documentsManager.get(documentUri)?.getSchemaDocument(), undefined); + assert.strictEqual(await completionLabels(), undefined); + assert.deepStrictEqual( + connection.notifications, + [SCHEMA_CHANGED_NOTIFICATION, SCHEMA_CHANGED_NOTIFICATION] + ); + }); +});