From 718a8c88ed4e91628fadd8c8198afd0a9bd119ec Mon Sep 17 00:00:00 2001 From: Satyaki Ghosh Date: Thu, 8 Oct 2026 16:48:41 -0400 Subject: [PATCH] Suffix server command ids on the client and add the Marketplace icon --- package-lock.json | 4 +- package.json | 3 +- .../marketplace/aws-cloudformation-icon.png | Bin 0 -> 8936 bytes src/extension.ts | 3 +- src/lsp-server/CfnLanguageClient.ts | 30 ++++ src/lsp-server/LspCommands.ts | 16 +- .../SuffixedExecuteCommandFeature.ts | 97 +++++++++++ tst/lsp-server/LspCommands.test.ts | 32 +++- .../SuffixedExecuteCommandFeature.test.ts | 155 ++++++++++++++++++ 9 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 resources/marketplace/aws-cloudformation-icon.png create mode 100644 src/lsp-server/CfnLanguageClient.ts create mode 100644 src/lsp-server/SuffixedExecuteCommandFeature.ts create mode 100644 tst/lsp-server/SuffixedExecuteCommandFeature.test.ts diff --git a/package-lock.json b/package-lock.json index dd95b904d..26bbb7e3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aws-iac-vscode", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aws-iac-vscode", - "version": "1.0.0", + "version": "1.1.0", "license": "Apache-2.0", "dependencies": { "@base2/pretty-print-object": "1.0.2", diff --git a/package.json b/package.json index 2706e09a6..c1a576745 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "aws-iac-vscode", "displayName": "AWS CloudFormation", - "version": "1.0.0", + "version": "1.1.0", "description": "AWS CloudFormation language support: validation, completion, hover documentation, and cfn-lint diagnostics for JSON and YAML templates.", "publisher": "amazonwebservices", "author": "Amazon Web Services (AWS)", @@ -16,6 +16,7 @@ }, "qna": "https://github.com/aws-cloudformation/cloudformation-vscode/issues", "pricing": "Free", + "icon": "resources/marketplace/aws-cloudformation-icon.png", "galleryBanner": { "color": "#232F3E", "theme": "dark" diff --git a/resources/marketplace/aws-cloudformation-icon.png b/resources/marketplace/aws-cloudformation-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f4210093b3fba50f81827b1f06c8c783bb3f7bee GIT binary patch literal 8936 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 literal 0 HcmV?d00001 diff --git a/src/extension.ts b/src/extension.ts index 2b504d2f6..5b4ccc30e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,6 +4,7 @@ import { AwsCredentialsService } from './auth/AwsCredentials'; import { restartCommand, RestartServerCommand, updateRegion } from './commands/Commands'; import { CfnInlineCompletionProvider } from './inlineCompletion/InlineCompletionProvider'; import { CfnDevLspServerProvider } from './lsp-server/CfnDevLspServerProvider'; +import { CfnLanguageClient } from './lsp-server/CfnLanguageClient'; import { CfnRemoteLspServerProvider } from './lsp-server/CfnRemoteLspServerProvider'; import { CfnDocumentSelector, cfnInitializationOptions, cfnServerOptions } from './lsp-server/LspClientConfig'; import { LspLauncher, LspResolver } from './lsp-server/LspLauncher'; @@ -129,7 +130,7 @@ async function initialize(context: ExtensionContext) { errorHandler, }; - return new LanguageClient(ExtensionId, ExtensionName, cfnServerOptions(serverFile), clientOptions); + return new CfnLanguageClient(ExtensionId, ExtensionName, cfnServerOptions(serverFile), clientOptions); }; const launcher = new LspLauncher(resolver, clientFactory, { diff --git a/src/lsp-server/CfnLanguageClient.ts b/src/lsp-server/CfnLanguageClient.ts new file mode 100644 index 000000000..8e80b2658 --- /dev/null +++ b/src/lsp-server/CfnLanguageClient.ts @@ -0,0 +1,30 @@ +import { ExecuteCommandRequest } from 'vscode-languageclient'; +import { + DynamicFeature, + LanguageClient, + LanguageClientOptions, + ServerOptions, + StaticFeature, +} from 'vscode-languageclient/node'; +import { advertisedCommands } from './LspCommands'; +import { suffixCodeActionCommands, SuffixedExecuteCommandFeature } from './SuffixedExecuteCommandFeature'; + +/** A `LanguageClient` that registers the server's command ids with VS Code under this client's suffix. */ +export class CfnLanguageClient extends LanguageClient { + constructor(id: string, name: string, serverOptions: ServerOptions, clientOptions: LanguageClientOptions) { + super(id, name, serverOptions, clientOptions); + this.middleware.provideCodeActions = async (document, range, context, token, next) => { + const actions = await next(document, range, context, token); + if (actions) { + suffixCodeActionCommands(actions, advertisedCommands(this)); + } + return actions; + }; + } + + override registerFeature(feature: StaticFeature | DynamicFeature): void { + const replaced = + 'registrationType' in feature && feature.registrationType.method === ExecuteCommandRequest.method; + super.registerFeature(replaced ? new SuffixedExecuteCommandFeature(this) : feature); + } +} diff --git a/src/lsp-server/LspCommands.ts b/src/lsp-server/LspCommands.ts index 076bb5afe..ad494bc05 100644 --- a/src/lsp-server/LspCommands.ts +++ b/src/lsp-server/LspCommands.ts @@ -3,16 +3,28 @@ import { CfnLspClientName } from '../utils/ExtensionConfig'; export const UpdateRegionCommand = '/command/region/update'; +const CommandSuffix = `.${CfnLspClientName}`; + +/** The ids the running server advertised through `executeCommandProvider`. */ +export function advertisedCommands(client: LanguageClient): string[] { + return client.initializeResult?.capabilities.executeCommandProvider?.commands ?? []; +} + /** * Returns the id under which the running server registered `baseCommand`: suffixed when the server honours * `aws.commandSuffix`, unsuffixed when it predates the option. */ export function advertisedCommand(client: LanguageClient, baseCommand: string): string { - const advertised = client.initializeResult?.capabilities.executeCommandProvider?.commands ?? []; - const suffixed = `${baseCommand}.${CfnLspClientName}`; + const advertised = advertisedCommands(client); + const suffixed = suffixedCommand(baseCommand); const command = [suffixed, baseCommand].find((candidate) => advertised.includes(candidate)); if (!command) { throw new Error(`Language server does not advertise ${suffixed} (advertised: ${advertised.join(', ')})`); } return command; } + +/** The id this client registers `command` with VS Code under, whether or not the server already suffixed it. */ +export function suffixedCommand(command: string): string { + return command.endsWith(CommandSuffix) ? command : `${command}${CommandSuffix}`; +} diff --git a/src/lsp-server/SuffixedExecuteCommandFeature.ts b/src/lsp-server/SuffixedExecuteCommandFeature.ts new file mode 100644 index 000000000..bed9a678a --- /dev/null +++ b/src/lsp-server/SuffixedExecuteCommandFeature.ts @@ -0,0 +1,97 @@ +import { randomUUID } from 'crypto'; +import { CodeAction, Command, commands, Disposable } from 'vscode'; +import { ExecuteCommandRequest } from 'vscode-languageclient'; +import { + ClientCapabilities, + DynamicFeature, + ExecuteCommandRegistrationOptions, + FeatureState, + LanguageClient, + RegistrationData, + ServerCapabilities, +} from 'vscode-languageclient/node'; +import { suffixedCommand } from './LspCommands'; + +type CommandClient = Pick; + +/** + * Replaces the client library's execute-command feature. The server's command ids are registered with VS Code under + * this client's suffix even when the server predates `aws.commandSuffix` and advertises unsuffixed ids, so another + * extension hosting the same server in the window cannot collide with ours. Executions are forwarded to the server + * under the id it advertised. + */ +export class SuffixedExecuteCommandFeature implements DynamicFeature { + private readonly registrations = new Map(); + + constructor(private readonly client: CommandClient) {} + + get registrationType() { + return ExecuteCommandRequest.type; + } + + getState(): FeatureState { + return { kind: 'workspace', id: ExecuteCommandRequest.method, registrations: this.registrations.size > 0 }; + } + + fillClientCapabilities(capabilities: ClientCapabilities): void { + (capabilities.workspace ??= {}).executeCommand = { dynamicRegistration: true }; + } + + initialize(capabilities: ServerCapabilities): void { + if (capabilities.executeCommandProvider) { + this.register({ id: randomUUID(), registerOptions: capabilities.executeCommandProvider }); + } + } + + register(data: RegistrationData): void { + this.registrations.set( + data.id, + data.registerOptions.commands.map((command) => + commands.registerCommand( + suffixedCommand(command), + async (...args: unknown[]) => await this.execute(command, args), + ), + ), + ); + } + + unregister(id: string): void { + for (const registration of this.registrations.get(id) ?? []) { + registration.dispose(); + } + this.registrations.delete(id); + } + + clear(): void { + for (const id of this.registrations.keys()) { + this.unregister(id); + } + } + + private async execute(command: string, args: unknown[]): Promise { + try { + return await this.client.sendRequest(ExecuteCommandRequest.method, { command, arguments: args }); + } catch (error) { + // `defaultValue` is a required parameter: the client returns it for errors it swallows and rethrows the rest. + // eslint-disable-next-line unicorn/no-useless-undefined + return this.client.handleFailedRequest(ExecuteCommandRequest.type, undefined, error, undefined); + } + } +} + +/** + * Points the commands of server-provided code actions at the ids {@link SuffixedExecuteCommandFeature} registered. + * Commands the server did not advertise, such as ones contributed by the extension itself, are left alone. + */ +export function suffixCodeActionCommands(actions: (Command | CodeAction)[], advertised: readonly string[]): void { + for (const action of actions) { + const command = isCommand(action) ? action : action.command; + if (command && advertised.includes(command.command)) { + command.command = suffixedCommand(command.command); + } + } +} + +function isCommand(action: Command | CodeAction): action is Command { + return typeof action.command === 'string'; +} diff --git a/tst/lsp-server/LspCommands.test.ts b/tst/lsp-server/LspCommands.test.ts index 5f0c0d3aa..bcf60619b 100644 --- a/tst/lsp-server/LspCommands.test.ts +++ b/tst/lsp-server/LspCommands.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from 'vitest'; import { LanguageClient } from 'vscode-languageclient/node'; -import { advertisedCommand, UpdateRegionCommand } from '../../src/lsp-server/LspCommands'; +import { + advertisedCommand, + advertisedCommands, + suffixedCommand, + UpdateRegionCommand, +} from '../../src/lsp-server/LspCommands'; function clientAdvertising(commands: string[] | undefined): LanguageClient { return { @@ -46,3 +51,28 @@ describe('advertisedCommand', () => { expect(() => advertisedCommand(client, UpdateRegionCommand)).toThrow('does not advertise'); }); }); + +describe('suffixedCommand', () => { + it('appends the client suffix to an unsuffixed id', () => { + expect(suffixedCommand(UpdateRegionCommand)).toBe('/command/region/update.iac-vscode'); + }); + + it('keeps an id the server already suffixed for this client', () => { + expect(suffixedCommand('/command/region/update.iac-vscode')).toBe('/command/region/update.iac-vscode'); + }); + + it('suffixes an id carrying another client suffix rather than replacing it', () => { + expect(suffixedCommand('/command/region/update.other')).toBe('/command/region/update.other.iac-vscode'); + }); +}); + +describe('advertisedCommands', () => { + it('returns the ids the server advertised', () => { + expect(advertisedCommands(clientAdvertising(['/command/region/update']))).toEqual(['/command/region/update']); + }); + + it('returns no ids before initialization or when the server provides no commands', () => { + expect(advertisedCommands({} as unknown as LanguageClient)).toEqual([]); + expect(advertisedCommands(clientAdvertising(undefined))).toEqual([]); + }); +}); diff --git a/tst/lsp-server/SuffixedExecuteCommandFeature.test.ts b/tst/lsp-server/SuffixedExecuteCommandFeature.test.ts new file mode 100644 index 000000000..39519f0f5 --- /dev/null +++ b/tst/lsp-server/SuffixedExecuteCommandFeature.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CodeAction, commands } from 'vscode'; +import { ExecuteCommandRequest } from 'vscode-languageclient'; +import { LanguageClient } from 'vscode-languageclient/node'; +import { + suffixCodeActionCommands, + SuffixedExecuteCommandFeature, +} from '../../src/lsp-server/SuffixedExecuteCommandFeature'; + +vi.mock('vscode'); +vi.mock('vscode-languageclient/node'); +vi.mock('vscode-languageclient'); + +const ClearDiagnostic = '/command/template/clear-diagnostic'; +const UpdateRegion = '/command/region/update'; +const SuffixedClearDiagnostic = `${ClearDiagnostic}.iac-vscode`; +const SuffixedUpdateRegion = `${UpdateRegion}.iac-vscode`; + +type CommandHandler = (...args: unknown[]) => Promise; + +function registeredCommands(): Map { + return new Map( + vi.mocked(commands.registerCommand).mock.calls.map(([id, handler]) => [id, handler as CommandHandler]), + ); +} + +describe('SuffixedExecuteCommandFeature', () => { + let client: { sendRequest: ReturnType; handleFailedRequest: ReturnType }; + let feature: SuffixedExecuteCommandFeature; + + beforeEach(() => { + vi.mocked(commands.registerCommand) + .mockReset() + .mockImplementation(() => ({ dispose: vi.fn() })); + client = { + sendRequest: vi.fn().mockResolvedValue('result'), + handleFailedRequest: vi.fn(), + }; + feature = new SuffixedExecuteCommandFeature(client as unknown as LanguageClient); + }); + + it('registers for workspace/executeCommand and asks for dynamic registration', () => { + const capabilities: { workspace?: { executeCommand?: { dynamicRegistration?: boolean } } } = {}; + + feature.fillClientCapabilities(capabilities); + + expect(feature.registrationType).toBe(ExecuteCommandRequest.type); + expect(capabilities.workspace?.executeCommand).toEqual({ dynamicRegistration: true }); + expect(feature.getState()).toEqual({ kind: 'workspace', id: 'workspace/executeCommand', registrations: false }); + }); + + it('registers unsuffixed ids from a server that predates the command suffix under the suffixed id', () => { + feature.initialize({ executeCommandProvider: { commands: [ClearDiagnostic, UpdateRegion] } }); + + expect([...registeredCommands().keys()]).toEqual([SuffixedClearDiagnostic, SuffixedUpdateRegion]); + expect(feature.getState()).toMatchObject({ registrations: true }); + }); + + it('registers ids a server that honours the command suffix advertised as they are', () => { + feature.initialize({ executeCommandProvider: { commands: [SuffixedClearDiagnostic, SuffixedUpdateRegion] } }); + + expect([...registeredCommands().keys()]).toEqual([SuffixedClearDiagnostic, SuffixedUpdateRegion]); + }); + + it('registers nothing when the server provides no commands', () => { + feature.initialize({}); + + expect(commands.registerCommand).not.toHaveBeenCalled(); + expect(feature.getState()).toMatchObject({ registrations: false }); + }); + + it('forwards an execution to the server under the id the server advertised', async () => { + feature.initialize({ executeCommandProvider: { commands: [ClearDiagnostic] } }); + + const result = await registeredCommands().get(SuffixedClearDiagnostic)?.('file:///t.yaml', { id: 1 }); + + expect(result).toBe('result'); + expect(client.sendRequest).toHaveBeenCalledWith('workspace/executeCommand', { + command: ClearDiagnostic, + arguments: ['file:///t.yaml', { id: 1 }], + }); + }); + + it('hands a failed execution to the client', async () => { + const error = new Error('boom'); + client.sendRequest.mockRejectedValue(error); + client.handleFailedRequest.mockReturnValue('fallback'); + feature.initialize({ executeCommandProvider: { commands: [ClearDiagnostic] } }); + + const result = await registeredCommands().get(SuffixedClearDiagnostic)?.(); + + expect(result).toBe('fallback'); + expect(client.handleFailedRequest).toHaveBeenCalledWith( + ExecuteCommandRequest.type, + undefined, + error, + undefined, + ); + }); + + it('disposes the registrations of an id on unregister and all of them on clear', () => { + feature.register({ id: 'first', registerOptions: { commands: [ClearDiagnostic] } }); + feature.register({ id: 'second', registerOptions: { commands: [UpdateRegion] } }); + const [first, second] = vi.mocked(commands.registerCommand).mock.results.map((result) => result.value); + + feature.unregister('first'); + + expect(first.dispose).toHaveBeenCalledTimes(1); + expect(second.dispose).not.toHaveBeenCalled(); + expect(feature.getState()).toMatchObject({ registrations: true }); + + feature.clear(); + + expect(second.dispose).toHaveBeenCalledTimes(1); + expect(feature.getState()).toMatchObject({ registrations: false }); + }); +}); + +describe('suffixCodeActionCommands', () => { + const advertised = [ClearDiagnostic, UpdateRegion]; + + it('points code actions at the suffixed id of an advertised command', () => { + const action = { title: 'Remove', command: { title: 'Remove', command: ClearDiagnostic, arguments: ['u'] } }; + + suffixCodeActionCommands([action], advertised); + + expect(action.command).toEqual({ title: 'Remove', command: SuffixedClearDiagnostic, arguments: ['u'] }); + }); + + it('rewrites a bare command item', () => { + const command = { title: 'Update', command: UpdateRegion }; + + suffixCodeActionCommands([command], advertised); + + expect(command.command).toBe(SuffixedUpdateRegion); + }); + + it('leaves commands the server did not advertise and actions without a command alone', () => { + const own = { title: 'Extract', command: { title: 'Extract', command: 'aws.cloudformation.extract' } }; + const editOnly = { title: 'Fix', edit: {} }; + + suffixCodeActionCommands([own, editOnly as CodeAction], advertised); + + expect(own.command.command).toBe('aws.cloudformation.extract'); + expect(editOnly).toEqual({ title: 'Fix', edit: {} }); + }); + + it('is a no-op for a server that already suffixes its commands', () => { + const action = { title: 'Remove', command: { title: 'Remove', command: SuffixedClearDiagnostic } }; + + suffixCodeActionCommands([action], [SuffixedClearDiagnostic, SuffixedUpdateRegion]); + + expect(action.command.command).toBe(SuffixedClearDiagnostic); + }); +});