From 2be9db59cefbc8de410ee4765ce666626cd803b8 Mon Sep 17 00:00:00 2001 From: "Brandon Waterloo [MSFT]" <36966225+bwateratmsft@users.noreply.github.com> Date: Tue, 17 Dec 2024 15:01:37 -0500 Subject: [PATCH] Add a CodeLens for starting services individually or all at once (#158) --- ...teYamlLanguageServiceClientCapabilities.ts | 1 + src/service/ComposeLanguageService.ts | 13 ++ .../ServiceStartupCodeLensProvider.ts | 80 ++++++++++ ...ternateYamlLanguageServiceClientFeature.ts | 1 + .../ServiceStartupCodeLensProvider.test.ts | 137 ++++++++++++++++++ 5 files changed, 232 insertions(+) create mode 100644 src/service/providers/ServiceStartupCodeLensProvider.ts create mode 100644 src/test/providers/ServiceStartupCodeLensProvider.test.ts diff --git a/src/client/AlternateYamlLanguageServiceClientCapabilities.ts b/src/client/AlternateYamlLanguageServiceClientCapabilities.ts index 1cbd32e..6da2ff9 100644 --- a/src/client/AlternateYamlLanguageServiceClientCapabilities.ts +++ b/src/client/AlternateYamlLanguageServiceClientCapabilities.ts @@ -15,6 +15,7 @@ export type AlternateYamlLanguageServiceClientCapabilities = { // LSP features readonly basicCompletions: boolean, readonly advancedCompletions: boolean, + readonly serviceStartupCodeLens: boolean, readonly hover: boolean, readonly imageLinks: boolean, readonly formatting: boolean, diff --git a/src/service/ComposeLanguageService.ts b/src/service/ComposeLanguageService.ts index 49d71a9..fc61ab6 100644 --- a/src/service/ComposeLanguageService.ts +++ b/src/service/ComposeLanguageService.ts @@ -30,6 +30,7 @@ import { DocumentFormattingProvider } from './providers/DocumentFormattingProvid import { ImageLinkProvider } from './providers/ImageLinkProvider'; import { KeyHoverProvider } from './providers/KeyHoverProvider'; import { ProviderBase } from './providers/ProviderBase'; +import { ServiceStartupCodeLensProvider } from './providers/ServiceStartupCodeLensProvider'; import { ActionContext, runWithContext } from './utils/ActionContext'; import { TelemetryAggregator } from './utils/telemetry/TelemetryAggregator'; @@ -49,6 +50,11 @@ const DefaultCapabilities: ServerCapabilities = { resolveProvider: false, }, + // Code lenses for starting services + codeLensProvider: { + resolveProvider: false, + }, + // Hover over YAML keys hoverProvider: true, @@ -75,6 +81,7 @@ const DefaultAlternateYamlLanguageServiceClientCapabilities: AlternateYamlLangua basicCompletions: false, advancedCompletions: false, + serviceStartupCodeLens: false, hover: false, imageLinks: false, formatting: false, @@ -113,6 +120,12 @@ export class ComposeLanguageService implements Disposable { this.createLspHandler(this.connection.onCompletion, new MultiCompletionProvider(!altYamlCapabilities.basicCompletions, !altYamlCapabilities.advancedCompletions)); } + if (altYamlCapabilities.serviceStartupCodeLens) { + this._capabilities.codeLensProvider = undefined; + } else { + this.createLspHandler(this.connection.onCodeLens, new ServiceStartupCodeLensProvider()); + } + if (altYamlCapabilities.hover) { this._capabilities.hoverProvider = undefined; } else { diff --git a/src/service/providers/ServiceStartupCodeLensProvider.ts b/src/service/providers/ServiceStartupCodeLensProvider.ts new file mode 100644 index 0000000..f6ec339 --- /dev/null +++ b/src/service/providers/ServiceStartupCodeLensProvider.ts @@ -0,0 +1,80 @@ +/*-------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken, CodeLens, CodeLensParams } from 'vscode-languageserver'; +import { ProviderBase } from './ProviderBase'; +import { ExtendedParams } from '../ExtendedParams'; +import { getCurrentContext } from '../utils/ActionContext'; +import { isMap, isPair, isScalar } from 'yaml'; +import { yamlRangeToLspRange } from '../utils/yamlRangeToLspRange'; + +export class ServiceStartupCodeLensProvider extends ProviderBase { + public on(params: CodeLensParams & ExtendedParams, token: CancellationToken): CodeLens[] | undefined { + const ctx = getCurrentContext(); + ctx.telemetry.properties.isActivationEvent = 'true'; // This happens automatically so we'll treat it as isActivationEvent === true + + const results: CodeLens[] = []; + + if (!params.document.yamlDocument.value.has('services')) { + return undefined; + } + + // First add the run-all from the main "services" node + const documentMap = params.document.yamlDocument.value.contents; + if (isMap(documentMap)) { + const servicesNode = documentMap.items.find(item => { + return isScalar(item.key) && item.key.value === 'services'; + }); + + if (isPair(servicesNode)) { + const servicesKey = servicesNode.key; + + if (isScalar(servicesKey) && servicesKey.range && isMap(servicesNode.value)) { + const lens = CodeLens.create(yamlRangeToLspRange(params.document.textDocument, servicesKey.range)); + lens.command = { + title: '$(run-all) Run All Services', + command: 'vscode-docker.compose.up', + arguments: [ + /* dockerComposeFileUri: */ params.document.uri + ], + }; + results.push(lens); + } + } + } + + // Check for cancellation + if (token.isCancellationRequested) { + return undefined; + } + + // Then add the run-single for each service + const serviceMap = params.document.yamlDocument.value.getIn(['services']); + if (isMap(serviceMap)) { + for (const service of serviceMap.items) { + // Within each loop we'll check for cancellation (though this is expected to be very fast) + if (token.isCancellationRequested) { + return undefined; + } + + if (isScalar(service.key) && typeof service.key.value === 'string' && service.key.range) { + const lens = CodeLens.create(yamlRangeToLspRange(params.document.textDocument, service.key.range)); + lens.command = { + title: '$(play) Run Service', + command: 'vscode-docker.compose.up.subset', + arguments: [ // Arguments are from here: https://github.com/microsoft/vscode-docker/blob/a45a3dfc8e582f563292a707bbe56f616f7fedeb/src/commands/compose/compose.ts#L79 + /* dockerComposeFileUri: */ params.document.uri, + /* selectedComposeFileUris: */ undefined, + /* preselectedServices: */[service.key.value], + ], + }; + results.push(lens); + } + } + } + + return results; + } +} diff --git a/src/test/clientExtension/AlternateYamlLanguageServiceClientFeature.ts b/src/test/clientExtension/AlternateYamlLanguageServiceClientFeature.ts index 311ea06..4b3dff9 100644 --- a/src/test/clientExtension/AlternateYamlLanguageServiceClientFeature.ts +++ b/src/test/clientExtension/AlternateYamlLanguageServiceClientFeature.ts @@ -26,6 +26,7 @@ export class AlternateYamlLanguageServiceClientFeature implements StaticFeature, schemaValidation: true, basicCompletions: true, advancedCompletions: false, // YAML extension does not have advanced completions for compose docs + serviceStartupCodeLens: false, // YAML extension does not have service startup for compose docs hover: false, // YAML extension provides hover, but the compose spec lacks descriptions -- https://github.com/compose-spec/compose-spec/issues/138 imageLinks: false, // YAML extension does not have image hyperlinks for compose docs formatting: true, diff --git a/src/test/providers/ServiceStartupCodeLensProvider.test.ts b/src/test/providers/ServiceStartupCodeLensProvider.test.ts new file mode 100644 index 0000000..35aa63d --- /dev/null +++ b/src/test/providers/ServiceStartupCodeLensProvider.test.ts @@ -0,0 +1,137 @@ +/*!-------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { expect } from 'chai'; +import { CodeLensRequest, CodeLens, DocumentUri, Range, ResponseError } from 'vscode-languageserver'; +import { TestConnection } from '../TestConnection'; + +interface ExpectedServiceStartupCodeLens { + range: Range; + command: { + command: string; + } +} + +describe('ServiceStartupCodeLensProvider', () => { + let testConnection: TestConnection; + before('Prepare a language server for testing', async () => { + testConnection = new TestConnection(); + }); + + describe('Common scenarios', () => { + it('Should provide a code lens to start all services at the root services node', async () => { + const testObject = { + services: {} + }; + + const expected = [ + { + range: Range.create(0, 0, 0, 8), + command: { + command: 'vscode-docker.compose.up' + } + }, + ]; + + const uri = testConnection.sendObjectAsYamlDocument(testObject); + await requestServiceStartupCodeLensesAndCompare(testConnection, uri, expected); + }); + + it('Should provide a code lens for starting each service', async () => { + const testObject = { + version: '123', + services: { + abc: { + image: 'alpine' + }, + def: { + image: 'mysql:latest' + }, + } + }; + + const expected = [ + { + range: Range.create(1, 0, 1, 8), + command: { + command: 'vscode-docker.compose.up' + } + }, + { + range: Range.create(2, 2, 2, 5), + command: { + command: 'vscode-docker.compose.up.subset', + } + }, + { + range: Range.create(4, 2, 4, 5), + command: { + command: 'vscode-docker.compose.up.subset', + } + }, + ]; + + const uri = testConnection.sendObjectAsYamlDocument(testObject); + await requestServiceStartupCodeLensesAndCompare(testConnection, uri, expected); + }); + }); + + describe('Error scenarios', () => { + it('Should return an error for nonexistent files', () => { + return testConnection + .client.sendRequest(CodeLensRequest.type, { textDocument: { uri: 'file:///bogus' } }) + .should.eventually.be.rejectedWith(ResponseError); + }); + + it('Should NOT provide service startup code lenses if `services` isn\'t present', async () => { + const uri = testConnection.sendObjectAsYamlDocument({}); + await requestServiceStartupCodeLensesAndCompare(testConnection, uri, undefined); + }); + + it('Should NOT provide service startup code lenses if `services` isn\'t a map', async () => { + const testObject = { + services: 'a' + }; + + const uri = testConnection.sendObjectAsYamlDocument(testObject); + await requestServiceStartupCodeLensesAndCompare(testConnection, uri, []); + }); + }); + + after('Cleanup', () => { + testConnection.dispose(); + }); +}); + +async function requestServiceStartupCodeLensesAndCompare(testConnection: TestConnection, uri: DocumentUri, expected: ExpectedServiceStartupCodeLens[] | undefined): Promise { + const result = await testConnection.client.sendRequest(CodeLensRequest.type, { textDocument: { uri } }) as CodeLens[]; + + if (expected === undefined) { + expect(result).to.be.null; + return; + } + + expect(result).to.be.ok; // Should always be OK result even if 0 code lenses + /* eslint-disable @typescript-eslint/no-non-null-assertion */ + result.length.should.equal(expected!.length); + + if (expected!.length) { + // Each diagnostic should have a matching range and content canary in the results + for (const expectedCodeLens of expected!) { + result.some(actualCodeLens => lensesMatch(actualCodeLens, expectedCodeLens)).should.be.true; + } + } + /* eslint-enable @typescript-eslint/no-non-null-assertion */ +} + +function lensesMatch(actual: CodeLens, expected: ExpectedServiceStartupCodeLens): boolean { + return ( + actual.command?.command === expected.command.command && + actual.range.start.line === expected.range.start.line && + actual.range.start.character === expected.range.start.character && + actual.range.end.line === expected.range.end.line && + actual.range.end.character === expected.range.end.character + ); +}