Skip to content

Commit

Permalink
Add a CodeLens for starting services individually or all at once (#158)
Browse files Browse the repository at this point in the history
  • Loading branch information
bwateratmsft authored Dec 17, 2024
1 parent 87dabbf commit 2be9db5
Show file tree
Hide file tree
Showing 5 changed files with 232 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions src/service/ComposeLanguageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -49,6 +50,11 @@ const DefaultCapabilities: ServerCapabilities = {
resolveProvider: false,
},

// Code lenses for starting services
codeLensProvider: {
resolveProvider: false,
},

// Hover over YAML keys
hoverProvider: true,

Expand All @@ -75,6 +81,7 @@ const DefaultAlternateYamlLanguageServiceClientCapabilities: AlternateYamlLangua

basicCompletions: false,
advancedCompletions: false,
serviceStartupCodeLens: false,
hover: false,
imageLinks: false,
formatting: false,
Expand Down Expand Up @@ -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 {
Expand Down
80 changes: 80 additions & 0 deletions src/service/providers/ServiceStartupCodeLensProvider.ts
Original file line number Diff line number Diff line change
@@ -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<CodeLensParams & ExtendedParams, CodeLens[] | undefined, never, never> {
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
137 changes: 137 additions & 0 deletions src/test/providers/ServiceStartupCodeLensProvider.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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
);
}

0 comments on commit 2be9db5

Please sign in to comment.