├── .nvmrc
├── .github
├── CODEOWNERS
└── workflows
│ ├── main.yml
│ ├── locker.yml
│ └── info-needed-closer.yml
├── .gitignore
├── .eslintignore
├── .vscode
├── extensions.json
├── settings.json
├── tasks.json
└── launch.json
├── .npmignore
├── src
├── test
│ ├── clientExtension
│ │ ├── README.md
│ │ ├── tsconfig.json
│ │ ├── package.json
│ │ ├── AlternateYamlLanguageServiceClientFeature.ts
│ │ ├── extension.ts
│ │ ├── DocumentSettingsClientFeature.ts
│ │ └── package-lock.json
│ ├── global.test.ts
│ ├── utils
│ │ ├── ActionContext.test.ts
│ │ ├── yamlRangeToLspRange.test.ts
│ │ ├── telemetry
│ │ │ ├── logNormal.test.ts
│ │ │ └── TelemetryAggregator.test.ts
│ │ ├── Lazy.test.ts
│ │ └── debounce.test.ts
│ ├── TestConnection.ts
│ └── providers
│ │ ├── completion
│ │ ├── requestCompletionsAndCompare.ts
│ │ ├── BuildCompletionCollection.test.ts
│ │ ├── ServiceCompletionCollection.test.ts
│ │ ├── PortsCompletionCollection.test.ts
│ │ ├── RootCompletionCollection.test.ts
│ │ └── VolumesCompletionCollection.test.ts
│ │ ├── DocumentFormattingProvider.test.ts
│ │ └── DiagnosticProvider.test.ts
├── service
│ ├── providers
│ │ ├── ProviderBase.ts
│ │ ├── DocumentFormattingProvider.ts
│ │ ├── completion
│ │ │ ├── RootCompletionCollection.ts
│ │ │ ├── CompletionCollection.ts
│ │ │ ├── PortsCompletionCollection.ts
│ │ │ ├── BuildCompletionCollection.ts
│ │ │ ├── VolumesCompletionCollection.ts
│ │ │ ├── MultiCompletionProvider.ts
│ │ │ └── ServiceCompletionCollection.ts
│ │ ├── DiagnosticProvider.ts
│ │ ├── ImageLinkProvider.ts
│ │ └── KeyHoverProvider.ts
│ ├── utils
│ │ ├── yamlRangeToLspRange.ts
│ │ ├── Lazy.ts
│ │ ├── ActionContext.ts
│ │ ├── debounce.ts
│ │ └── telemetry
│ │ │ ├── logNormal.ts
│ │ │ └── TelemetryAggregator.ts
│ ├── ExtendedParams.ts
│ └── ComposeLanguageService.ts
├── client
│ ├── ClientCapabilities.ts
│ ├── AlternateYamlLanguageServiceClientCapabilities.ts
│ ├── DocumentSettings.ts
│ └── TelemetryEvent.ts
└── server.ts
├── .gitattributes
├── .azure-pipelines
├── compliance
│ ├── CredScanSuppressions.json
│ └── PoliCheckExclusions.xml
├── main.yml
└── release-npm.yml
├── bin
└── docker-compose-langserver
├── tsconfig.json
├── LICENSE
├── package.json
├── SECURITY.md
├── CHANGELOG.md
├── .eslintrc.json
├── README.md
└── NOTICE.html
/.nvmrc:
--------------------------------------------------------------------------------
1 | 16.17
2 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @Microsoft/vscodedocker
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | lib
2 | dist
3 | node_modules
4 | *.tgz
5 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | /**/*.js
2 | lib
3 | bin
4 | node_modules
5 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "dbaeumer.vscode-eslint"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | lib/test/**
2 | .eslint*
3 | .vscode
4 | src
5 | tsconfig.json
6 | .github
7 | .azure-pipelines
8 | *.tgz
9 | .gitattributes
10 |
--------------------------------------------------------------------------------
/src/test/clientExtension/README.md:
--------------------------------------------------------------------------------
1 | # Compose Language Client Test Extension
2 |
3 | A super simple extension to act as a client for the language server, to facilitate easy testing.
4 |
5 | Launch with the `Client + Server` compound launch configuration.
6 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Mark some files as generated to prevent them from showing in the repo's language stats
2 | NOTICE.html linguist-vendored=true
3 |
4 | # Stop git from breaking this script by putting CRLF in place of LF
5 | bin/docker-compose-langserver text eol=lf
6 |
--------------------------------------------------------------------------------
/.azure-pipelines/compliance/CredScanSuppressions.json:
--------------------------------------------------------------------------------
1 | // More info at https://eng.ms/docs/microsoft-security/security/azure-security/cloudai-security-fundamentals-engineering/cred-bot-trinity/credential-risk-exposure-defense/troubleshoot_guides/local-suppressions
2 | {
3 | "tool": "Credential Scanner",
4 | "suppressions": []
5 | }
6 |
--------------------------------------------------------------------------------
/bin/docker-compose-langserver:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | /*!--------------------------------------------------------------------------------------------
3 | * Copyright (c) Microsoft Corporation. All rights reserved.
4 | * Licensed under the MIT License. See LICENSE in the project root for license information.
5 | *--------------------------------------------------------------------------------------------*/
6 | require("../lib/server");
7 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "eslint.validate": [
3 | "typescript"
4 | ],
5 | "files.trimTrailingWhitespace": true,
6 | "files.insertFinalNewline": true,
7 | "editor.formatOnSave": true,
8 | "editor.codeActionsOnSave": {
9 | "source.fixAll": true
10 | },
11 | "typescript.preferences.importModuleSpecifier": "relative",
12 | "git.branchProtection": [
13 | "main",
14 | "rel/*"
15 | ],
16 | }
17 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Node PR Lint, Build and Test
2 |
3 | on:
4 | # Trigger when manually run
5 | workflow_dispatch:
6 |
7 | # Trigger on pushes to `main` or `rel/*`
8 | push:
9 | branches:
10 | - main
11 | - rel/*
12 |
13 | # Trigger on pull requests to `main` or `rel/*`
14 | pull_request:
15 | branches:
16 | - main
17 | - rel/*
18 |
19 | jobs:
20 | Build:
21 | # Use template from https://github.com/microsoft/vscode-azuretools/tree/main/.github/workflows
22 | uses: microsoft/vscode-azuretools/.github/workflows/jobs.yml@main
23 |
--------------------------------------------------------------------------------
/src/test/global.test.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import * as chai from 'chai';
7 | import * as chaiAsPromised from 'chai-as-promised';
8 |
9 | before('Global setup', function () {
10 | console.log('Global setup');
11 | chai.use(chaiAsPromised);
12 | chai.should();
13 | });
14 |
--------------------------------------------------------------------------------
/src/test/clientExtension/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2018",
4 | "module": "commonjs",
5 | "lib": [
6 | "es2018",
7 | ],
8 | "moduleResolution": "node",
9 | "sourceMap": true,
10 | "inlineSources": false,
11 | "declaration": true,
12 | "outDir": "./dist",
13 | "rootDir": ".",
14 | "alwaysStrict": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "noImplicitReturns": true,
17 | "forceConsistentCasingInFileNames": true,
18 | "strict": true,
19 | "stripInternal": true,
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/.github/workflows/locker.yml:
--------------------------------------------------------------------------------
1 | name: Locker
2 | on:
3 | schedule:
4 | - cron: 0 5 * * * # 10:00pm PT
5 | workflow_dispatch:
6 |
7 | jobs:
8 | main:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Checkout Actions
12 | uses: actions/checkout@v2
13 | with:
14 | repository: "microsoft/vscode-github-triage-actions"
15 | path: ./actions
16 | ref: stable
17 | - name: Install Actions
18 | run: npm install --production --prefix ./actions
19 | - name: Run Locker
20 | uses: ./actions/locker
21 | with:
22 | token: ${{secrets.AZCODE_BOT_PAT}}
23 | daysSinceClose: 45
24 | daysSinceUpdate: 7
25 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2018",
4 | "module": "commonjs",
5 | "lib": [
6 | "es2018",
7 | ],
8 | "moduleResolution": "node",
9 | "sourceMap": true,
10 | "inlineSources": false,
11 | "declaration": true,
12 | "outDir": "lib",
13 | "rootDir": "src",
14 | "alwaysStrict": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "noImplicitReturns": true,
17 | "forceConsistentCasingInFileNames": true,
18 | "strict": true,
19 | "stripInternal": true,
20 | },
21 | "exclude": [
22 | "src/test/clientExtension",
23 | "lib",
24 | "bin"
25 | ],
26 | }
27 |
--------------------------------------------------------------------------------
/src/service/providers/ProviderBase.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { CancellationToken, HandlerResult, ResultProgressReporter, WorkDoneProgressReporter } from 'vscode-languageserver';
7 |
8 | export abstract class ProviderBase
{
9 | public abstract on(params: P, token: CancellationToken, workDoneProgress?: WorkDoneProgressReporter, resultProgress?: ResultProgressReporter): HandlerResult;
10 | }
11 |
--------------------------------------------------------------------------------
/.azure-pipelines/main.yml:
--------------------------------------------------------------------------------
1 | # Trigger the build whenever `main` or `rel/*` is updated
2 | trigger:
3 | - main
4 | - rel/*
5 |
6 | pr: none # Disable PR trigger
7 |
8 | # Scheduled nightly build
9 | schedules:
10 | - cron: "0 0 * * *"
11 | displayName: Nightly scheduled build
12 | always: false # Don't rebuild if there haven't been changes
13 | branches:
14 | include:
15 | - main
16 |
17 | # Grab the base templates from https://github.com/microsoft/vscode-azuretools/tree/main/azure-pipelines
18 | resources:
19 | repositories:
20 | - repository: templates
21 | type: github
22 | name: microsoft/vscode-azuretools
23 | ref: main
24 | endpoint: GitHub
25 |
26 | # Use those templates
27 | extends:
28 | template: azure-pipelines/jobs.yml@templates
29 |
--------------------------------------------------------------------------------
/.azure-pipelines/release-npm.yml:
--------------------------------------------------------------------------------
1 | trigger: none # Disable the branch trigger
2 | pr: none # Disable PR trigger
3 |
4 | # Choose a package to publish at the time of job creation
5 | parameters:
6 | - name: PackageToPublish
7 | displayName: Package to Publish
8 | type: string
9 | default: microsoft-compose-language-service
10 |
11 | # Grab the base templates from https://github.com/microsoft/vscode-azuretools/tree/main/azure-pipelines
12 | resources:
13 | repositories:
14 | - repository: templates
15 | type: github
16 | name: microsoft/vscode-azuretools
17 | ref: main
18 | endpoint: GitHub
19 |
20 | # Use those base templates
21 | extends:
22 | template: azure-pipelines/release-npm.yml@templates
23 | parameters:
24 | PackageToPublish: ${{ parameters.PackageToPublish }}
25 | PipelineDefinition: 38
26 |
--------------------------------------------------------------------------------
/src/service/utils/yamlRangeToLspRange.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { Range as LspRange } from 'vscode-languageserver';
7 | import { Range as YamlRange } from 'yaml/dist/nodes/Node';
8 | import { TextDocument } from 'vscode-languageserver-textdocument';
9 |
10 | export function yamlRangeToLspRange(document: TextDocument, yamlRange: YamlRange | [number, number]): LspRange {
11 | return {
12 | start: document.positionAt(yamlRange[0]),
13 | end: document.positionAt(yamlRange[1]),
14 | };
15 | }
16 |
--------------------------------------------------------------------------------
/src/service/utils/Lazy.ts:
--------------------------------------------------------------------------------
1 | /*---------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE.md in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | export class Lazy {
7 | /* private */ #value: T | undefined;
8 |
9 | public constructor(private readonly valueFactory: () => T) {
10 | }
11 |
12 | public get value(): T {
13 | if (this.#value === undefined) {
14 | this.#value = this.valueFactory();
15 | }
16 |
17 | return this.#value;
18 | }
19 |
20 | public hasValue(): boolean {
21 | return (this.#value !== undefined);
22 | }
23 |
24 | public clear(): void {
25 | this.#value = undefined;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/client/ClientCapabilities.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import type { ClientCapabilities } from 'vscode-languageserver-protocol';
7 | import type { DocumentSettingsClientCapabilities } from './DocumentSettings';
8 | import type { AlternateYamlLanguageServiceClientCapabilities } from './AlternateYamlLanguageServiceClientCapabilities';
9 |
10 | export type ComposeLanguageClientCapabilities = Omit & {
11 | readonly experimental?: {
12 | readonly documentSettings?: DocumentSettingsClientCapabilities;
13 | readonly alternateYamlLanguageService?: AlternateYamlLanguageServiceClientCapabilities;
14 | }
15 | };
16 |
--------------------------------------------------------------------------------
/src/test/clientExtension/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "compose-languageserver-testclient",
3 | "publisher": "ms-azuretools",
4 | "version": "0.0.1",
5 | "engines": {
6 | "vscode": "^1.69.0"
7 | },
8 | "activationEvents": [
9 | "onLanguage:dockercompose",
10 | "onStartupFinished"
11 | ],
12 | "main": "./dist/extension.js",
13 | "dependencies": {
14 | "vscode-languageclient": "^8.0.0"
15 | },
16 | "devDependencies": {
17 | "@types/vscode": "1.69.0"
18 | },
19 | "contributes": {
20 | "configurationDefaults": {
21 | "[dockercompose]": {
22 | "editor.insertSpaces": true,
23 | "editor.tabSize": 2,
24 | "editor.autoIndent": "advanced",
25 | "editor.quickSuggestions": {
26 | "other": true,
27 | "comments": false,
28 | "strings": true
29 | }
30 | }
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/.azure-pipelines/compliance/PoliCheckExclusions.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | NODE_MODULES|BACKUPTEMPLATES|.VSCODE-TEST|DIST
5 |
6 |
7 |
8 |
9 |
10 | NOTICE.HTML
11 |
12 |
--------------------------------------------------------------------------------
/src/client/AlternateYamlLanguageServiceClientCapabilities.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | /**
7 | * If another YAML-generic language service is present (e.g. from the YAML extension),
8 | * we can disable some of the capabilities of this extension to avoid duplicate functionality.
9 | */
10 | export type AlternateYamlLanguageServiceClientCapabilities = {
11 | // Diagnostics features
12 | readonly syntaxValidation: boolean,
13 | readonly schemaValidation: boolean,
14 |
15 | // LSP features
16 | readonly basicCompletions: boolean,
17 | readonly advancedCompletions: boolean,
18 | readonly hover: boolean,
19 | readonly imageLinks: boolean,
20 | readonly formatting: boolean,
21 | };
22 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "type": "typescript",
6 | "tsconfig": "tsconfig.json",
7 | "option": "watch",
8 | "problemMatcher": [
9 | "$tsc-watch"
10 | ],
11 | "group": {
12 | "kind": "build",
13 | "isDefault": true
14 | },
15 | "presentation": {
16 | "reveal": "never"
17 | },
18 | "isBackground": true,
19 | "label": "tsc-watch: language service"
20 | },
21 | {
22 | "type": "typescript",
23 | "tsconfig": "src/test/clientExtension/tsconfig.json",
24 | "option": "watch",
25 | "problemMatcher": [
26 | "$tsc-watch"
27 | ],
28 | "group": "build",
29 | "presentation": {
30 | "reveal": "never"
31 | },
32 | "isBackground": true,
33 | "label": "tsc-watch: client extension"
34 | }
35 | ]
36 | }
37 |
--------------------------------------------------------------------------------
/.github/workflows/info-needed-closer.yml:
--------------------------------------------------------------------------------
1 | name: Info Needed Closer
2 | on:
3 | schedule:
4 | - cron: 30 5 * * * # 10:30pm PT
5 | workflow_dispatch:
6 |
7 | jobs:
8 | main:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Checkout Actions
12 | uses: actions/checkout@v2
13 | with:
14 | repository: "microsoft/vscode-github-triage-actions"
15 | path: ./actions
16 | ref: stable
17 | - name: Install Actions
18 | run: npm install --production --prefix ./actions
19 | - name: Run Info Needed Closer
20 | uses: ./actions/needs-more-info-closer
21 | with:
22 | token: ${{secrets.AZCODE_BOT_PAT}}
23 | label: info-needed
24 | closeDays: 14
25 | closeComment: "This issue has been closed automatically because it needs more information and has not had recent activity. See also our [issue reporting](https://aka.ms/azcodeissuereporting) guidelines.\n\nHappy Coding!"
26 | pingDays: 80
27 | pingComment: "Hey @${assignee}, this issue might need further attention.\n\n@${author}, you can help us out by closing this issue if the problem no longer exists, or adding more information."
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Microsoft Corporation.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE
22 |
--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { Connection, InitializeParams, ProposedFeatures } from 'vscode-languageserver';
7 | import { createConnection } from 'vscode-languageserver/node';
8 | import { ComposeLanguageService } from './service/ComposeLanguageService';
9 |
10 | const connection: Connection = createConnection(ProposedFeatures.all);
11 |
12 | // Hook up the connection
13 | connection.onInitialize((params: InitializeParams) => {
14 | const service = new ComposeLanguageService(connection, params);
15 |
16 | connection.onShutdown(() => {
17 | service.dispose();
18 | });
19 |
20 | // Return the InitializeResult
21 | return {
22 | capabilities: service.capabilities,
23 | serverInfo: {
24 | name: 'Docker Compose Language Server',
25 | },
26 | };
27 | });
28 |
29 | // Start the connection
30 | connection.listen();
31 |
--------------------------------------------------------------------------------
/src/service/ExtendedParams.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { CompletionParams, TextDocumentIdentifier, TextDocumentPositionParams } from 'vscode-languageserver';
7 | import { ComposeDocument } from './ComposeDocument';
8 |
9 | export interface TextDocumentParams { // This interface ought to exist in `vscode-languageserver`, like `TextDocumentPositionParams`, but here we are...
10 | textDocument: TextDocumentIdentifier;
11 | }
12 |
13 | export interface ExtendedParams extends TextDocumentParams {
14 | document: ComposeDocument;
15 | }
16 |
17 | export interface ExtendedPositionParams extends ExtendedParams, TextDocumentPositionParams {
18 | }
19 |
20 | export interface PositionInfo {
21 | path: string;
22 | indentDepth: number;
23 | }
24 |
25 | export interface ExtendedCompletionParams extends CompletionParams, ExtendedPositionParams {
26 | positionInfo: PositionInfo;
27 | basicCompletions: boolean;
28 | advancedCompletions: boolean;
29 | }
30 |
--------------------------------------------------------------------------------
/src/service/utils/ActionContext.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import * as async from 'async_hooks';
7 | import { Connection, ErrorCodes, ResponseError } from 'vscode-languageserver';
8 | import type { ComposeLanguageClientCapabilities } from '../../client/ClientCapabilities';
9 | import { TelemetryEvent } from '../../client/TelemetryEvent';
10 |
11 | const als = new async.AsyncLocalStorage();
12 |
13 | export interface ActionContext {
14 | clientCapabilities: ComposeLanguageClientCapabilities;
15 | connection: Connection;
16 | telemetry: TelemetryEvent;
17 | }
18 |
19 | export function getCurrentContext(): ActionContext {
20 | const ctx = als.getStore();
21 |
22 | if (!ctx) {
23 | throw new ResponseError(ErrorCodes.InternalError, 'Failed to get action context');
24 | }
25 |
26 | return ctx;
27 | }
28 |
29 | export function runWithContext(context: ActionContext, callback: () => R): R {
30 | return als.run(context, callback);
31 | }
32 |
--------------------------------------------------------------------------------
/src/test/utils/ActionContext.test.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { expect } from 'chai';
7 | import { ResponseError } from 'vscode-jsonrpc';
8 | import { initEvent } from '../../client/TelemetryEvent';
9 | import { ActionContext, getCurrentContext, runWithContext } from '../../service/utils/ActionContext';
10 |
11 | describe('(Unit) ActionContext', () => {
12 | describe('Common scenarios', () => {
13 | it('Should provide a context when called correctly', async () => {
14 | const ctx = {
15 | telemetry: initEvent('foo'),
16 | } as ActionContext;
17 |
18 | await runWithContext(ctx, async () => {
19 | const localCtx = getCurrentContext();
20 |
21 | ctx.should.equal(localCtx);
22 | ctx.should.deep.equal(localCtx);
23 |
24 | ctx.telemetry.properties.test = '1';
25 | localCtx.telemetry.properties.test?.should.equal('1');
26 | });
27 | });
28 | });
29 |
30 | describe('Error scenarios', () => {
31 | it('Should throw a ResponseError if called incorrectly', async () => {
32 | expect(getCurrentContext).to.throw(ResponseError);
33 | });
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/src/service/utils/debounce.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { Disposable, DocumentUri } from 'vscode-languageserver';
7 |
8 | export interface DebounceId {
9 | uri: DocumentUri;
10 | callId: string; // TODO: convert to an enum of debounceable calls?
11 | }
12 |
13 | const activeDebounces: { [key: string]: Disposable } = {};
14 |
15 | export function debounce(delay: number, id: DebounceId, callback: () => Promise | void, thisArg?: unknown): void {
16 | const idString = `${id.uri}/${id.callId}`;
17 |
18 | // If there's an existing call queued up, wipe it out (can't simply refresh as the inputs to the callback may be different)
19 | if (activeDebounces[idString]) {
20 | activeDebounces[idString].dispose();
21 | delete activeDebounces[idString];
22 | }
23 |
24 | // Schedule the callback
25 | const timeout = setTimeout(() => {
26 | // Clear the callback since we're about to fire it
27 | activeDebounces[idString].dispose();
28 | delete activeDebounces[idString];
29 |
30 | // Fire it
31 | void callback.call(thisArg);
32 | }, delay);
33 |
34 | // Keep track of the active debounce
35 | activeDebounces[idString] = {
36 | dispose: () => clearTimeout(timeout),
37 | };
38 | }
39 |
--------------------------------------------------------------------------------
/src/test/utils/yamlRangeToLspRange.test.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { TextDocument } from 'vscode-languageserver-textdocument';
7 | import { Range } from 'vscode-languageserver-types';
8 | import { Range as YamlRange } from 'yaml/dist/nodes/Node';
9 | import { yamlRangeToLspRange } from '../../service/utils/yamlRangeToLspRange';
10 |
11 | describe('(Unit) yamlRangeToLspRange', () => {
12 | describe('Common scenarios', () => {
13 | it('Should return the correct result for two-integer ranges', () => {
14 | const doc = TextDocument.create('file:///foo', 'dockercompose', 1, `version: '123'
15 | services:
16 | foo:
17 | image: redis`);
18 |
19 | const result = yamlRangeToLspRange(doc, [4, 29]);
20 | result.should.deep.equal(Range.create(0, 4, 2, 4));
21 | });
22 |
23 | it('Should return the correct result for yaml `Range` objects', () => {
24 | const doc = TextDocument.create('file:///foo', 'dockercompose', 1, `version: '123'
25 | services:
26 | foo:
27 | image: redis`);
28 |
29 | const yamlRange: YamlRange = [4, 29, 30];
30 | const result = yamlRangeToLspRange(doc, yamlRange);
31 | result.should.deep.equal(Range.create(0, 4, 2, 4));
32 | });
33 | });
34 | });
35 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@microsoft/compose-language-service",
3 | "author": "Microsoft Corporation",
4 | "version": "0.2.1-alpha",
5 | "publisher": "ms-azuretools",
6 | "description": "Language service for Docker Compose documents",
7 | "license": "See LICENSE in the project root for license information.",
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/microsoft/compose-language-service"
11 | },
12 | "bugs": {
13 | "url": "https://github.com/microsoft/compose-language-service/issues"
14 | },
15 | "homepage": "https://github.com/microsoft/compose-language-service/blob/main/README.md",
16 | "keywords": [
17 | "vscode",
18 | "docker"
19 | ],
20 | "main": "lib/server.js",
21 | "bin": {
22 | "docker-compose-langserver": "./bin/docker-compose-langserver"
23 | },
24 | "scripts": {
25 | "build": "tsc",
26 | "lint": "eslint --max-warnings 0 src --ext ts",
27 | "test": "mocha --file lib/test/global.test.js --recursive lib/test",
28 | "unittest": "npm test -- --grep /unit/i",
29 | "package": "npm pack"
30 | },
31 | "devDependencies": {
32 | "@types/chai": "^4.3.0",
33 | "@types/chai-as-promised": "^7.1.3",
34 | "@types/mocha": "^9.0.0",
35 | "@types/node": "16.x",
36 | "@typescript-eslint/eslint-plugin": "^5.7.0",
37 | "@typescript-eslint/parser": "^5.7.0",
38 | "chai": "^4.3.0",
39 | "chai-as-promised": "^7.1.1",
40 | "eslint": "^8.4.1",
41 | "mocha": "^10.0.0",
42 | "typescript": "^5.0.4"
43 | },
44 | "dependencies": {
45 | "vscode-languageserver": "^8.0.2",
46 | "vscode-languageserver-textdocument": "^1.0.3",
47 | "yaml": "^2.2.2"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/client/DocumentSettings.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { NotificationType, RequestType } from 'vscode-languageserver-protocol';
7 | import { TextDocumentParams } from '../service/ExtendedParams';
8 |
9 | export type DocumentSettingsClientCapabilities = {
10 | readonly request: boolean,
11 | readonly notify: boolean,
12 | };
13 |
14 | // TODO: can we get these from @types/vscode instead? It seems there's some type conflict between `Thenable` from @types/vscode and vscode-jsonrpc preventing @types/vscode from working nicely
15 | export const LF = 1;
16 | export const CRLF = 2;
17 | type EndOfLine = typeof LF | typeof CRLF;
18 |
19 | export interface DocumentSettings {
20 | tabSize: number;
21 | eol: EndOfLine;
22 | }
23 |
24 | export type DocumentSettingsParams = TextDocumentParams;
25 |
26 | // Use the same syntax as the LSP
27 | // eslint-disable-next-line @typescript-eslint/no-namespace
28 | export namespace DocumentSettingsRequest {
29 | export const method = '$/textDocument/documentSettings' as const;
30 | export const type = new RequestType(method);
31 | }
32 |
33 | export type DocumentSettingsNotificationParams = DocumentSettingsParams & DocumentSettings;
34 |
35 | // Use the same syntax as the LSP
36 | // eslint-disable-next-line @typescript-eslint/no-namespace
37 | export namespace DocumentSettingsNotification {
38 | export const method = '$/textDocument/documentSettings/didChange' as const;
39 | export const type = new NotificationType(method);
40 | }
41 |
--------------------------------------------------------------------------------
/src/test/utils/telemetry/logNormal.test.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { logNormal } from '../../../service/utils/telemetry/logNormal';
7 |
8 | describe('(Unit) logNormal', () => {
9 | describe('Common scenarios', () => {
10 | it('Should give correct results', () => {
11 | const testData = [91, 53, 106, 98, 87, 97, 85, 109, 93, 47, 63, 72, 46, 106, 84, 69, 120, 82, 74, 104, 70, 63, 93, 82, 77, 88, 93, 120, 47, 55, 117, 120, 58, 55, 70, 96, 49, 119, 86, 107, 90, 103, 61, 92, 122, 90, 56, 113, 64, 62];
12 |
13 | const result = logNormal(testData);
14 |
15 | result.median.should.equal(81);
16 | result.mu.should.equal(4.393);
17 | result.sigma.should.equal(0.286);
18 | });
19 |
20 | it('Should give sigma of 0 for single item inputs', () => {
21 | const testData = [91];
22 |
23 | const result = logNormal(testData);
24 |
25 | result.median.should.equal(91);
26 | result.mu.should.equal(4.511);
27 | result.sigma.should.equal(0);
28 | });
29 | });
30 |
31 | describe('Error scenarios', () => {
32 | it('Should return 0\'s for undefined or empty inputs', () => {
33 | const result1 = logNormal(undefined as unknown as []);
34 |
35 | result1.median.should.equal(0);
36 | result1.mu.should.equal(0);
37 | result1.sigma.should.equal(0);
38 |
39 | const result2 = logNormal([]);
40 |
41 | result2.median.should.equal(0);
42 | result2.mu.should.equal(0);
43 | result2.sigma.should.equal(0);
44 | });
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/src/test/utils/Lazy.test.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { expect } from 'chai';
7 | import { Lazy } from '../../service/utils/Lazy';
8 |
9 | describe('(Unit) Lazy', () => {
10 | describe('Common scenarios', () => {
11 | it('Should calculate the value only once', () => {
12 | let x = 0;
13 | const a = new Lazy(() => {
14 | return ++x;
15 | });
16 |
17 | a.value.should.equal(1);
18 | a.value.should.equal(1); // Shouldn't change after re-requesting the value
19 | x.should.equal(1);
20 | });
21 |
22 | it('Should return the correct result for hasValue', () => {
23 | let x = 0;
24 | const a = new Lazy(() => {
25 | return ++x;
26 | });
27 |
28 | a.hasValue().should.be.false;
29 | a.value.should.equal(1);
30 | a.hasValue().should.be.true;
31 | });
32 |
33 | it('Should allow clearing the value', () => {
34 | let x = 0;
35 | const a = new Lazy(() => {
36 | return ++x;
37 | });
38 |
39 | a.value.should.equal(1);
40 | x.should.equal(1);
41 |
42 | a.clear();
43 |
44 | a.value.should.equal(2);
45 | x.should.equal(2);
46 | });
47 | });
48 |
49 | describe('Error scenarios', () => {
50 | it('Should rethrow errors', () => {
51 | const a = new Lazy(() => {
52 | throw new Error('foo');
53 | });
54 |
55 | expect(() => a.value).to.throw(Error, 'foo');
56 | });
57 | });
58 | });
59 |
--------------------------------------------------------------------------------
/src/service/providers/DocumentFormattingProvider.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { CancellationToken, DocumentFormattingParams, Range, TextEdit } from 'vscode-languageserver';
7 | import { ToStringOptions } from 'yaml';
8 | import { ExtendedParams } from '../ExtendedParams';
9 | import { ProviderBase } from './ProviderBase';
10 |
11 | export class DocumentFormattingProvider extends ProviderBase {
12 | public on(params: DocumentFormattingParams & ExtendedParams, token: CancellationToken): TextEdit[] | undefined {
13 | if (params.document.yamlDocument.value.errors.length) {
14 | // Won't return formatting info unless the document is syntactically correct
15 | return undefined;
16 | }
17 |
18 | const options: ToStringOptions = {
19 | indent: params.options.tabSize,
20 | indentSeq: true,
21 | simpleKeys: true, // todo?
22 | nullStr: '',
23 | lineWidth: 0,
24 | };
25 |
26 | const range = Range.create(
27 | params.document.textDocument.positionAt(0),
28 | params.document.textDocument.positionAt(params.document.textDocument.getText().length) // This technically goes past the end of the doc, but it's OK because the protocol accepts this (positions past the end of the doc are rounded backward)
29 | );
30 |
31 | const formatted = params.document.yamlDocument.value.toString(options);
32 |
33 | // It's heavy-handed but the replacement is for the entire document
34 | // TODO is this terrible?
35 | return [TextEdit.replace(
36 | range,
37 | formatted
38 | )];
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/test/clientExtension/AlternateYamlLanguageServiceClientFeature.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import type { AlternateYamlLanguageServiceClientCapabilities } from '../../../lib/client/AlternateYamlLanguageServiceClientCapabilities';
7 | import * as vscode from 'vscode';
8 | import type { ClientCapabilities, FeatureState, StaticFeature } from 'vscode-languageclient';
9 |
10 | /**
11 | * This class will note the features covered by an alternate YAML language service,
12 | * that the compose language service can disable
13 | */
14 | export class AlternateYamlLanguageServiceClientFeature implements StaticFeature, vscode.Disposable {
15 | public getState(): FeatureState {
16 | return {
17 | kind: 'static'
18 | };
19 | }
20 |
21 | public fillClientCapabilities(capabilities: ClientCapabilities): void {
22 | // If the RedHat YAML extension is present, we can disable many of the compose language service features
23 | if (vscode.extensions.getExtension('redhat.vscode-yaml')) {
24 | const altYamlClientCapabilities: AlternateYamlLanguageServiceClientCapabilities = {
25 | syntaxValidation: true,
26 | schemaValidation: true,
27 | basicCompletions: true,
28 | advancedCompletions: false, // YAML extension does not have advanced completions for compose docs
29 | hover: false, // YAML extension provides hover, but the compose spec lacks descriptions -- https://github.com/compose-spec/compose-spec/issues/138
30 | imageLinks: false, // YAML extension does not have image hyperlinks for compose docs
31 | formatting: true,
32 | };
33 |
34 | capabilities.experimental = {
35 | ...capabilities.experimental,
36 | alternateYamlLanguageService: altYamlClientCapabilities,
37 | };
38 | }
39 | }
40 |
41 | public initialize(): void {
42 | // Noop
43 | }
44 |
45 | public dispose(): void {
46 | // Noop
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/test/clientExtension/extension.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import * as vscode from 'vscode';
7 | import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind } from 'vscode-languageclient/node';
8 | import { DocumentSettingsClientFeature } from './DocumentSettingsClientFeature';
9 | import { AlternateYamlLanguageServiceClientFeature } from './AlternateYamlLanguageServiceClientFeature';
10 |
11 | export async function activate(context: vscode.ExtensionContext): Promise {
12 | const serverModule = context.asAbsolutePath('../../../lib/server.js');
13 |
14 | const serverOptions: ServerOptions = {
15 | run: {
16 | module: serverModule,
17 | transport: TransportKind.ipc,
18 | },
19 | debug: {
20 | module: serverModule,
21 | transport: TransportKind.ipc,
22 | options: { execArgv: ['--nolazy', '--inspect=6009'] },
23 | },
24 | };
25 |
26 | const serverOutputChannel = vscode.window.createOutputChannel('Compose Language Service');
27 | const clientOutputChannel = vscode.window.createOutputChannel('Compose Client Extension');
28 |
29 | const clientOptions: LanguageClientOptions = {
30 | documentSelector: [
31 | {
32 | language: 'dockercompose'
33 | },
34 | ],
35 | outputChannel: serverOutputChannel,
36 | initializationOptions: {
37 | telemetryAggregationInterval: 20 * 1000, // Used to speed up the telemetry aggregation cycle
38 | },
39 | };
40 |
41 | const client = new LanguageClient('compose-language-server', serverOptions, clientOptions, true);
42 | client.registerFeature(new DocumentSettingsClientFeature(client));
43 | client.registerFeature(new AlternateYamlLanguageServiceClientFeature());
44 |
45 | context.subscriptions.push(client.onTelemetry((e) => {
46 | clientOutputChannel.appendLine(JSON.stringify(e));
47 | }));
48 |
49 | context.subscriptions.push(client);
50 | await client.start();
51 | }
52 |
53 | export function deactivate(): void {
54 | // Do nothing
55 | }
56 |
--------------------------------------------------------------------------------
/src/service/providers/completion/RootCompletionCollection.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { InsertTextFormat } from 'vscode-languageserver';
7 | import { CompletionCollection } from './CompletionCollection';
8 |
9 | /**
10 | * The position given when the cursor is at the root, i.e. at the | below:
11 | |
12 | services:
13 | foo:
14 | a: b
15 | */
16 | const PositionAtRootPathRegex = /^\/$/i; // e.g. /
17 |
18 | /**
19 | * The position given when the cursor is in a partially-typed root key, i.e. at the | below:
20 | val|
21 | services:
22 | foo:
23 | a: b
24 | */
25 | const PositionInRootKeyPathRegex = /^\/$/i; // e.g. /
26 |
27 | export const RootCompletionCollection = new CompletionCollection(
28 | 'root',
29 | { logicalPaths: [PositionAtRootPathRegex, PositionInRootKeyPathRegex], indentationDepth: 0 },
30 | ...[
31 | {
32 | label: 'configs:',
33 | insertText: 'configs:\n',
34 | insertTextFormat: InsertTextFormat.PlainText,
35 | isAdvancedComposeCompletion: false,
36 | },
37 | {
38 | label: 'networks:',
39 | insertText: 'networks:\n',
40 | insertTextFormat: InsertTextFormat.PlainText,
41 | isAdvancedComposeCompletion: false,
42 | },
43 | {
44 | label: 'secrets:',
45 | insertText: 'secrets:\n',
46 | insertTextFormat: InsertTextFormat.PlainText,
47 | isAdvancedComposeCompletion: false,
48 | },
49 | {
50 | label: 'services:',
51 | insertText: 'services:\n',
52 | insertTextFormat: InsertTextFormat.PlainText,
53 | isAdvancedComposeCompletion: false,
54 | },
55 | {
56 | label: 'version:',
57 | insertText: 'version: \'${1:version}\'$0',
58 | insertTextFormat: InsertTextFormat.Snippet,
59 | isAdvancedComposeCompletion: false,
60 | },
61 | {
62 | label: 'volumes:',
63 | insertText: 'volumes:\n',
64 | insertTextFormat: InsertTextFormat.PlainText,
65 | isAdvancedComposeCompletion: false,
66 | },
67 | ]
68 | );
69 |
--------------------------------------------------------------------------------
/src/client/TelemetryEvent.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | // These interfaces are loosely copied from https://github.com/microsoft/vscode-azuretools/blob/be03d9a57f66bb25efdd6f2e281052d05b0ec01b/ui/index.d.ts#L536-L592
7 | // Ideally they would just be used directly but I'm reluctant to add the dependency on vscode-azureextensionui, given how much unrelated stuff it contains...
8 |
9 | export interface TelemetryEvent {
10 | /**
11 | * The event name.
12 | */
13 | eventName: string;
14 |
15 | /**
16 | * Properties of the event. Successful events will be aggregated, and each property from each event attached to the ultimate aggregated event.
17 | * It is recommended in most cases to sort array properties, in order for them to be functionally treated as sets rather than arrays.
18 | */
19 | properties: TelemetryProperties;
20 |
21 | /**
22 | * Duration measurements for the event
23 | */
24 | measurements: TelemetryMeasurements & AggregateTelemetryMeasurements;
25 |
26 | /**
27 | * How the events will be grouped, either by name only, or name + JSON.stringify(properties)
28 | */
29 | groupingStrategy: 'eventName' | 'eventNameAndProperties';
30 |
31 | /**
32 | * If true, the event will not be sent if it is successful.
33 | */
34 | suppressIfSuccessful?: boolean;
35 |
36 | /**
37 | * If true, the event will not be sent.
38 | */
39 | suppressAll?: boolean;
40 | }
41 |
42 | interface TelemetryProperties {
43 | isActivationEvent: 'true' | 'false';
44 | result: 'Succeeded' | 'Failed' | 'Canceled';
45 | error?: string;
46 | errorMessage?: string;
47 | stack?: string;
48 |
49 | [key: string]: string | undefined;
50 | }
51 |
52 | interface TelemetryMeasurements {
53 | duration?: number;
54 | }
55 |
56 | interface AggregateTelemetryMeasurements {
57 | count?: number;
58 | durationMu?: number;
59 | durationSigma?: number;
60 | durationMedian?: number;
61 | }
62 |
63 | export function initEvent(eventName: string): TelemetryEvent {
64 | return {
65 | eventName: eventName,
66 | properties: {
67 | isActivationEvent: 'false',
68 | result: 'Succeeded',
69 | },
70 | measurements: {},
71 | groupingStrategy: 'eventNameAndProperties',
72 | };
73 | }
74 |
--------------------------------------------------------------------------------
/src/service/providers/completion/CompletionCollection.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { CompletionItem } from 'vscode-languageserver';
7 | import { ExtendedCompletionParams } from '../../ExtendedParams';
8 |
9 | interface ExtendedCompletionItem extends CompletionItem {
10 | /**
11 | * The matching expression
12 | */
13 | matcher?: RegExp;
14 |
15 | /**
16 | * Insert text is required in ExtendedCompletionItem
17 | */
18 | insertText: string;
19 |
20 | /**
21 | * Whether a completion is an advanced compose completion
22 | */
23 | isAdvancedComposeCompletion: boolean;
24 |
25 | // TODO: in the long run, we should use `InsertReplaceEdit` to avoid client-side interpretation and make a more client-agnostic server
26 | // TODO: However, using `insertText` instead of `textEdit`, the behavior for 24x7 completions is closer in-line to what we want at least in VSCode
27 | }
28 |
29 | export class CompletionCollection extends Array {
30 | public constructor(
31 | public readonly name: string,
32 | private readonly locationRequirements: CompletionLocationRequirements,
33 | ...items: ExtendedCompletionItem[]
34 | ) {
35 | super(...items);
36 | }
37 |
38 | public getActiveCompletionItems(params: ExtendedCompletionParams): CompletionItem[] | undefined {
39 | if (this.locationRequirements.logicalPaths !== undefined && !this.locationRequirements.logicalPaths.some(p => p.test(params.positionInfo.path ?? ''))) {
40 | return undefined; // Reject this collection: the logical path requirement is not satisfied
41 | }
42 |
43 | if (this.locationRequirements.indentationDepth !== undefined && this.locationRequirements.indentationDepth !== params.positionInfo.indentDepth) {
44 | return undefined; // Reject this collection: the indentation depth requirement is not satisfied
45 | }
46 |
47 | const line = params.document.lineAt(params.position);
48 | return this
49 | .filter(eci => (params.basicCompletions && !eci.isAdvancedComposeCompletion) || (params.advancedCompletions && eci.isAdvancedComposeCompletion))
50 | .filter(eci => !eci.matcher || eci.matcher.test(line));
51 | }
52 | }
53 |
54 | interface CompletionLocationRequirements {
55 | logicalPaths?: RegExp[];
56 | indentationDepth?: number;
57 | }
58 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | ## Security
4 |
5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/).
6 |
7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)), please report it to us as described below.
8 |
9 | ## Reporting Security Issues
10 |
11 | **Please do not report security vulnerabilities through public GitHub issues.**
12 |
13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://msrc.microsoft.com/create-report).
14 |
15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://www.microsoft.com/en-us/msrc/pgp-key-msrc).
16 |
17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc).
18 |
19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue:
20 |
21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.)
22 | * Full paths of source file(s) related to the manifestation of the issue
23 | * The location of the affected source code (tag/branch/commit or direct URL)
24 | * Any special configuration required to reproduce the issue
25 | * Step-by-step instructions to reproduce the issue
26 | * Proof-of-concept or exploit code (if possible)
27 | * Impact of the issue, including how an attacker might exploit the issue
28 |
29 | This information will help us triage your report more quickly.
30 |
31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://microsoft.com/msrc/bounty) page for more details about our active programs.
32 |
33 | ## Preferred Languages
34 |
35 | We prefer all communications to be in English.
36 |
37 | ## Policy
38 |
39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd).
40 |
41 |
--------------------------------------------------------------------------------
/src/service/providers/completion/PortsCompletionCollection.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { InsertTextFormat, InsertTextMode } from 'vscode-languageserver';
7 | import { CompletionCollection } from './CompletionCollection';
8 |
9 | // Matches ` - ""` or ` -`, with allowances for other amounts of whitespace/quoting
10 | const PortItemStartRegex = /(\s*-\s*)(?"|')?\2\s*$/i;
11 |
12 | export const PortsCompletionCollection = new CompletionCollection(
13 | 'ports',
14 | { logicalPaths: [/^\/services\/[.\w-]+\/ports\/- \/.*$/i], indentationDepth: 3 },
15 | ...[
16 | {
17 | matcher: PortItemStartRegex,
18 | label: 'containerPort',
19 | insertText: '"${1:80}"$0',
20 | insertTextFormat: InsertTextFormat.Snippet,
21 | isAdvancedComposeCompletion: false,
22 | },
23 | {
24 | matcher: PortItemStartRegex,
25 | label: 'hostPort:containerPort',
26 | insertText: '"${1:8080}:${2:80}"$0',
27 | insertTextFormat: InsertTextFormat.Snippet,
28 | isAdvancedComposeCompletion: true,
29 | },
30 | {
31 | matcher: PortItemStartRegex,
32 | label: 'hostPort:containerPort/protocol',
33 | insertText: '"${1:8080}:${2:80}/${3|tcp,udp|}"$0',
34 | insertTextFormat: InsertTextFormat.Snippet,
35 | isAdvancedComposeCompletion: true,
36 | },
37 | {
38 | matcher: PortItemStartRegex,
39 | label: 'hostRange:containerRange',
40 | insertText: '"${1:8080}-${2:8081}:${3:80}-${4:81}"$0',
41 | insertTextFormat: InsertTextFormat.Snippet,
42 | isAdvancedComposeCompletion: true,
43 | },
44 | {
45 | matcher: PortItemStartRegex,
46 | label: '(Long form port specification)',
47 | insertText: 'target: ${1:80}\n published: ${2:8080}\n protocol: ${3|tcp,udp|}\n mode: ${4|host,ingress|}$0', // These are intentionally always two spaces, not the auto-replaced tabs; this is a flow map and must align to the first item which is after `- `
48 | insertTextFormat: InsertTextFormat.Snippet,
49 | insertTextMode: InsertTextMode.adjustIndentation,
50 | isAdvancedComposeCompletion: true,
51 | documentation: 'target: \n published: \n protocol: \n mode: ',
52 | sortText: 'zzz', // Force this to sort to the bottom
53 | },
54 | ]
55 | );
56 |
--------------------------------------------------------------------------------
/src/service/utils/telemetry/logNormal.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | /**
7 | * Fits a lognormal distribution to the given data and returns the mu, sigma, and median of the data.
8 | * Lognormal is a decent approximation of performance (i.e. durations of things).
9 | * Fun fact! Compose file line counts are very well-fit by a lognormal distribution, mu=3.7970, sigma=1.0152
10 | * @see https://en.wikipedia.org/wiki/Log-normal_distribution
11 | * @param values The list of values to fit a lognormal distribution to
12 | * @returns The mu (log of median), sigma (stdev), and median, fit with a lognormal distribution
13 | */
14 | export function logNormal(values: number[]): { mu: number, sigma: number, median: number } {
15 | if (!values?.length) {
16 | // If there's no elements, return all 0's
17 | return { mu: 0, sigma: 0, median: 0, };
18 | } else if (values.length === 1) {
19 | // If there's only 1 element sigma must be 0
20 | return {
21 | median: round(values[0], 3),
22 | mu: round(ln(values[0]), 3),
23 | sigma: 0,
24 | };
25 | }
26 |
27 | const n = values.length;
28 | const lnValues = values.map(a => ln(a));
29 | const sqLnValues = lnValues.map(a => sq(a));
30 |
31 | // Mu is the mean of the natural logs of the values
32 | const mu = sum(lnValues) / n;
33 |
34 | // Sigma is calculated from the natural logs and their squares
35 | const sigma = Math.sqrt(
36 | (n * sum(sqLnValues) - sq(sum(lnValues))) /
37 | (n * (n - 1))
38 | );
39 |
40 | return {
41 | mu: round(mu, 3),
42 | sigma: round(sigma, 3),
43 | median: round(Math.pow(Math.E, mu), 0),
44 | };
45 | }
46 |
47 | /**
48 | * Adds all elements of an array
49 | */
50 | function sum(values: number[]): number {
51 | return values.reduce((a, b) => a + b, 0);
52 | }
53 |
54 | /**
55 | * Squares the number. This function is added to make the above sigma calculation more readable.
56 | */
57 | function sq(value: number): number {
58 | return Math.pow(value, 2);
59 | }
60 |
61 | /**
62 | * Gets the natural log of a number. This function is added to make the above sigma calculation more readable.
63 | */
64 | function ln(value: number): number {
65 | return Math.log(value);
66 | }
67 |
68 | /**
69 | * Rounds a number to a given decimal precision
70 | */
71 | function round(value: number, precision: number = 3): number {
72 | const multiplier = Math.pow(10, precision);
73 | return Math.round(value * multiplier) / multiplier;
74 | }
75 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## 0.2.0 - 10 May 2023
2 | ### Breaking Changes
3 | * The `ComposeLanguageClientCapabilities` type has been moved from `lib/client/DocumentSettings` to `lib/client/ClientCapabilities`
4 |
5 | ### Added
6 | * The client can now specify whether an alternative YAML language service is present (e.g., from the YAML extension), selectively disabling features of this language service. [#122](https://github.com/microsoft/compose-language-service/issues/122)
7 |
8 | ## 0.1.3 - 13 February 2023
9 | ### Added
10 | * Added an executable to launch the language server. [#114](https://github.com/microsoft/compose-language-service/issues/114)
11 |
12 | ## 0.1.2 - 20 July 2022
13 | ### Changed
14 | * Switched to Node 16 and updates some dependencies. [#98](https://github.com/microsoft/compose-language-service/pull/98), [#102](https://github.com/microsoft/compose-language-service/pull/102)
15 |
16 | ## 0.1.1 - 8 April 2022
17 | ### Added
18 | * Completions for the `profiles` section within a service. [#94](https://github.com/microsoft/compose-language-service/pull/94)
19 |
20 | ### Fixed
21 | * Formatting should no longer remove document end markers. [#93](https://github.com/microsoft/compose-language-service/issues/93)
22 |
23 | ## 0.1.0 - 14 February 2022
24 | ### Fixed
25 | * Merge keys are now allowed. [#78](https://github.com/microsoft/compose-language-service/issues/78)
26 | * Better error messages. [#88](https://github.com/microsoft/compose-language-service/pull/88)
27 |
28 | ## 0.0.5-alpha - 15 December 2021
29 | ### Added
30 | * Completions under the `build` section within a service. [#48](https://github.com/microsoft/compose-language-service/issues/48)
31 |
32 | ### Fixed
33 | * `null` will no longer be inserted on empty maps. [#65](https://github.com/microsoft/compose-language-service/issues/65)
34 | * Lines longer than 80 characters will no longer be wrapped. [#70](https://github.com/microsoft/compose-language-service/issues/70)
35 | * Completions will no longer be suggested on lines that are already complete. [#68](https://github.com/microsoft/compose-language-service/issues/68)
36 |
37 | ## 0.0.4-alpha - 8 November 2021
38 | ### Fixed
39 | * Removes test-scenario postinstall script as it was preventing installation.
40 |
41 | ## 0.0.3-alpha - 8 November 2021
42 | ### Fixed
43 | * A handful of minor bugs relating to position logic (especially affecting hover).
44 |
45 | ## 0.0.2-alpha - 29 October 2021
46 | ### Added
47 | * Significantly more completions have been added.
48 |
49 | ### Removed
50 | * Removed signature help for ports, in favor of completions instead.
51 |
52 | ## 0.0.1-alpha - 20 September 2021
53 | ### Added
54 | * Initial release!
55 | * Hyperlinks to Docker Hub for images
56 | * Hover info for many common Compose keys
57 | * Signature help for ports
58 | * Completions for volume mappings
59 | * Diagnostics (currently validates correct YAML only, does not enforce Compose schema)
60 | * Document formatting
61 |
--------------------------------------------------------------------------------
/src/test/TestConnection.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { PassThrough } from 'stream';
7 | import { Connection, DidOpenTextDocumentNotification, DidOpenTextDocumentParams, Disposable, InitializeParams, TextDocumentItem } from 'vscode-languageserver';
8 | import { DocumentUri } from 'vscode-languageserver-textdocument';
9 | import { createConnection } from 'vscode-languageserver/node';
10 | import { Document } from 'yaml';
11 | import { initEvent } from '../client/TelemetryEvent';
12 | import { ComposeLanguageService } from '../service/ComposeLanguageService';
13 | import { ActionContext } from '../service/utils/ActionContext';
14 |
15 | export const DefaultInitializeParams: InitializeParams = {
16 | capabilities: {},
17 | processId: 1,
18 | rootUri: null,
19 | workspaceFolders: null,
20 | };
21 |
22 | export class TestConnection implements Disposable {
23 | public readonly server: Connection;
24 | public readonly client: Connection;
25 | public readonly languageService: ComposeLanguageService;
26 | private counter = 0;
27 |
28 | public constructor(public readonly initParams: InitializeParams = DefaultInitializeParams) {
29 | const up = new PassThrough();
30 | const down = new PassThrough();
31 |
32 | this.server = createConnection(up, down);
33 | this.client = createConnection(down, up);
34 |
35 | this.languageService = new ComposeLanguageService(this.server, initParams);
36 |
37 | this.server.listen();
38 | this.client.listen();
39 | }
40 |
41 | public dispose(): void {
42 | this.languageService?.dispose();
43 | this.server?.dispose();
44 | this.client?.dispose();
45 | }
46 |
47 | public sendObjectAsYamlDocument(object: unknown): DocumentUri {
48 | const yamlInput = new Document(object);
49 | return this.sendTextAsYamlDocument(yamlInput.toString());
50 | }
51 |
52 | public sendTextAsYamlDocument(text: string): DocumentUri {
53 | const uri = `file:///a${this.counter++}`;
54 |
55 | const openParams: DidOpenTextDocumentParams = {
56 | textDocument: TextDocumentItem.create(uri, 'dockercompose', 1, text),
57 | };
58 |
59 | void this.client.sendNotification(DidOpenTextDocumentNotification.type, openParams);
60 | return uri;
61 | }
62 |
63 | public getMockContext(): ActionContext {
64 | return {
65 | clientCapabilities: this.initParams.capabilities,
66 | connection: this.server,
67 | telemetry: initEvent('mock'),
68 | };
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": false,
4 | "es6": true,
5 | "node": true
6 | },
7 | "parser": "@typescript-eslint/parser",
8 | "parserOptions": {
9 | "project": "tsconfig.json",
10 | "sourceType": "module"
11 | },
12 | "ignorePatterns": "src/test/clientExtension",
13 | "plugins": [
14 | "@typescript-eslint"
15 | ],
16 | "extends": [
17 | "eslint:recommended",
18 | "plugin:@typescript-eslint/eslint-recommended",
19 | "plugin:@typescript-eslint/recommended"
20 | ],
21 | "rules": {
22 | "@typescript-eslint/naming-convention": [ // Naming is enforced with some exceptions below
23 | "warn",
24 | { // Names should be either camelCase or PascalCase, both are extensively used throughout this project
25 | "selector": "default",
26 | "format": [
27 | "camelCase",
28 | "PascalCase"
29 | ]
30 | },
31 | { // const variables can also have UPPER_CASE
32 | "selector": "variable",
33 | "modifiers": [
34 | "const"
35 | ],
36 | "format": [
37 | "camelCase",
38 | "PascalCase",
39 | "UPPER_CASE"
40 | ]
41 | },
42 | { // private class properties can also have leading _underscores
43 | "selector": "classProperty",
44 | "modifiers": [
45 | "private"
46 | ],
47 | "format": [
48 | "camelCase",
49 | "PascalCase"
50 | ],
51 | "leadingUnderscore": "allow"
52 | }
53 | ],
54 | "@typescript-eslint/no-floating-promises": "warn", // Floating promises are bad, should do `void thePromise()`
55 | "@typescript-eslint/no-inferrable-types": "off", // This gets upset about e.g. `const foo: string = 'bar'` because it's obvious that it's a string; it doesn't matter enough to enforce
56 | "@typescript-eslint/no-unused-vars": [ // Unused variables aren't allowed, with an exception (below)
57 | "warn",
58 | { // As a function parameter, unused parameters are allowed
59 | "args": "none"
60 | }
61 | ],
62 | "@typescript-eslint/semi": "warn", // Elevate this to warning, we like semicolons
63 | "curly": "warn", // May have been a mistake to include a `{curly}` inside a template string, you might mean `${curly}`
64 | "eqeqeq": "warn", // Should use `===`, not `==`, nearly 100% of the time
65 | "no-extra-boolean-cast": "off", // We !!flatten a lot of things into booleans this way
66 | "no-throw-literal": "warn", // Elevate this from suggestion to warning
67 | "semi": "off" // Covered by @typescript-eslint/semi
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/service/providers/completion/BuildCompletionCollection.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { InsertTextFormat, InsertTextMode } from 'vscode-languageserver';
7 | import { CompletionCollection } from './CompletionCollection';
8 |
9 | /**
10 | * The position given when the cursor is inbetween the build key and first properties, i.e. at the | below:
11 | services:
12 | foo:
13 | build:
14 | |
15 | a: b
16 | */
17 | const PositionAtBuildPathRegex = /^\/services\/[.\w-]+\/build$/i; // e.g. /services/foo/build
18 |
19 | /**
20 | * The position given when the cursor is in a partially-typed properties in a service build, i.e. at the | below:
21 | services:
22 | foo:
23 | build:
24 | a: b
25 | b|
26 | */
27 | const PositionInBuildKeyPathRegex = /^\/services\/[.\w-]+\/build\/$/i; // e.g. /services/foo/build/
28 |
29 | export const BuildCompletionCollection = new CompletionCollection(
30 | 'build',
31 | { logicalPaths: [PositionAtBuildPathRegex, PositionInBuildKeyPathRegex], indentationDepth: 3 },
32 | ...[
33 | {
34 | label: 'args:',
35 | insertText: 'args:\n\t- ${1:name}=${2:value}$0',
36 | insertTextFormat: InsertTextFormat.Snippet,
37 | insertTextMode: InsertTextMode.adjustIndentation,
38 | isAdvancedComposeCompletion: true,
39 | },
40 | {
41 | label: 'context:',
42 | insertText: 'context: ${1:buildContext}$0',
43 | insertTextFormat: InsertTextFormat.Snippet,
44 | isAdvancedComposeCompletion: false,
45 | },
46 | {
47 | label: 'dockerfile:',
48 | insertText: 'dockerfile: ${1:dockerfile}$0',
49 | insertTextFormat: InsertTextFormat.Snippet,
50 | isAdvancedComposeCompletion: false,
51 | },
52 | {
53 | label: 'labels:',
54 | insertText: 'labels:\n\t- ${1:com.host.description}=${2:label}$0',
55 | insertTextFormat: InsertTextFormat.Snippet,
56 | insertTextMode: InsertTextMode.adjustIndentation,
57 | isAdvancedComposeCompletion: true,
58 | },
59 | {
60 | label: 'network:',
61 | insertText: 'network: ${1:networkName}$0',
62 | insertTextFormat: InsertTextFormat.Snippet,
63 | isAdvancedComposeCompletion: false,
64 | },
65 | {
66 | label: 'target:',
67 | insertText: 'target: ${1:targetStage}$0',
68 | insertTextFormat: InsertTextFormat.Snippet,
69 | isAdvancedComposeCompletion: false,
70 | },
71 | ]
72 | );
73 |
--------------------------------------------------------------------------------
/src/service/providers/completion/VolumesCompletionCollection.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { InsertTextFormat } from 'vscode-languageserver';
7 | import { CompletionCollection } from './CompletionCollection';
8 |
9 | export const VolumesCompletionCollection = new CompletionCollection(
10 | 'volumes',
11 | { logicalPaths: [/^\/services\/[.\w-]+\/volumes\/
- \/.*$/i], indentationDepth: 3 },
12 | ...[
13 | {
14 | // Matches ` - ""` or ` -`, with allowances for other amounts of whitespace/quoting
15 | matcher: /(\s*-\s*)(?"|')?\2\s*$/i,
16 | label: 'hostPath:containerPath:mode',
17 | insertText: '${1:hostPath}:${2:containerPath}:${3|ro,rw|}$0',
18 | insertTextFormat: InsertTextFormat.Snippet,
19 | isAdvancedComposeCompletion: true,
20 | },
21 | {
22 | // Matches ` - ""` or ` -`, with allowances for other amounts of whitespace/quoting
23 | matcher: /(\s*-\s*)(?"|')?\2\s*$/i,
24 | label: 'volumeName:containerPath:mode',
25 | insertText: '${1:volumeName}:${2:containerPath}:${3|ro,rw|}$0',
26 | insertTextFormat: InsertTextFormat.Snippet,
27 | isAdvancedComposeCompletion: true,
28 | },
29 | {
30 | // Matches ` - "C:\some\path:"` or ` - /some/path:`, with allowances for other amounts of whitespace/quoting
31 | matcher: /(\s*-\s*)(?"|')?(([a-z]:\\)?[^:"]+):\2\s*$/i,
32 | label: ':containerPath:mode',
33 | insertText: '${1:containerPath}:${2|ro,rw|}$0',
34 | insertTextFormat: InsertTextFormat.Snippet,
35 | isAdvancedComposeCompletion: true,
36 | },
37 | {
38 | // Matches ` - "C:\some\path:/another/path:"` or ` - /some/path:/another/path:`, with allowances for other amounts of whitespace/quoting
39 | matcher: /(\s*-\s*)(?"|')?(([a-z]:\\)?[^:"]+):(([a-z]:\\)?[^:"]+):\2\s*$/i,
40 | label: ':ro',
41 | insertText: 'ro',
42 | insertTextFormat: InsertTextFormat.PlainText,
43 | isAdvancedComposeCompletion: true,
44 | detail: 'Read-only',
45 | },
46 | {
47 | // Matches ` - "C:\some\path:/another/path:"` or ` - /some/path:/another/path:`, with allowances for other amounts of whitespace/quoting
48 | matcher: /(\s*-\s*)(?"|')?(([a-z]:\\)?[^:"]+):(([a-z]:\\)?[^:"]+):\2\s*$/i,
49 | label: ':rw',
50 | insertText: 'rw',
51 | insertTextFormat: InsertTextFormat.PlainText,
52 | isAdvancedComposeCompletion: true,
53 | detail: 'Read-write',
54 | },
55 | ]
56 | );
57 |
--------------------------------------------------------------------------------
/src/service/providers/DiagnosticProvider.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { Diagnostic, DiagnosticSeverity, TextDocumentChangeEvent } from 'vscode-languageserver';
7 | import { ComposeDocument } from '../ComposeDocument';
8 | import { ExtendedParams } from '../ExtendedParams';
9 | import { getCurrentContext } from '../utils/ActionContext';
10 | import { debounce } from '../utils/debounce';
11 | import { yamlRangeToLspRange } from '../utils/yamlRangeToLspRange';
12 | import { ProviderBase } from './ProviderBase';
13 |
14 | // The default time between when typing stops and when diagnostics will be sent (milliseconds)
15 | const DiagnosticDelay = 1000;
16 |
17 | export class DiagnosticProvider extends ProviderBase & ExtendedParams, void, never, never> {
18 | public constructor(
19 | private readonly diagnosticDelay: number = DiagnosticDelay,
20 | private readonly syntaxValidation: boolean,
21 | private readonly schemaValidation: boolean
22 | ) {
23 | super();
24 | }
25 |
26 | public on(params: TextDocumentChangeEvent & ExtendedParams): void {
27 | if (!this.syntaxValidation) {
28 | // Do nothing if syntax validation is disabled. At present schema validation is not supported, https://github.com/microsoft/compose-language-service/issues/84
29 | return;
30 | }
31 |
32 | const ctx = getCurrentContext();
33 |
34 | ctx.telemetry.suppressAll = true; // Diagnostics is async and telemetry won't really work
35 | ctx.telemetry.properties.isActivationEvent = 'true'; // In case we do someday enable it, let's make sure it's treated as an activation event since it is done automatically
36 |
37 | if (!ctx.clientCapabilities.textDocument?.publishDiagnostics) {
38 | return;
39 | }
40 |
41 | debounce(this.diagnosticDelay, { uri: params.document.textDocument.uri, callId: 'diagnostics' }, () => {
42 | const diagnostics: Diagnostic[] = [];
43 |
44 | for (const error of [...params.document.yamlDocument.value.errors, ...params.document.yamlDocument.value.warnings]) {
45 | diagnostics.push(
46 | Diagnostic.create(
47 | yamlRangeToLspRange(params.document.textDocument, error.pos),
48 | error.message,
49 | error.name === 'YAMLWarning' ? DiagnosticSeverity.Warning : DiagnosticSeverity.Error,
50 | error.code,
51 | )
52 | );
53 | }
54 |
55 | void ctx.connection.sendDiagnostics({
56 | uri: params.document.textDocument.uri,
57 | diagnostics: diagnostics,
58 | });
59 | }, this);
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/test/clientExtension/DocumentSettingsClientFeature.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import * as vscode from 'vscode';
7 | import type { ClientCapabilities, FeatureState, StaticFeature } from 'vscode-languageclient';
8 | import type { LanguageClient } from 'vscode-languageclient/node';
9 | import type { DocumentSettings, DocumentSettingsClientCapabilities, DocumentSettingsNotificationParams, DocumentSettingsParams } from '../../../lib/client/DocumentSettings'; // Dev-time-only imports, with `require` below for the real imports, to avoid desync issues or needing to actually install the langserver package
10 |
11 | export class DocumentSettingsClientFeature implements StaticFeature, vscode.Disposable {
12 | private disposables: vscode.Disposable[] = [];
13 |
14 | public constructor(private readonly client: LanguageClient) { }
15 |
16 | public getState(): FeatureState {
17 | return {
18 | kind: 'static'
19 | };
20 | }
21 |
22 | public fillClientCapabilities(capabilities: ClientCapabilities): void {
23 | const docSettingsClientCapabilities: DocumentSettingsClientCapabilities = {
24 | notify: true,
25 | request: true,
26 | };
27 |
28 | capabilities.experimental = {
29 | ...capabilities.experimental,
30 | documentSettings: docSettingsClientCapabilities,
31 | };
32 | }
33 |
34 | public initialize(): void {
35 | this.disposables.push(
36 | this.client.onRequest(
37 | require('../../../../lib/client/DocumentSettings').DocumentSettingsRequest.method,
38 | (params: DocumentSettingsParams): DocumentSettings | undefined => {
39 | const textEditor = vscode.window.visibleTextEditors.find(e => e.document.uri.toString() === params.textDocument.uri);
40 |
41 | if (!textEditor) {
42 | return undefined;
43 | }
44 |
45 | return {
46 | eol: textEditor.document.eol,
47 | tabSize: Number(textEditor.options.tabSize),
48 | };
49 | }
50 | )
51 | );
52 |
53 | this.disposables.push(
54 | vscode.window.onDidChangeTextEditorOptions(
55 | (e: vscode.TextEditorOptionsChangeEvent) => {
56 | const params: DocumentSettingsNotificationParams = {
57 | textDocument: { uri: e.textEditor.document.uri.toString() },
58 | eol: e.textEditor.document.eol,
59 | tabSize: Number(e.options.tabSize),
60 | };
61 |
62 | this.client.sendNotification(require('../../../../lib/client/DocumentSettings').DocumentSettingsNotification.method, params);
63 | }
64 | )
65 | );
66 | }
67 |
68 | public dispose(): void {
69 | this.disposables.forEach(d => d.dispose());
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Docker Compose Language Service
2 |
3 | [](https://github.com/microsoft/compose-language-service/actions/workflows/node.js.yml)
4 |
5 | ## Overview
6 |
7 | This project contains a language service for Docker Compose, implementing the [Language Server Protocol](https://microsoft.github.io/language-server-protocol/). It is shipped in the [Docker](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-docker) extension for Visual Studio Code.
8 |
9 | ## Features
10 |
11 | The Compose Language Service offers some common language service features like completions, signatures, diagnostics, document formatting, and hover hints. In addition, it contains some Docker-specific features like image names becoming hyperlinks to their corresponding pages on Docker Hub.
12 |
13 | The language service is intended to work primarily for the [Compose file version 3 spec](https://docs.docker.com/compose/compose-file/compose-file-v3/)--it will not support properties specific to versions 1 or 2--but it shouldn't interfere with development in such documents either.
14 |
15 | The language service is a work-in-progress, and will continue adding new features and functionality each release.
16 |
17 | ## Contributing
18 |
19 | This project welcomes contributions and suggestions. Most contributions require you to agree to a
20 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
21 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com.
22 |
23 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide
24 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions
25 | provided by the bot. You will only need to do this once across all repos using our CLA.
26 |
27 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
28 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or
29 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.
30 |
31 | ## Telemetry
32 |
33 | VS Code collects usage data and sends it to Microsoft to help improve our products and services. Read our [privacy statement](https://go.microsoft.com/fwlink/?LinkID=528096&clcid=0x409) to learn more. If you don’t wish to send usage data to Microsoft, you can set the `telemetry.enableTelemetry` setting to `false`. Learn more in our [FAQ](https://code.visualstudio.com/docs/supporting/faq#_how-to-disable-telemetry-reporting).
34 |
35 | This language service will emit VS Code-specific telemetry events. If using the service outside of VS Code (e.g. in Vim), these telemetry events can be safely ignored.
36 |
37 | ## Trademarks
38 |
39 | This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft
40 | trademarks or logos is subject to and must follow
41 | [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general).
42 | Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship.
43 | Any use of third-party trademarks or logos are subject to those third-party's policies.
44 |
--------------------------------------------------------------------------------
/src/test/providers/completion/requestCompletionsAndCompare.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { expect } from 'chai';
7 | import { CompletionItem, CompletionRequest, DocumentUri, InsertTextFormat, InsertTextMode, Position } from 'vscode-languageserver';
8 | import { TestConnection } from '../../TestConnection';
9 |
10 | export interface ExpectedCompletionItem {
11 | label: string;
12 | insertTextCanary: string;
13 | insertTextFormat?: InsertTextFormat;
14 | insertTextMode?: InsertTextMode;
15 | }
16 |
17 | export interface UnexpectedCompletionItem {
18 | insertTextCanary: string;
19 | }
20 |
21 | /**
22 | * Requests completions from the server and compares to expected/unexpected results
23 | * @param testConnection The test connection to use for sending the request
24 | * @param uri The URI of a document already sent to the test connection
25 | * @param position The position within the document to test at
26 | * @param expected Expected completion items, all of which must be present (if the result contains extra items they will NOT cause a failure). If undefined, the result must also be undefined.
27 | * @param unexpected Unexpected completion items, none of which may be present
28 | */
29 | export async function requestCompletionsAndCompare(testConnection: TestConnection, uri: DocumentUri, position: Position, expected: ExpectedCompletionItem[] | undefined, unexpected: UnexpectedCompletionItem[] | undefined): Promise {
30 | const result = await testConnection.client.sendRequest(CompletionRequest.type, { textDocument: { uri }, position: position }) as CompletionItem[] | undefined;
31 |
32 | if (!expected) {
33 | expect(result).to.not.be.ok; // Completion providers will return undefined rather than an empty list
34 | } else {
35 | if (expected.length) {
36 | expect(result).to.be.ok;
37 | /* eslint-disable @typescript-eslint/no-non-null-assertion */
38 | result!.length.should.be.greaterThanOrEqual(expected.length);
39 |
40 | for (const expectedItem of expected) {
41 | result!.some(ci => completionItemsMatch(expectedItem, ci)).should.be.true;
42 | }
43 | /* eslint-enable @typescript-eslint/no-non-null-assertion */
44 | }
45 |
46 | if (unexpected?.length && result?.length) {
47 | // If any of the unexpected items show up, fail
48 | for (const unexpectedItem of unexpected) {
49 | result.some(ci => (ci.insertText?.indexOf(unexpectedItem.insertTextCanary) ?? -1) >= 0).should.be.false;
50 | }
51 | }
52 | }
53 | }
54 |
55 | function completionItemsMatch(expected: ExpectedCompletionItem, actual: CompletionItem): boolean {
56 | return (
57 | actual.label === expected.label && // Labels must match
58 | (expected.insertTextCanary === undefined || (actual.insertText?.indexOf(expected.insertTextCanary) ?? -1) >= 0) && // Insert text must be defined and contain the value of `insertTextCanary`
59 | (actual.insertTextMode === expected.insertTextMode || expected.insertTextMode === undefined) && // If expected.insertTextMode is defined, the actual must match it
60 | (actual.insertTextFormat === expected.insertTextFormat || expected.insertTextFormat === undefined) // If expected.insertTextFormat is defined, the actual must match it
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/src/service/providers/completion/MultiCompletionProvider.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { CancellationToken, CompletionItem, CompletionParams, WorkDoneProgressReporter } from 'vscode-languageserver';
7 | import { ExtendedCompletionParams, ExtendedParams, ExtendedPositionParams } from '../../ExtendedParams';
8 | import { getCurrentContext } from '../../utils/ActionContext';
9 | import { ProviderBase } from '../ProviderBase';
10 | import { BuildCompletionCollection } from './BuildCompletionCollection';
11 | import { CompletionCollection } from './CompletionCollection';
12 | import { PortsCompletionCollection } from './PortsCompletionCollection';
13 | import { RootCompletionCollection } from './RootCompletionCollection';
14 | import { ServiceCompletionCollection } from './ServiceCompletionCollection';
15 | import { VolumesCompletionCollection } from './VolumesCompletionCollection';
16 |
17 | /**
18 | * Completions are one of the more involved features so we will split up the code, with this multi-provider calling each of them
19 | * Most will no-op but the results will all be aggregated upon return
20 | * Importantly, if any fail, we will throw an error--all other results will be ignored
21 | */
22 | export class MultiCompletionProvider extends ProviderBase {
23 | private readonly completionCollections: CompletionCollection[];
24 |
25 | public constructor(private readonly basicCompletions: boolean, private readonly advancedCompletions: boolean) {
26 | super();
27 |
28 | this.completionCollections = [
29 | RootCompletionCollection,
30 | ServiceCompletionCollection,
31 | BuildCompletionCollection,
32 | VolumesCompletionCollection,
33 | PortsCompletionCollection,
34 | ];
35 | }
36 |
37 | public override async on(params: CompletionParams & ExtendedPositionParams, token: CancellationToken, workDoneProgress: WorkDoneProgressReporter): Promise {
38 | const ctx = getCurrentContext();
39 |
40 | const extendedParams: ExtendedCompletionParams = {
41 | ...params,
42 | positionInfo: await params.document.getPositionInfo(params),
43 | basicCompletions: this.basicCompletions,
44 | advancedCompletions: this.advancedCompletions,
45 | };
46 |
47 | const results: CompletionItem[] = [];
48 | const respondingCollections: string[] = [];
49 |
50 | for (const collection of this.completionCollections) {
51 | // Within each loop we'll check for cancellation
52 | if (token.isCancellationRequested) {
53 | return undefined;
54 | }
55 |
56 | const subresults = collection.getActiveCompletionItems(extendedParams);
57 |
58 | if (subresults?.length) {
59 | respondingCollections.push(collection.name);
60 | results.push(...subresults);
61 | }
62 |
63 | // The set of collections that answer will be attached as a property
64 | ctx.telemetry.properties.respondingCollections = respondingCollections.sort().join(',');
65 | }
66 |
67 | // It should be noted, many of the completions include tabs `\t` which aren't allowed in YAML, however,
68 | // VSCode automatically translates these into the configured tab size in spaces. It does the same for line endings.
69 |
70 | return results.length > 0 ? results : undefined;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "type": "pwa-node",
9 | "request": "launch",
10 | "name": "Mocha All",
11 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha",
12 | "args": [
13 | "--timeout", // Set an infinite timeout when debugging so breakpoints don't mislead into thinking the test failed
14 | "0",
15 | "--file",
16 | "${workspaceFolder}/lib/test/global.test.js",
17 | "--recursive",
18 | "${workspaceFolder}/lib/test"
19 | ],
20 | "preLaunchTask": "${defaultBuildTask}",
21 | "resolveSourceMapLocations": [
22 | "${workspaceFolder}/**",
23 | "!**/node_modules/**"
24 | ],
25 | "internalConsoleOptions": "openOnSessionStart",
26 | "presentation": {
27 | "group": "Mocha",
28 | }
29 | },
30 | {
31 | "type": "pwa-node",
32 | "request": "launch",
33 | "name": "Mocha Unit Tests",
34 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha",
35 | "args": [
36 | "--timeout", // Set an infinite timeout when debugging so breakpoints don't mislead into thinking the test failed
37 | "0",
38 | "--file",
39 | "${workspaceFolder}/lib/test/global.test.js",
40 | "--grep",
41 | "/unit/i",
42 | "--recursive",
43 | "${workspaceFolder}/lib/test"
44 | ],
45 | "preLaunchTask": "${defaultBuildTask}",
46 | "resolveSourceMapLocations": [
47 | "${workspaceFolder}/**",
48 | "!**/node_modules/**"
49 | ],
50 | "internalConsoleOptions": "openOnSessionStart",
51 | "presentation": {
52 | "group": "Mocha",
53 | }
54 | },
55 | {
56 | "name": "Test Client Extension",
57 | "type": "extensionHost",
58 | "request": "launch",
59 | "args": [
60 | "--extensionDevelopmentPath=${workspaceFolder}/src/test/clientExtension",
61 | "--disable-extension=ms-azuretools.vscode-docker", // Keep the Docker extension from running so it doesn't interfere with testing
62 | "--disable-extension=redhat.vscode-yaml", // Keep the YAML extension from running so it doesn't interfere with testing
63 | ],
64 | "preLaunchTask": "tsc-watch: client extension",
65 | "presentation": {
66 | "hidden": true,
67 | }
68 | },
69 | {
70 | "name": "Server Attach",
71 | "port": 6009,
72 | "request": "attach",
73 | "skipFiles": [
74 | "/**"
75 | ],
76 | "type": "pwa-node",
77 | "timeout": 20000,
78 | "presentation": {
79 | "group": "Live testing",
80 | "order": 2
81 | }
82 | },
83 | ],
84 | "compounds": [
85 | {
86 | "name": "Client + Server",
87 | "configurations": [
88 | "Test Client Extension",
89 | "Server Attach"
90 | ],
91 | "presentation": {
92 | "group": "Live testing",
93 | "order": 1
94 | },
95 | "preLaunchTask": "tsc-watch: language service"
96 | }
97 | ]
98 | }
99 |
--------------------------------------------------------------------------------
/src/service/providers/ImageLinkProvider.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { CancellationToken, DocumentLink, DocumentLinkParams } from 'vscode-languageserver';
7 | import { isMap, isScalar, Scalar } from 'yaml';
8 | import { ExtendedParams } from '../ExtendedParams';
9 | import { getCurrentContext } from '../utils/ActionContext';
10 | import { yamlRangeToLspRange } from '../utils/yamlRangeToLspRange';
11 | import { ProviderBase } from './ProviderBase';
12 |
13 | const dockerHubImageRegex = /^(?[.\w-]+)(?:[.\w-]+)?$/i;
14 | const dockerHubNamespacedImageRegex = /^(?[a-z0-9]+)\/(?[.\w-]+)(?:[.\w-]+)?$/i;
15 | const mcrImageRegex = /^mcr.microsoft.com\/(?([a-z0-9]+\/)+)(?[.\w-]+)(?:[.\w-]+)?$/i;
16 |
17 | export class ImageLinkProvider extends ProviderBase {
18 | public on(params: DocumentLinkParams & ExtendedParams, token: CancellationToken): DocumentLink[] | undefined {
19 | const ctx = getCurrentContext();
20 | ctx.telemetry.properties.isActivationEvent = 'true'; // This happens automatically so we'll treat it as isActivationEvent === true
21 |
22 | const results: DocumentLink[] = [];
23 | const imageTypes = new Set();
24 |
25 | const serviceMap = params.document.yamlDocument.value.getIn(['services']);
26 | if (isMap(serviceMap)) {
27 | for (const service of serviceMap.items) {
28 | // Within each loop we'll check for cancellation (though this is expected to be very fast)
29 | if (token.isCancellationRequested) {
30 | return undefined;
31 | }
32 |
33 | if (isMap(service.value)) {
34 | const image = service.value.getIn(['image'], true);
35 | const hasBuild = service.value.has('build');
36 | if (!hasBuild && isScalar(image) && typeof image.value === 'string') {
37 | const quoteOffset = (image.type === Scalar.QUOTE_SINGLE || image.type === Scalar.QUOTE_DOUBLE) ? 1 : 0; // Offset if the scalar is quoted
38 | const link = ImageLinkProvider.getLinkForImage(image.value, imageTypes);
39 |
40 | if (link && image.range) {
41 | results.push(DocumentLink.create(yamlRangeToLspRange(params.document.textDocument, [quoteOffset + image.range[0] + link.start, quoteOffset + image.range[0] + link.start + link.length]), link.uri));
42 | }
43 | }
44 | }
45 | }
46 | }
47 |
48 | ctx.telemetry.properties.imageTypes = Array.from(imageTypes.values()).sort().join(',');
49 |
50 | return results;
51 | }
52 |
53 | private static getLinkForImage(image: string, imageTypes: Set): { uri: string, start: number, length: number } | undefined {
54 | let match: RegExpExecArray | null;
55 | let namespace: string | undefined;
56 | let imageName: string | undefined;
57 |
58 | if ((match = dockerHubImageRegex.exec(image)) &&
59 | (imageName = match.groups?.['imageName'])) {
60 |
61 | imageTypes.add('dockerHub');
62 |
63 | return {
64 | uri: `https://hub.docker.com/_/${imageName}`,
65 | start: match.index,
66 | length: imageName.length
67 | };
68 | } else if ((match = dockerHubNamespacedImageRegex.exec(image)) &&
69 | (namespace = match.groups?.['namespace']) &&
70 | (imageName = match.groups?.['imageName'])) {
71 |
72 | imageTypes.add('dockerHubNamespaced');
73 |
74 | return {
75 | uri: `https://hub.docker.com/r/${namespace}/${imageName}`,
76 | start: match.index,
77 | length: namespace.length + 1 + imageName.length // 1 is the length of the '/' after namespace
78 | };
79 | } else if ((match = mcrImageRegex.exec(image)) &&
80 | (namespace = match.groups?.['namespace']?.replace(/\/$/, '')) &&
81 | (imageName = match.groups?.['imageName'])) {
82 |
83 | imageTypes.add('mcr');
84 |
85 | return {
86 | uri: `https://hub.docker.com/_/microsoft-${namespace.replace('/', '-')}-${imageName}`,
87 | start: match.index,
88 | length: 18 + namespace.length + 1 + imageName.length // 18 is the length of 'mcr.microsoft.com/', 1 is the length of the '/' after namespace
89 | };
90 | }
91 | return undefined;
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/src/test/providers/DocumentFormattingProvider.test.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { expect } from 'chai';
7 | import { DocumentFormattingRequest, DocumentUri, FormattingOptions, ResponseError, TextEdit } from 'vscode-languageserver-protocol';
8 | import { TestConnection } from '../TestConnection';
9 |
10 | describe('DocumentFormattingProvider', () => {
11 | let testConnection: TestConnection;
12 | before('Populate the language server with a compose document', async () => {
13 | testConnection = new TestConnection();
14 | });
15 |
16 | describe('Common scenarios', () => {
17 | it('Should fix up bad spacing without semantically changing anything', async () => {
18 | const testObject = `version: '123'
19 | services:
20 | foo:
21 | image: bar
22 | build: .
23 | ports:
24 | - 1234`;
25 |
26 | const uri = testConnection.sendTextAsYamlDocument(testObject);
27 |
28 | const expected2Space = `version: '123'
29 | services:
30 | foo:
31 | image: bar
32 | build: .
33 | ports:
34 | - 1234
35 | `;
36 |
37 | const expected4Space = `version: '123'
38 | services:
39 | foo:
40 | image: bar
41 | build: .
42 | ports:
43 | - 1234
44 | `;
45 | await requestDocumentFormattingAndCompare(testConnection, uri, 2, expected2Space);
46 | await requestDocumentFormattingAndCompare(testConnection, uri, 4, expected4Space);
47 | });
48 |
49 | it('Should NOT insert null on empty maps', async () => {
50 | const testObject = `version: '123'
51 | services:
52 | foo:
53 | image: bar
54 | build: .
55 | ports:
56 | - 1234
57 |
58 | volumes:
59 | myvolume:\n`;
60 |
61 | const uri = testConnection.sendTextAsYamlDocument(testObject);
62 |
63 | const expected2Space = testObject; // Output will be unchanged, null must not be inserted
64 |
65 | await requestDocumentFormattingAndCompare(testConnection, uri, 2, expected2Space);
66 | });
67 |
68 | it('Should NOT wrap long string lines', async () => {
69 | const testObject = `version: '123'
70 | services:
71 | foo:
72 | image: bar
73 | build: .
74 | ports:
75 | - 1234
76 | labels:
77 | - "com.microsoft.testlongstringlinesnowrapping=thequickbrownfoxjumpsoverthelazydog"
78 | `;
79 |
80 | const uri = testConnection.sendTextAsYamlDocument(testObject);
81 |
82 | const expected2Space = testObject; // Output will be unchanged, wrapping must not occur
83 |
84 | await requestDocumentFormattingAndCompare(testConnection, uri, 2, expected2Space);
85 | });
86 | });
87 |
88 | describe('Error scenarios', () => {
89 | it('Should return an error for nonexistent files', () => {
90 | return testConnection
91 | .client.sendRequest(DocumentFormattingRequest.type, { textDocument: { uri: 'file:///bogus' }, options: FormattingOptions.create(2, true) })
92 | .should.eventually.be.rejectedWith(ResponseError);
93 | });
94 |
95 | it('Should NOT try formatting a syntactically incorrect document', async () => {
96 | const testFile = `version: '123'
97 | services:
98 | foo:
99 | image: bar
100 | build: .
101 | ports:
102 | - 1234`;
103 |
104 | const uri = testConnection.sendTextAsYamlDocument(testFile);
105 |
106 | const expected = undefined;
107 |
108 | await requestDocumentFormattingAndCompare(testConnection, uri, 2, expected);
109 | });
110 | });
111 |
112 | after('Cleanup', () => {
113 | testConnection.dispose();
114 | });
115 | });
116 |
117 | async function requestDocumentFormattingAndCompare(testConnection: TestConnection, uri: DocumentUri, tabSize: number, expected: string | undefined): Promise {
118 | const result = await testConnection.client.sendRequest(DocumentFormattingRequest.type, { textDocument: { uri }, options: FormattingOptions.create(tabSize, true) }) as TextEdit[] | null;
119 |
120 | if (expected === undefined) {
121 | expect(result).to.not.be.ok;
122 | } else {
123 | expect(result).to.be.ok;
124 |
125 | /* eslint-disable @typescript-eslint/no-non-null-assertion */
126 | result!.length.should.equal(1); // As of today, the formatter only ever rewrites the whole document
127 | result![0].newText.should.equal(expected);
128 | /* eslint-enable @typescript-eslint/no-non-null-assertion */
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/src/test/providers/completion/BuildCompletionCollection.test.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { CompletionRequest, InsertTextFormat, InsertTextMode, Position, ResponseError } from 'vscode-languageserver';
7 | import { TestConnection } from '../../TestConnection';
8 | import { ExpectedCompletionItem, requestCompletionsAndCompare, UnexpectedCompletionItem } from './requestCompletionsAndCompare';
9 |
10 | // A subset of the completions that are provided by the BuildCompletionCollection
11 | const defaultExpected: ExpectedCompletionItem[] = [
12 | {
13 | // Context
14 | label: 'context:',
15 | insertTextCanary: 'buildContext',
16 | insertTextFormat: InsertTextFormat.Snippet,
17 | },
18 | {
19 | // Dockerfile
20 | label: 'dockerfile:',
21 | insertTextCanary: 'dockerfile',
22 | insertTextFormat: InsertTextFormat.Snippet,
23 | },
24 | {
25 | // Args
26 | label: 'args:',
27 | insertTextCanary: 'value',
28 | insertTextFormat: InsertTextFormat.Snippet,
29 | insertTextMode: InsertTextMode.adjustIndentation,
30 | },
31 | ];
32 |
33 | // Completions that are not allowed from BuildCompletionCollection
34 | const defaultUnexpected: UnexpectedCompletionItem[] = [
35 | {
36 | insertTextCanary: 'services',
37 | },
38 | {
39 | insertTextCanary: 'containerPort',
40 | },
41 | {
42 | insertTextCanary: 'containerPath',
43 | },
44 | {
45 | insertTextCanary: 'healthcheck',
46 | },
47 | {
48 | insertTextCanary: 'build:',
49 | },
50 | ];
51 |
52 | describe('BuildCompletionCollection', () => {
53 | let testConnection: TestConnection;
54 | before('Prepare a language server for testing', async () => {
55 | testConnection = new TestConnection();
56 | });
57 |
58 | describe('Common scenarios', () => {
59 | it('Should provide completions when within the build tag', async () => {
60 | const testObject = `services:
61 | foo:
62 | image: redis
63 | build:
64 | `;
65 |
66 | const uri = testConnection.sendTextAsYamlDocument(testObject);
67 |
68 | await requestCompletionsAndCompare(
69 | testConnection,
70 | uri,
71 | Position.create(4, 6), // Indented on the line under `build`
72 | defaultExpected,
73 | defaultUnexpected
74 | );
75 | });
76 |
77 | it('Should NOT provide completions at the root', async () => {
78 | const testObject = `version: '3.4'
79 | `;
80 |
81 | const uri = testConnection.sendTextAsYamlDocument(testObject);
82 |
83 | await requestCompletionsAndCompare(
84 | testConnection,
85 | uri,
86 | Position.create(1, 0), // The start of the line after `version`
87 | [],
88 | defaultExpected
89 | );
90 | });
91 |
92 | it('Should NOT provide completions in an empty document', async () => {
93 | const testObject = '';
94 |
95 | const uri = testConnection.sendTextAsYamlDocument(testObject);
96 |
97 | await requestCompletionsAndCompare(
98 | testConnection,
99 | uri,
100 | Position.create(0, 0), // The start of the first line
101 | [],
102 | defaultExpected
103 | );
104 | });
105 |
106 | it('Should NOT provide completions if over-indented under build', async () => {
107 | const testObject = `services:
108 | foo:
109 | image: redis
110 | build:
111 | context: .
112 | `;
113 |
114 | const uri = testConnection.sendTextAsYamlDocument(testObject);
115 |
116 | await requestCompletionsAndCompare(
117 | testConnection,
118 | uri,
119 | Position.create(5, 8), // Indented further on the line under `context`
120 | [],
121 | defaultExpected
122 | );
123 | });
124 | });
125 |
126 | describe('Error scenarios', () => {
127 | it('Should return an error for nonexistent files', () => {
128 | return testConnection
129 | .client.sendRequest(CompletionRequest.type, { textDocument: { uri: 'file:///bogus' }, position: Position.create(0, 0) })
130 | .should.eventually.be.rejectedWith(ResponseError);
131 | });
132 | });
133 |
134 | after('Cleanup', () => {
135 | testConnection.dispose();
136 | });
137 | });
138 |
--------------------------------------------------------------------------------
/src/test/utils/debounce.test.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import * as chai from 'chai';
7 | import { debounce } from '../../service/utils/debounce';
8 |
9 | // This delay is used as the delay given to `debounce`, but also assertions are made that each test should take at least (delay - 1) milliseconds (minus 1 is for sanity against rounding)
10 | const delay = 10;
11 |
12 | describe('(Unit) debounce', () => {
13 | describe('Common scenarios', () => {
14 | it('Should filter multiple calls into one', async () => {
15 | let x = 0;
16 | const start = process.hrtime.bigint();
17 |
18 | await new Promise((resolve) => {
19 | debounce(delay, { callId: 'debounce', uri: 'file:///foo', }, () => {
20 | x++;
21 | chai.assert.fail('Should not be executing this debounce call');
22 | });
23 |
24 | debounce(delay, { callId: 'debounce', uri: 'file:///foo', }, () => {
25 | x++;
26 | resolve();
27 | });
28 | });
29 |
30 | const stop = process.hrtime.bigint();
31 | const elapsedMs = Number((stop - start) / BigInt(1000 * 1000));
32 |
33 | x.should.equal(1); // x should only have been incremented once
34 | elapsedMs.should.be.greaterThanOrEqual(delay - 1); // It should take at least ms to get to this point
35 | });
36 |
37 | it('Should debounce separate call IDs in the same document separately', async () => {
38 | let x = 0;
39 | const start = process.hrtime.bigint();
40 |
41 | await Promise.all([
42 | new Promise((resolve) => {
43 | debounce(delay, { callId: 'debounce1', uri: 'file:///foo', }, () => {
44 | x++;
45 | resolve();
46 | });
47 | }),
48 |
49 | new Promise((resolve) => {
50 | debounce(delay, { callId: 'debounce2', uri: 'file:///foo', }, () => {
51 | x++;
52 | resolve();
53 | });
54 | })
55 | ]);
56 |
57 | const stop = process.hrtime.bigint();
58 | const elapsedMs = Number((stop - start) / BigInt(1000 * 1000));
59 |
60 | x.should.equal(2); // x should have been incremented twice
61 | elapsedMs.should.be.greaterThanOrEqual(delay - 1); // It should take at least ms to get to this point
62 | });
63 |
64 | it('Should debounce the same call ID in separate documents separately', async () => {
65 | let x = 0;
66 | const start = process.hrtime.bigint();
67 |
68 | await Promise.all([
69 | new Promise((resolve) => {
70 | debounce(delay, { callId: 'debounce', uri: 'file:///foo1', }, () => {
71 | x++;
72 | resolve();
73 | });
74 | }),
75 |
76 | new Promise((resolve) => {
77 | debounce(delay, { callId: 'debounce', uri: 'file:///foo2', }, () => {
78 | x++;
79 | resolve();
80 | });
81 | })
82 | ]);
83 |
84 | const stop = process.hrtime.bigint();
85 | const elapsedMs = Number((stop - start) / BigInt(1000 * 1000));
86 |
87 | x.should.equal(2); // x should have been incremented twice
88 | elapsedMs.should.be.greaterThanOrEqual(delay - 1); // It should take at least ms to get to this point
89 | });
90 |
91 | it('Should pass along the thisArg', async () => {
92 | const start = process.hrtime.bigint();
93 |
94 | await new Promise((resolve) => {
95 | const x = {
96 | test: function () {
97 | this.should.be.ok;
98 | this.foo.should.equal(1);
99 | resolve();
100 | },
101 | foo: 1,
102 | };
103 |
104 | debounce(delay, { callId: 'debounce', uri: 'file:///foo', }, x.test, x);
105 | });
106 |
107 | const stop = process.hrtime.bigint();
108 | const elapsedMs = Number((stop - start) / BigInt(1000 * 1000));
109 |
110 | elapsedMs.should.be.greaterThanOrEqual(delay - 1); // It should take at least ms to get to this point
111 | });
112 | });
113 | });
114 |
--------------------------------------------------------------------------------
/src/service/utils/telemetry/TelemetryAggregator.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { Connection, Disposable } from 'vscode-languageserver';
7 | import { initEvent, TelemetryEvent } from '../../../client/TelemetryEvent';
8 | import { logNormal } from './logNormal';
9 |
10 | // One minute flush interval by default
11 | const FlushIntervalMilliseconds = 60 * 1000;
12 |
13 | export class TelemetryAggregator implements Disposable {
14 | private eventBuffer: TelemetryEvent[] = [];
15 | private readonly timer: NodeJS.Timeout;
16 |
17 | public constructor(private readonly connection: Connection, private readonly interval: number = FlushIntervalMilliseconds) {
18 | this.timer = setInterval(() => this.flush(), this.interval);
19 | }
20 |
21 | public dispose(): void {
22 | clearInterval(this.timer);
23 |
24 | // Flush one last time
25 | this.flush();
26 | }
27 |
28 | public logEvent(event: TelemetryEvent): void {
29 | if (event.suppressAll) {
30 | // Do nothing, this event is suppressed
31 | } else if (event.properties.result === 'Succeeded' && event.suppressIfSuccessful) {
32 | // Do nothing, the event succeeded and has suppressIfSuccessful === true
33 | } else if (event.properties.result === 'Failed') {
34 | // Event is an error, send immediately rather than buffering
35 | this.connection.telemetry.logEvent(event);
36 | } else {
37 | // Add it to the event buffer to be flushed on the interval
38 | this.eventBuffer.push(event);
39 | }
40 | }
41 |
42 | private flush(): void {
43 | try {
44 | for (const evt of this.getAggregatedEvents()) {
45 | this.connection.telemetry.logEvent(evt);
46 | }
47 | } catch (err) {
48 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
49 | const error = err instanceof Error ? err : Error((err as any).toString());
50 | const telemetryFailedEvent = initEvent('telemetryaggregatorfailure');
51 |
52 | telemetryFailedEvent.properties.result = 'Failed';
53 | telemetryFailedEvent.properties.error = error.name;
54 | telemetryFailedEvent.properties.errorMessage = error.message;
55 | telemetryFailedEvent.properties.stack = error.stack;
56 |
57 | this.connection.telemetry.logEvent(telemetryFailedEvent);
58 | } finally {
59 | // Finally, clear out the buffer
60 | this.eventBuffer = [];
61 | }
62 | }
63 |
64 | private getAggregatedEvents(): TelemetryEvent[] {
65 | const aggregated: TelemetryEvent[] = [];
66 | const eventGroups = new Map();
67 |
68 | // Group events according to their grouping strategy
69 | for (const evt of this.eventBuffer) {
70 | let key: string;
71 | switch (evt.groupingStrategy) {
72 | case 'eventNameAndProperties':
73 | key = evt.eventName + JSON.stringify(evt.properties);
74 | break;
75 | case 'eventName':
76 | default:
77 | key = evt.eventName;
78 | break;
79 | }
80 |
81 | if (!eventGroups.has(key)) {
82 | eventGroups.set(key, []);
83 | }
84 |
85 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
86 | eventGroups.get(key)!.push(evt);
87 | }
88 |
89 | // For each group, aggregate properties and add performance statistics, to get one aggregated event per group
90 | for (const events of eventGroups.values()) {
91 | const eventName = events[0].eventName;
92 |
93 | const aggregatedEvent = initEvent(eventName);
94 |
95 | // Aggregate the performance statistics
96 | const durations = events.map(e => e.measurements.duration ?? undefined).filter(d => d !== undefined) as number[] || [];
97 | const stats = logNormal(durations);
98 |
99 | aggregatedEvent.measurements.count = events.length;
100 | aggregatedEvent.measurements.durationMu = stats.mu;
101 | aggregatedEvent.measurements.durationSigma = stats.sigma;
102 | aggregatedEvent.measurements.durationMedian = stats.median;
103 |
104 | // Aggregate the properties--this will apply all properties from all events, with the recent events overriding prior events if there is a conflict
105 | // If the grouping strategy is 'eventNameAndProperties', there will inherently never be conflicts, since their values must be identical
106 | events.forEach(evt => Object.assign(aggregatedEvent.properties, evt.properties));
107 |
108 | aggregated.push(aggregatedEvent);
109 | }
110 |
111 | return aggregated;
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/src/test/providers/completion/ServiceCompletionCollection.test.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { CompletionRequest, InsertTextFormat, InsertTextMode, Position, ResponseError } from 'vscode-languageserver';
7 | import { TestConnection } from '../../TestConnection';
8 | import { ExpectedCompletionItem, requestCompletionsAndCompare, UnexpectedCompletionItem } from './requestCompletionsAndCompare';
9 |
10 | // A subset of the completions that are provided by the ServiceCompletionCollection
11 | const defaultExpected: ExpectedCompletionItem[] = [
12 | {
13 | // Build long form
14 | label: 'build:',
15 | insertTextCanary: 'context',
16 | insertTextFormat: InsertTextFormat.Snippet,
17 | insertTextMode: InsertTextMode.adjustIndentation,
18 | },
19 | {
20 | // Build short form
21 | label: 'build:',
22 | insertTextCanary: 'path',
23 | insertTextFormat: InsertTextFormat.Snippet,
24 | },
25 | {
26 | label: 'image:',
27 | insertTextCanary: 'image',
28 | insertTextFormat: InsertTextFormat.Snippet,
29 | },
30 | {
31 | label: 'healthcheck:',
32 | insertTextCanary: 'healthcheck',
33 | insertTextFormat: InsertTextFormat.Snippet,
34 | insertTextMode: InsertTextMode.adjustIndentation,
35 | },
36 | ];
37 |
38 | // Completions that are not allowed from ServiceCompletionCollection
39 | const defaultUnexpected: UnexpectedCompletionItem[] = [
40 | {
41 | insertTextCanary: 'services',
42 | },
43 | {
44 | insertTextCanary: 'containerPort',
45 | },
46 | {
47 | insertTextCanary: 'containerPath',
48 | },
49 | ];
50 |
51 | describe('ServiceCompletionCollection', () => {
52 | let testConnection: TestConnection;
53 | before('Prepare a language server for testing', async () => {
54 | testConnection = new TestConnection();
55 | });
56 |
57 | describe('Common scenarios', () => {
58 | it('Should provide completions when within a service', async () => {
59 | const testObject = `services:
60 | foo:
61 | image: redis
62 | `;
63 |
64 | const uri = testConnection.sendTextAsYamlDocument(testObject);
65 |
66 | await requestCompletionsAndCompare(
67 | testConnection,
68 | uri,
69 | Position.create(3, 4), // Indented on the line under `image`
70 | defaultExpected,
71 | defaultUnexpected
72 | );
73 | });
74 |
75 | it('Should provide completions when within a service, even with extra whitespace', async () => {
76 | const testObject = `services:
77 | foo:
78 | image: redis
79 |
80 | `;
81 |
82 | const uri = testConnection.sendTextAsYamlDocument(testObject);
83 |
84 | await requestCompletionsAndCompare(
85 | testConnection,
86 | uri,
87 | Position.create(4, 4), // Indented on the second line under `image`
88 | defaultExpected,
89 | defaultUnexpected
90 | );
91 | });
92 |
93 | it('Should NOT provide completions at the root', async () => {
94 | const testObject = `version: '3.4'
95 | `;
96 |
97 | const uri = testConnection.sendTextAsYamlDocument(testObject);
98 |
99 | await requestCompletionsAndCompare(
100 | testConnection,
101 | uri,
102 | Position.create(1, 0), // The start of the line after `version`
103 | [],
104 | defaultExpected
105 | );
106 | });
107 |
108 | it('Should NOT provide completions in an empty document', async () => {
109 | const testObject = '';
110 |
111 | const uri = testConnection.sendTextAsYamlDocument(testObject);
112 |
113 | await requestCompletionsAndCompare(
114 | testConnection,
115 | uri,
116 | Position.create(0, 0), // The start of the first line
117 | [],
118 | defaultExpected
119 | );
120 | });
121 |
122 | it('Should NOT provide completions if over-indented under services', async () => {
123 | const testObject = `services:
124 | foo:
125 | image: redis
126 | `;
127 |
128 | const uri = testConnection.sendTextAsYamlDocument(testObject);
129 |
130 | await requestCompletionsAndCompare(
131 | testConnection,
132 | uri,
133 | Position.create(3, 6), // Indented further on the line under `image`
134 | [],
135 | defaultExpected
136 | );
137 | });
138 |
139 | it('Should NOT provide completions on an already-completed line', async () => {
140 | const testObject = `services:
141 | foo:
142 | image:`;
143 |
144 | const uri = testConnection.sendTextAsYamlDocument(testObject);
145 |
146 | await requestCompletionsAndCompare(
147 | testConnection,
148 | uri,
149 | Position.create(2, 10), // After `image:`
150 | undefined,
151 | undefined,
152 | );
153 | });
154 | });
155 |
156 | describe('Error scenarios', () => {
157 | it('Should return an error for nonexistent files', () => {
158 | return testConnection
159 | .client.sendRequest(CompletionRequest.type, { textDocument: { uri: 'file:///bogus' }, position: Position.create(0, 0) })
160 | .should.eventually.be.rejectedWith(ResponseError);
161 | });
162 | });
163 |
164 | after('Cleanup', () => {
165 | testConnection.dispose();
166 | });
167 | });
168 |
--------------------------------------------------------------------------------
/src/test/providers/completion/PortsCompletionCollection.test.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { CompletionRequest, InsertTextFormat, Position, ResponseError } from 'vscode-languageserver';
7 | import { TestConnection } from '../../TestConnection';
8 | import { ExpectedCompletionItem, requestCompletionsAndCompare, UnexpectedCompletionItem } from './requestCompletionsAndCompare';
9 |
10 | // Completions that are not allowed from PortsCompletionCollection
11 | const defaultUnexpected: UnexpectedCompletionItem[] = [
12 | {
13 | insertTextCanary: 'services',
14 | },
15 | {
16 | insertTextCanary: 'build',
17 | },
18 | {
19 | insertTextCanary: 'containerPath',
20 | },
21 | ];
22 |
23 | describe('PortsCompletionCollection', () => {
24 | let testConnection: TestConnection;
25 | before('Prepare a language server for testing', async () => {
26 | testConnection = new TestConnection();
27 | });
28 |
29 | describe('Common scenarios', () => {
30 | it('Should provide completions when in a port mapping', async () => {
31 | const testObject = `services:
32 | foo:
33 | ports:
34 | - `;
35 |
36 | const uri = testConnection.sendTextAsYamlDocument(testObject);
37 |
38 | const expected: ExpectedCompletionItem[] = [
39 | {
40 | label: 'containerPort',
41 | insertTextCanary: '80',
42 | insertTextFormat: InsertTextFormat.Snippet,
43 | },
44 | {
45 | label: 'hostPort:containerPort',
46 | insertTextCanary: '8080',
47 | insertTextFormat: InsertTextFormat.Snippet,
48 | },
49 | {
50 | label: 'hostPort:containerPort/protocol',
51 | insertTextCanary: 'tcp,udp',
52 | insertTextFormat: InsertTextFormat.Snippet,
53 | },
54 | {
55 | label: 'hostRange:containerRange',
56 | insertTextCanary: '8081',
57 | insertTextFormat: InsertTextFormat.Snippet,
58 | },
59 | {
60 | label: '(Long form port specification)',
61 | insertTextCanary: 'published',
62 | insertTextFormat: InsertTextFormat.Snippet,
63 | },
64 | ];
65 |
66 | await requestCompletionsAndCompare(
67 | testConnection,
68 | uri,
69 | Position.create(3, 7), // Immediately after the dash under `ports:`
70 | expected,
71 | defaultUnexpected
72 | );
73 |
74 | await requestCompletionsAndCompare(
75 | testConnection,
76 | uri,
77 | Position.create(3, 8), // One space after the dash under `ports:`
78 | expected,
79 | defaultUnexpected
80 | );
81 | });
82 |
83 | it('Should NOT provide completions at the root', async () => {
84 | const testObject = `version: '3.4'
85 | `;
86 |
87 | const uri = testConnection.sendTextAsYamlDocument(testObject);
88 |
89 | const unexpected: UnexpectedCompletionItem[] = [
90 | {
91 | insertTextCanary: 'containerPort',
92 | },
93 | {
94 | insertTextCanary: 'containerRange',
95 | },
96 | {
97 | insertTextCanary: 'published',
98 | },
99 | ];
100 |
101 | await requestCompletionsAndCompare(
102 | testConnection,
103 | uri,
104 | Position.create(1, 0), // The start of the line after `version`
105 | [],
106 | unexpected
107 | );
108 | });
109 |
110 | it('Should NOT provide completions in an empty document', async () => {
111 | const testObject = ``;
112 |
113 | const uri = testConnection.sendTextAsYamlDocument(testObject);
114 |
115 | const unexpected: UnexpectedCompletionItem[] = [
116 | {
117 | insertTextCanary: 'containerPort',
118 | },
119 | {
120 | insertTextCanary: 'containerRange',
121 | },
122 | {
123 | insertTextCanary: 'published',
124 | },
125 | ];
126 |
127 | await requestCompletionsAndCompare(
128 | testConnection,
129 | uri,
130 | Position.create(0, 0), // The start of the first line
131 | [],
132 | unexpected
133 | );
134 | });
135 |
136 | it('Should NOT provide completions under volumes', async () => {
137 | const testObject = `services:
138 | foo:
139 | volumes:
140 | - `;
141 |
142 | const uri = testConnection.sendTextAsYamlDocument(testObject);
143 |
144 | const unexpected: UnexpectedCompletionItem[] = [
145 | {
146 | insertTextCanary: 'containerPort',
147 | },
148 | {
149 | insertTextCanary: 'containerRange',
150 | },
151 | {
152 | insertTextCanary: 'published',
153 | },
154 | ];
155 |
156 | await requestCompletionsAndCompare(
157 | testConnection,
158 | uri,
159 | Position.create(3, 7), // Immediately after the dash under `ports:`
160 | [],
161 | unexpected
162 | );
163 |
164 | await requestCompletionsAndCompare(
165 | testConnection,
166 | uri,
167 | Position.create(3, 8), // One space after the dash under `ports:`
168 | [],
169 | unexpected
170 | );
171 | });
172 | });
173 |
174 | describe('Error scenarios', () => {
175 | it('Should return an error for nonexistent files', () => {
176 | return testConnection
177 | .client.sendRequest(CompletionRequest.type, { textDocument: { uri: 'file:///bogus' }, position: Position.create(0, 0) })
178 | .should.eventually.be.rejectedWith(ResponseError);
179 | });
180 | });
181 |
182 | after('Cleanup', () => {
183 | testConnection.dispose();
184 | });
185 | });
186 |
--------------------------------------------------------------------------------
/src/service/providers/completion/ServiceCompletionCollection.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { InsertTextFormat, InsertTextMode } from 'vscode-languageserver';
7 | import { CompletionCollection } from './CompletionCollection';
8 |
9 | /**
10 | * The position given when the cursor is inbetween the service key and first properties, i.e. at the | below:
11 | services:
12 | foo:
13 | |
14 | a: b
15 | */
16 | const PositionAtServicePathRegex = /^\/services\/[.\w-]+$/i; // e.g. /services/foo
17 |
18 | /**
19 | * The position given when the cursor is in a partially-typed properties in a service, i.e. at the | below:
20 | services:
21 | foo:
22 | a: b
23 | b|
24 | */
25 | const PositionInServiceKeyPathRegex = /^\/services\/[.\w-]+\/$/i; // e.g. /services/foo/
26 |
27 | export const ServiceCompletionCollection = new CompletionCollection(
28 | 'service',
29 | { logicalPaths: [PositionAtServicePathRegex, PositionInServiceKeyPathRegex], indentationDepth: 2 },
30 | ...[
31 | {
32 | label: 'build:',
33 | insertText: 'build: ${1:path}$0',
34 | insertTextFormat: InsertTextFormat.Snippet,
35 | isAdvancedComposeCompletion: false,
36 | detail: 'Short form',
37 | documentation: 'build: ',
38 | },
39 | {
40 | label: 'build:',
41 | insertText: 'build:\n\tcontext: ${1:contextPath}\n\tdockerfile: ${2:Dockerfile}',
42 | insertTextFormat: InsertTextFormat.Snippet,
43 | insertTextMode: InsertTextMode.adjustIndentation,
44 | isAdvancedComposeCompletion: true,
45 | detail: 'Long form',
46 | documentation: 'build:\n\tcontext: \n\tdockerfile: ',
47 | },
48 | {
49 | label: 'command:',
50 | insertText: 'command: ${1:command}$0',
51 | insertTextFormat: InsertTextFormat.Snippet,
52 | isAdvancedComposeCompletion: false,
53 | detail: 'String form',
54 | documentation: 'command: echo hello'
55 | },
56 | {
57 | label: 'command:',
58 | insertText: 'command: ["${1:executable}", "${2:arg}"]$0',
59 | insertTextFormat: InsertTextFormat.Snippet,
60 | isAdvancedComposeCompletion: true,
61 | detail: 'List form',
62 | documentation: 'command: ["echo", "hello"]'
63 | },
64 | {
65 | label: 'container_name:',
66 | insertText: 'container_name: ${1:name}$0',
67 | insertTextFormat: InsertTextFormat.Snippet,
68 | isAdvancedComposeCompletion: false,
69 | },
70 | {
71 | label: 'depends_on:',
72 | insertText: 'depends_on:\n\t- ${1:serviceName}$0',
73 | insertTextFormat: InsertTextFormat.Snippet,
74 | insertTextMode: InsertTextMode.adjustIndentation,
75 | isAdvancedComposeCompletion: true,
76 | },
77 | {
78 | label: 'entrypoint:',
79 | insertText: 'entrypoint: ${1:entrypoint}$0',
80 | insertTextFormat: InsertTextFormat.Snippet,
81 | isAdvancedComposeCompletion: false,
82 | detail: 'String form',
83 | documentation: 'entrypoint: /app/start.sh'
84 | },
85 | {
86 | label: 'entrypoint:',
87 | insertText: 'entrypoint: ["${1:executable}", "${2:arg}"]$0',
88 | insertTextFormat: InsertTextFormat.Snippet,
89 | isAdvancedComposeCompletion: true,
90 | detail: 'List form',
91 | documentation: 'entrypoint: ["echo", "hello"]'
92 | },
93 | {
94 | label: 'env_file:',
95 | insertText: 'env_file:\n\t- ${1:fileName}$0',
96 | insertTextFormat: InsertTextFormat.Snippet,
97 | insertTextMode: InsertTextMode.adjustIndentation,
98 | isAdvancedComposeCompletion: true,
99 | },
100 | {
101 | label: 'environment:',
102 | insertText: 'environment:\n\t- ${1:name}=${2:value}$0',
103 | insertTextFormat: InsertTextFormat.Snippet,
104 | insertTextMode: InsertTextMode.adjustIndentation,
105 | isAdvancedComposeCompletion: true,
106 | },
107 | {
108 | label: 'expose:',
109 | insertText: 'expose:\n\t- ${1:1234}$0',
110 | insertTextFormat: InsertTextFormat.Snippet,
111 | insertTextMode: InsertTextMode.adjustIndentation,
112 | isAdvancedComposeCompletion: true,
113 | },
114 | {
115 | label: 'healthcheck:',
116 | insertText: 'healthcheck:\n\ttest: ["${1:executable}", "${2:arg}"]\n\tinterval: ${3:1m30s}\n\ttimeout: ${4:30s}\n\tretries: ${5:5}\n\tstart_period: ${6:30s}$0',
117 | insertTextFormat: InsertTextFormat.Snippet,
118 | insertTextMode: InsertTextMode.adjustIndentation,
119 | isAdvancedComposeCompletion: true,
120 | },
121 | {
122 | label: 'image:',
123 | insertText: 'image: ${1:imageName}$0',
124 | insertTextFormat: InsertTextFormat.Snippet,
125 | isAdvancedComposeCompletion: false,
126 | },
127 | {
128 | label: 'labels:',
129 | insertText: 'labels:\n\t- ${1:com.host.description}=${2:label}$0',
130 | insertTextFormat: InsertTextFormat.Snippet,
131 | insertTextMode: InsertTextMode.adjustIndentation,
132 | isAdvancedComposeCompletion: true,
133 | },
134 | {
135 | label: 'networks:',
136 | insertText: 'networks:\n\t- ${1:networkName}$0',
137 | insertTextFormat: InsertTextFormat.Snippet,
138 | insertTextMode: InsertTextMode.adjustIndentation,
139 | isAdvancedComposeCompletion: true,
140 | },
141 | {
142 | label: 'ports:',
143 | insertText: 'ports:\n\t-$0',
144 | insertTextFormat: InsertTextFormat.Snippet,
145 | insertTextMode: InsertTextMode.adjustIndentation,
146 | isAdvancedComposeCompletion: false,
147 | },
148 | {
149 | label: 'profiles:',
150 | insertText: 'profiles:\n\t- ${1:profileName}$0',
151 | insertTextFormat: InsertTextFormat.Snippet,
152 | insertTextMode: InsertTextMode.adjustIndentation,
153 | isAdvancedComposeCompletion: true,
154 | },
155 | {
156 | label: 'volumes:',
157 | insertText: 'volumes:\n\t-$0',
158 | insertTextFormat: InsertTextFormat.Snippet,
159 | insertTextMode: InsertTextMode.adjustIndentation,
160 | isAdvancedComposeCompletion: false,
161 | },
162 | ]
163 | );
164 |
--------------------------------------------------------------------------------
/src/test/providers/DiagnosticProvider.test.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { expect } from 'chai';
7 | import { ClientCapabilities, Diagnostic, DocumentUri, InitializeParams, PublishDiagnosticsNotification, PublishDiagnosticsParams, Range } from 'vscode-languageserver-protocol';
8 | import { DefaultInitializeParams, TestConnection } from '../TestConnection';
9 |
10 | const DiagnosticClientCapabilities: ClientCapabilities = {
11 | textDocument: {
12 | publishDiagnostics: {}, // Just the object is checked to be truthy at this time, none of the subfeatures are needed
13 | moniker: {}, // For some reason this is a mandatory parameter
14 | },
15 | };
16 |
17 | interface ExpectedDiagnostic {
18 | range: Range;
19 | contentCanary: string;
20 | }
21 |
22 | const DiagnosticDelay = 10;
23 |
24 | describe('DiagnosticProvider', () => {
25 | let testConnection: TestConnection;
26 | let noDiagnosticsTestConnection: TestConnection;
27 | before('Prepare a language server for testing (with added diagnostic capability)', async () => {
28 | const initParams: InitializeParams = {
29 | ...DefaultInitializeParams,
30 | ...{ initializationOptions: { diagnosticDelay: DiagnosticDelay } },
31 | ...{ capabilities: DiagnosticClientCapabilities },
32 | };
33 |
34 | testConnection = new TestConnection(initParams);
35 |
36 | const noDiagnosticsInitParams: InitializeParams = {
37 | ...DefaultInitializeParams,
38 | ...{ initializationOptions: { diagnosticDelay: DiagnosticDelay } },
39 | };
40 |
41 | noDiagnosticsTestConnection = new TestConnection(noDiagnosticsInitParams);
42 | });
43 |
44 | describe('Common scenarios', () => {
45 | it('Should provide diagnostics for malformed yaml', async () => {
46 | const malformedTestObject = `version: '123'
47 |
48 | services:
49 | foo:
50 | build: .
51 | image: redis
52 |
53 | [bar : foo
54 | `;
55 |
56 | const expected: ExpectedDiagnostic[] = [
57 | {
58 | range: Range.create(5, 0, 5, 1), // The YAML library's ranges aren't super helpful, but the start position is at least accurate
59 | contentCanary: 'start at the same column',
60 | },
61 | {
62 | range: Range.create(7, 2, 7, 3),
63 | contentCanary: 'on a single line',
64 | },
65 | {
66 | range: Range.create(7, 2, 8, 0),
67 | contentCanary: 'followed by map values',
68 | },
69 | {
70 | range: Range.create(8, 0, 8, 0),
71 | contentCanary: 'and end with a ]',
72 | },
73 | ];
74 |
75 | await awaitDiagnosticsAndCompare(testConnection, malformedTestObject, expected);
76 | });
77 |
78 | it('Should provide nothing for valid compose documents', async () => {
79 | const validTestObject = {
80 | version: '123',
81 | services: {
82 | foo: {
83 | image: 'redis',
84 | build: '.',
85 | ports: ['1234', '5678:9012'],
86 | },
87 | bar: {
88 | image: 'alpine',
89 | volumes: ['foo:/bar:rw'],
90 | },
91 | },
92 | };
93 |
94 | const expected = undefined;
95 |
96 | await awaitDiagnosticsAndCompare(testConnection, validTestObject, expected);
97 | });
98 |
99 | xit('TODO: Should provide schema validation diagnostics for malformed compose documents');
100 | });
101 |
102 | describe('Error scenarios', () => {
103 | it('Should NOT send diagnostics if the client doesn\'t support it', () => {
104 | const malformedTestObject = `[bar : foo`;
105 |
106 | return awaitDiagnosticsAndCompare(noDiagnosticsTestConnection, malformedTestObject, []).should.eventually.be.rejectedWith('timed out');
107 | });
108 | });
109 |
110 | after('Cleanup', () => {
111 | testConnection.dispose();
112 | noDiagnosticsTestConnection.dispose();
113 | });
114 | });
115 |
116 | async function awaitDiagnosticsAndCompare(testConnection: TestConnection, testObject: string | unknown, expected: ExpectedDiagnostic[] | undefined): Promise {
117 | let timeout: NodeJS.Timeout | undefined = undefined;
118 |
119 | try {
120 | // Need to connect the listener *before* sending the document, to ensure no timing issues, i.e. with the response being sent before the listener is ready
121 | const listenerPromise = new Promise((resolve) => {
122 | testConnection.client.onNotification(PublishDiagnosticsNotification.type, (diagnosticParams) => {
123 | resolve(diagnosticParams);
124 | });
125 | });
126 |
127 | // Now that the listener is connected, send the document
128 | let uri: DocumentUri;
129 | if (typeof (testObject) === 'string') {
130 | uri = testConnection.sendTextAsYamlDocument(testObject);
131 | } else {
132 | uri = testConnection.sendObjectAsYamlDocument(testObject);
133 | }
134 |
135 | // A promise that will reject if it times out (if the diagnostics never get sent)
136 | const failurePromise = new Promise((resolve, reject) => {
137 | timeout = setTimeout(() => reject('timed out awaiting diagnostic response'), DiagnosticDelay * 10); // This carries some risk of test fragility but we have to draw a line somewhere (*sigh* halting problem)
138 | });
139 |
140 | // Now await the listener's completion promise to get the result
141 | const result = await Promise.race([listenerPromise, failurePromise]);
142 |
143 | expect(result).to.be.ok;
144 | result.uri.should.equal(uri);
145 |
146 | expect(result.diagnostics).to.be.ok;
147 | result.diagnostics.length.should.equal((expected ?? []).length);
148 |
149 | if (expected?.length) {
150 | // Each diagnostic should have a matching range and content canary in the results
151 | for (const expectedDiagnostic of expected) {
152 | result.diagnostics.some(actualDiagnostic => diagnosticsMatch(actualDiagnostic, expectedDiagnostic)).should.be.true;
153 | }
154 | }
155 | } finally {
156 | if (timeout) {
157 | clearTimeout(timeout);
158 | }
159 | }
160 | }
161 |
162 | function diagnosticsMatch(actual: Diagnostic, expected: ExpectedDiagnostic): boolean {
163 | return (
164 | actual.message.indexOf(expected.contentCanary) >= 0 &&
165 | actual.range.start.line === expected.range.start.line &&
166 | actual.range.start.character === expected.range.start.character &&
167 | actual.range.end.line === expected.range.end.line &&
168 | actual.range.end.character === expected.range.end.character
169 | );
170 | }
171 |
--------------------------------------------------------------------------------
/src/test/providers/completion/RootCompletionCollection.test.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { CompletionRequest, InsertTextFormat, Position, ResponseError } from 'vscode-languageserver';
7 | import { TestConnection } from '../../TestConnection';
8 | import { ExpectedCompletionItem, requestCompletionsAndCompare, UnexpectedCompletionItem } from './requestCompletionsAndCompare';
9 |
10 | // A subset of the completions that are provided by the RootCompletionCollection
11 | const defaultExpected: ExpectedCompletionItem[] = [
12 | {
13 | label: 'services:',
14 | insertTextCanary: 'services',
15 | insertTextFormat: InsertTextFormat.PlainText,
16 | },
17 | {
18 | label: 'volumes:',
19 | insertTextCanary: 'volumes',
20 | insertTextFormat: InsertTextFormat.PlainText,
21 | },
22 | {
23 | label: 'networks:',
24 | insertTextCanary: 'networks',
25 | insertTextFormat: InsertTextFormat.PlainText,
26 | },
27 | ];
28 |
29 | // Completions that are not allowed from RootCompletionCollection
30 | const defaultUnexpected: UnexpectedCompletionItem[] = [
31 | {
32 | insertTextCanary: 'build',
33 | },
34 | {
35 | insertTextCanary: 'containerPort',
36 | },
37 | {
38 | insertTextCanary: 'containerPath',
39 | },
40 | ];
41 |
42 | describe('RootCompletionCollection', () => {
43 | let testConnection: TestConnection;
44 | before('Prepare a language server for testing', async () => {
45 | testConnection = new TestConnection();
46 | });
47 |
48 | describe('Common scenarios', () => {
49 | it('Should provide completions at the root', async () => {
50 | const testObject = `version: '3.4'
51 | `;
52 |
53 | const uri = testConnection.sendTextAsYamlDocument(testObject);
54 |
55 | await requestCompletionsAndCompare(
56 | testConnection,
57 | uri,
58 | Position.create(1, 0), // The start of the line after `version`
59 | defaultExpected,
60 | defaultUnexpected
61 | );
62 | });
63 |
64 | it('Should provide completions in an empty document', async () => {
65 | const testObject = '';
66 |
67 | const uri = testConnection.sendTextAsYamlDocument(testObject);
68 |
69 | await requestCompletionsAndCompare(
70 | testConnection,
71 | uri,
72 | Position.create(0, 0), // The start of the first line
73 | defaultExpected,
74 | defaultUnexpected
75 | );
76 | });
77 |
78 | it('Should NOT provide completions if not at the root', async () => {
79 | const testObject = `services:
80 | foo:
81 | image: redis
82 | ports:
83 | - 1234
84 | `;
85 |
86 | const uri = testConnection.sendTextAsYamlDocument(testObject);
87 |
88 | await requestCompletionsAndCompare(
89 | testConnection,
90 | uri,
91 | Position.create(0, 3), // A few characters into `services`
92 | undefined,
93 | undefined
94 | );
95 |
96 | await requestCompletionsAndCompare(
97 | testConnection,
98 | uri,
99 | Position.create(0, 8), // Before the : in `services:`
100 | undefined,
101 | undefined
102 | );
103 |
104 | await requestCompletionsAndCompare(
105 | testConnection,
106 | uri,
107 | Position.create(0, 9), // After the : in `services:`
108 | undefined,
109 | undefined
110 | );
111 |
112 | await requestCompletionsAndCompare(
113 | testConnection,
114 | uri,
115 | Position.create(1, 2), // At the start of `foo:`
116 | undefined,
117 | undefined
118 | );
119 |
120 | await requestCompletionsAndCompare(
121 | testConnection,
122 | uri,
123 | Position.create(1, 3), // In the middle of `foo:`
124 | undefined,
125 | undefined
126 | );
127 |
128 | await requestCompletionsAndCompare(
129 | testConnection,
130 | uri,
131 | Position.create(1, 5), // Before the : in `foo:`
132 | undefined,
133 | undefined
134 | );
135 |
136 | await requestCompletionsAndCompare(
137 | testConnection,
138 | uri,
139 | Position.create(1, 6), // After the : in `foo:`
140 | undefined,
141 | undefined
142 | );
143 |
144 | await requestCompletionsAndCompare(
145 | testConnection,
146 | uri,
147 | Position.create(4, 6), // Before the - in the first port
148 | undefined,
149 | undefined
150 | );
151 |
152 | await requestCompletionsAndCompare(
153 | testConnection,
154 | uri,
155 | Position.create(4, 7), // After the - in the first port
156 | undefined,
157 | undefined
158 | );
159 |
160 | await requestCompletionsAndCompare(
161 | testConnection,
162 | uri,
163 | Position.create(4, 9), // In the first port
164 | undefined,
165 | undefined
166 | );
167 | });
168 |
169 | it('Should NOT provide completions if indented on an empty line', async () => {
170 | const testObject = `services:
171 | foo:
172 | `;
173 |
174 | const uri = testConnection.sendTextAsYamlDocument(testObject);
175 |
176 | await requestCompletionsAndCompare(
177 | testConnection,
178 | uri,
179 | Position.create(2, 2), // Indented on the empty line
180 | undefined,
181 | undefined,
182 | );
183 | });
184 |
185 | it('Should NOT provide completions in an empty document, if not at the root', async () => {
186 | const testObject = ` `;
187 |
188 | const uri = testConnection.sendTextAsYamlDocument(testObject);
189 |
190 | await requestCompletionsAndCompare(
191 | testConnection,
192 | uri,
193 | Position.create(0, 2), // Indented on the empty line
194 | undefined,
195 | undefined,
196 | );
197 | });
198 |
199 | it('Should NOT provide completions on an already-completed line', async () => {
200 | const testObject = `services:`;
201 |
202 | const uri = testConnection.sendTextAsYamlDocument(testObject);
203 |
204 | await requestCompletionsAndCompare(
205 | testConnection,
206 | uri,
207 | Position.create(0, 9), // After `services:`
208 | undefined,
209 | undefined,
210 | );
211 | });
212 | });
213 |
214 | describe('Error scenarios', () => {
215 | it('Should return an error for nonexistent files', () => {
216 | return testConnection
217 | .client.sendRequest(CompletionRequest.type, { textDocument: { uri: 'file:///bogus' }, position: Position.create(0, 0) })
218 | .should.eventually.be.rejectedWith(ResponseError);
219 | });
220 | });
221 |
222 | after('Cleanup', () => {
223 | testConnection.dispose();
224 | });
225 | });
226 |
--------------------------------------------------------------------------------
/src/service/providers/KeyHoverProvider.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { CancellationToken, Hover, HoverParams, MarkupKind, Position, Range } from 'vscode-languageserver';
7 | import { KeyValueRegex } from '../ComposeDocument';
8 | import { ExtendedParams } from '../ExtendedParams';
9 | import { getCurrentContext } from '../utils/ActionContext';
10 | import { ProviderBase } from './ProviderBase';
11 |
12 | export class KeyHoverProvider extends ProviderBase {
13 | public async on(params: HoverParams & ExtendedParams, token: CancellationToken): Promise {
14 | const ctx = getCurrentContext();
15 | ctx.telemetry.groupingStrategy = 'eventName'; // The below `hoverMatch` property that is attached will be lossy, but that's not serious; at global scales it will still be representative of usage
16 | const contentFormat = ctx.clientCapabilities.textDocument?.hover?.contentFormat;
17 | const preferMarkdown = contentFormat?.length ? contentFormat?.[0] === MarkupKind.Markdown : false;
18 |
19 | const positionInfo = await params.document.getPositionInfo(params);
20 |
21 | for (const keyInfo of ComposeKeyInfo) {
22 | const pathMatch = keyInfo.pathRegex.exec(positionInfo.path);
23 |
24 | if (!pathMatch) {
25 | continue;
26 | }
27 |
28 | const line = params.document.lineAt(params.position);
29 | const lineMatch = KeyValueRegex.exec(line);
30 |
31 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
32 | const pathKeyName = pathMatch.groups!['keyName']; // Can't be undefined if it matched
33 | const lineKeyName = lineMatch?.groups?.['keyName'];
34 |
35 | // Need to ensure the key on the line is the same as the key in the path
36 | // They can be different is because if you are in the whitespace before a key--the path will be in the parent key, but no hover should be provided here
37 | if (lineKeyName === pathKeyName) {
38 | const keyIndex = line.indexOf(lineKeyName);
39 |
40 | // Attach the key name to telemetry
41 | ctx.telemetry.properties.keyName = lineKeyName;
42 |
43 | return {
44 | contents: {
45 | kind: preferMarkdown ? MarkupKind.Markdown : MarkupKind.PlainText, // If Markdown is preferred, even plaintext will be treated as Markdown--it renders better, has line wrapping, etc.
46 | value: (preferMarkdown && keyInfo.markdownContents) || keyInfo.plaintextContents,
47 | },
48 | range: Range.create(Position.create(params.position.line, keyIndex), Position.create(params.position.line, keyIndex + lineKeyName.length)),
49 | };
50 | }
51 | }
52 |
53 | return undefined;
54 | }
55 | }
56 |
57 | interface ComposeKeyInformation {
58 | pathRegex: RegExp, // Must contain a group called `keyName` that matches the key
59 | plaintextContents: string,
60 | markdownContents?: string,
61 | }
62 |
63 | const ComposeKeyInfo: ComposeKeyInformation[] = [
64 | {
65 | pathRegex: /^\/(?configs)$/i, // `/configs`
66 | plaintextContents: 'Configurations for services in the project',
67 | },
68 | {
69 | pathRegex: /^\/(?networks)$/i, // `/networks`
70 | plaintextContents: 'Networks that are shared among multiple services',
71 | },
72 | {
73 | pathRegex: /^\/networks\/[.\w-]+\/(?driver)$/i, // `/networks/foo/driver`
74 | plaintextContents: 'The driver used for this network',
75 | },
76 | {
77 | pathRegex: /^\/(?secrets)$/i, // `/secrets`
78 | plaintextContents: 'Secrets that are shared among multiple services',
79 | },
80 | {
81 | pathRegex: /^\/(?services)$/i, // `/services`
82 | plaintextContents: 'The services in your project',
83 | },
84 | {
85 | pathRegex: /^\/services\/[.\w-]+\/(?build)$/i, // `/services/foo/build`
86 | plaintextContents: 'The context used for building the image',
87 | },
88 | {
89 | pathRegex: /^\/services\/[.\w-]+\/build\/(?args)$/i, // `/services/foo/build/args`
90 | plaintextContents: 'Arguments used during the image build process',
91 | },
92 | {
93 | pathRegex: /^\/services\/[.\w-]+\/build\/(?context)$/i, // `/services/foo/build/context`
94 | plaintextContents: 'The context used for building the image',
95 | },
96 | {
97 | pathRegex: /^\/services\/[.\w-]+\/build\/(?dockerfile)$/i, // `/services/foo/build/dockerfile`
98 | plaintextContents: 'The Dockerfile used for building the image',
99 | },
100 | {
101 | pathRegex: /^\/services\/[.\w-]+\/(?command)$/i, // `/services/foo/command`
102 | plaintextContents: 'The command that will be run in the container',
103 | },
104 | {
105 | pathRegex: /^\/services\/[.\w-]+\/(?container_name)$/i, // `/services/foo/container_name`
106 | plaintextContents: 'The name that will be given to the container',
107 | },
108 | {
109 | pathRegex: /^\/services\/[.\w-]+\/(?depends_on)$/i, // `/services/foo/depends_on`
110 | plaintextContents: 'Other services that this service depends on, which will be started before this one',
111 | },
112 | {
113 | pathRegex: /^\/services\/[.\w-]+\/(?entrypoint)$/i, // `/services/foo/entrypoint`
114 | plaintextContents: 'The entrypoint to the application in the container',
115 | },
116 | {
117 | pathRegex: /^\/services\/[.\w-]+\/(?env_file)$/i, // `/services/foo/env_file`
118 | plaintextContents: 'Files containing environment variables that will be included',
119 | },
120 | {
121 | pathRegex: /^\/services\/[.\w-]+\/(?environment)$/i, // `/services/foo/environment`
122 | plaintextContents: 'Environment variables that will be included',
123 | },
124 | {
125 | pathRegex: /^\/services\/[.\w-]+\/(?expose)$/i, // `/services/foo/expose`
126 | plaintextContents: 'Ports exposed to the other services but not to the host machine',
127 | },
128 | {
129 | pathRegex: /^\/services\/[.\w-]+\/(?healthcheck)$/i, // `/services/foo/healthcheck`
130 | plaintextContents: 'A command for checking if the container is healthy',
131 | },
132 | {
133 | pathRegex: /^\/services\/[.\w-]+\/(?image)$/i, // `/services/foo/image`
134 | plaintextContents: 'The image that will be pulled for the service. If `build` is specified, the built image will be given this tag.',
135 | },
136 | {
137 | pathRegex: /^\/services\/[.\w-]+\/(?labels)$/i, // `/services/foo/labels`
138 | plaintextContents: 'Labels that will be given to the container',
139 | },
140 | {
141 | pathRegex: /^\/services\/[.\w-]+\/(?logging)$/i, // `/services/foo/logging`
142 | plaintextContents: 'Settings for logging for this service',
143 | },
144 | {
145 | pathRegex: /^\/services\/[.\w-]+\/(?networks)$/i, // `/services/foo/networks`
146 | plaintextContents: 'The service will be included in these networks, allowing it to reach other containers on the same network',
147 | },
148 | {
149 | pathRegex: /^\/services\/[.\w-]+\/(?ports)$/i, // `/services/foo/ports`
150 | plaintextContents: 'Ports that will be exposed to the host',
151 | },
152 | {
153 | pathRegex: /^\/services\/[.\w-]+\/(?profiles)$/i, // `/services/foo/profiles`
154 | plaintextContents: 'Profiles that this service is a part of. When the profile is started, this service will be started.',
155 | },
156 | {
157 | pathRegex: /^\/services\/[.\w-]+\/(?secrets)$/i, // `/services/foo/secrets`
158 | plaintextContents: 'Secrets the service will have access to',
159 | },
160 | {
161 | pathRegex: /^\/services\/[.\w-]+\/(?user)$/i, // `/services/foo/user`
162 | plaintextContents: 'The username under which the app in the container will be started',
163 | },
164 | {
165 | pathRegex: /^\/services\/[.\w-]+\/(?volumes)$/i, // `/services/foo/volumes`
166 | plaintextContents: 'Named volumes and paths on the host mapped to paths in the container',
167 | },
168 | {
169 | pathRegex: /^\/services\/[.\w-]+\/(?working_dir)$/i, // `/services/foo/working_dir`
170 | plaintextContents: 'The working directory in which the entrypoint or command will be run',
171 | },
172 | {
173 | pathRegex: /^\/(?version)$/i, // `/version`
174 | plaintextContents: 'The version of the Docker Compose document',
175 | },
176 | {
177 | pathRegex: /^\/(?volumes)$/i, // `/volumes`
178 | plaintextContents: 'Named volumes that are shared among multiple services',
179 | },
180 | {
181 | pathRegex: /^\/volumes\/[.\w-]+\/(?driver)$/i, // `/volumes/foo/driver`
182 | plaintextContents: 'The driver used for this volume',
183 | },
184 | ];
185 |
--------------------------------------------------------------------------------
/src/test/clientExtension/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "compose-languageserver-testclient",
3 | "version": "0.0.1",
4 | "lockfileVersion": 2,
5 | "requires": true,
6 | "packages": {
7 | "": {
8 | "name": "compose-languageserver-testclient",
9 | "version": "0.0.1",
10 | "dependencies": {
11 | "vscode-languageclient": "^8.0.0"
12 | },
13 | "devDependencies": {
14 | "@types/vscode": "1.69.0"
15 | },
16 | "engines": {
17 | "vscode": "^1.69.0"
18 | }
19 | },
20 | "node_modules/@types/vscode": {
21 | "version": "1.69.0",
22 | "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.69.0.tgz",
23 | "integrity": "sha512-RlzDAnGqUoo9wS6d4tthNyAdZLxOIddLiX3djMoWk29jFfSA1yJbIwr0epBYqqYarWB6s2Z+4VaZCQ80Jaa3kA==",
24 | "dev": true
25 | },
26 | "node_modules/balanced-match": {
27 | "version": "1.0.2",
28 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
29 | "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
30 | },
31 | "node_modules/brace-expansion": {
32 | "version": "1.1.11",
33 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
34 | "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
35 | "dependencies": {
36 | "balanced-match": "^1.0.0",
37 | "concat-map": "0.0.1"
38 | }
39 | },
40 | "node_modules/concat-map": {
41 | "version": "0.0.1",
42 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
43 | "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
44 | },
45 | "node_modules/lru-cache": {
46 | "version": "6.0.0",
47 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
48 | "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
49 | "dependencies": {
50 | "yallist": "^4.0.0"
51 | },
52 | "engines": {
53 | "node": ">=10"
54 | }
55 | },
56 | "node_modules/minimatch": {
57 | "version": "3.1.2",
58 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
59 | "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
60 | "dependencies": {
61 | "brace-expansion": "^1.1.7"
62 | },
63 | "engines": {
64 | "node": "*"
65 | }
66 | },
67 | "node_modules/semver": {
68 | "version": "7.5.3",
69 | "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz",
70 | "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==",
71 | "dependencies": {
72 | "lru-cache": "^6.0.0"
73 | },
74 | "bin": {
75 | "semver": "bin/semver.js"
76 | },
77 | "engines": {
78 | "node": ">=10"
79 | }
80 | },
81 | "node_modules/vscode-jsonrpc": {
82 | "version": "8.0.2",
83 | "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.0.2.tgz",
84 | "integrity": "sha512-RY7HwI/ydoC1Wwg4gJ3y6LpU9FJRZAUnTYMXthqhFXXu77ErDd/xkREpGuk4MyYkk4a+XDWAMqe0S3KkelYQEQ==",
85 | "engines": {
86 | "node": ">=14.0.0"
87 | }
88 | },
89 | "node_modules/vscode-languageclient": {
90 | "version": "8.0.2",
91 | "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-8.0.2.tgz",
92 | "integrity": "sha512-lHlthJtphG9gibGb/y72CKqQUxwPsMXijJVpHEC2bvbFqxmkj9LwQ3aGU9dwjBLqsX1S4KjShYppLvg1UJDF/Q==",
93 | "dependencies": {
94 | "minimatch": "^3.0.4",
95 | "semver": "^7.3.5",
96 | "vscode-languageserver-protocol": "3.17.2"
97 | },
98 | "engines": {
99 | "vscode": "^1.67.0"
100 | }
101 | },
102 | "node_modules/vscode-languageserver-protocol": {
103 | "version": "3.17.2",
104 | "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.2.tgz",
105 | "integrity": "sha512-8kYisQ3z/SQ2kyjlNeQxbkkTNmVFoQCqkmGrzLH6A9ecPlgTbp3wDTnUNqaUxYr4vlAcloxx8zwy7G5WdguYNg==",
106 | "dependencies": {
107 | "vscode-jsonrpc": "8.0.2",
108 | "vscode-languageserver-types": "3.17.2"
109 | }
110 | },
111 | "node_modules/vscode-languageserver-types": {
112 | "version": "3.17.2",
113 | "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.2.tgz",
114 | "integrity": "sha512-zHhCWatviizPIq9B7Vh9uvrH6x3sK8itC84HkamnBWoDFJtzBf7SWlpLCZUit72b3os45h6RWQNC9xHRDF8dRA=="
115 | },
116 | "node_modules/yallist": {
117 | "version": "4.0.0",
118 | "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
119 | "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
120 | }
121 | },
122 | "dependencies": {
123 | "@types/vscode": {
124 | "version": "1.69.0",
125 | "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.69.0.tgz",
126 | "integrity": "sha512-RlzDAnGqUoo9wS6d4tthNyAdZLxOIddLiX3djMoWk29jFfSA1yJbIwr0epBYqqYarWB6s2Z+4VaZCQ80Jaa3kA==",
127 | "dev": true
128 | },
129 | "balanced-match": {
130 | "version": "1.0.2",
131 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
132 | "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
133 | },
134 | "brace-expansion": {
135 | "version": "1.1.11",
136 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
137 | "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
138 | "requires": {
139 | "balanced-match": "^1.0.0",
140 | "concat-map": "0.0.1"
141 | }
142 | },
143 | "concat-map": {
144 | "version": "0.0.1",
145 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
146 | "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
147 | },
148 | "lru-cache": {
149 | "version": "6.0.0",
150 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
151 | "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
152 | "requires": {
153 | "yallist": "^4.0.0"
154 | }
155 | },
156 | "minimatch": {
157 | "version": "3.1.2",
158 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
159 | "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
160 | "requires": {
161 | "brace-expansion": "^1.1.7"
162 | }
163 | },
164 | "semver": {
165 | "version": "7.5.3",
166 | "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz",
167 | "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==",
168 | "requires": {
169 | "lru-cache": "^6.0.0"
170 | }
171 | },
172 | "vscode-jsonrpc": {
173 | "version": "8.0.2",
174 | "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.0.2.tgz",
175 | "integrity": "sha512-RY7HwI/ydoC1Wwg4gJ3y6LpU9FJRZAUnTYMXthqhFXXu77ErDd/xkREpGuk4MyYkk4a+XDWAMqe0S3KkelYQEQ=="
176 | },
177 | "vscode-languageclient": {
178 | "version": "8.0.2",
179 | "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-8.0.2.tgz",
180 | "integrity": "sha512-lHlthJtphG9gibGb/y72CKqQUxwPsMXijJVpHEC2bvbFqxmkj9LwQ3aGU9dwjBLqsX1S4KjShYppLvg1UJDF/Q==",
181 | "requires": {
182 | "minimatch": "^3.0.4",
183 | "semver": "^7.3.5",
184 | "vscode-languageserver-protocol": "3.17.2"
185 | }
186 | },
187 | "vscode-languageserver-protocol": {
188 | "version": "3.17.2",
189 | "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.2.tgz",
190 | "integrity": "sha512-8kYisQ3z/SQ2kyjlNeQxbkkTNmVFoQCqkmGrzLH6A9ecPlgTbp3wDTnUNqaUxYr4vlAcloxx8zwy7G5WdguYNg==",
191 | "requires": {
192 | "vscode-jsonrpc": "8.0.2",
193 | "vscode-languageserver-types": "3.17.2"
194 | }
195 | },
196 | "vscode-languageserver-types": {
197 | "version": "3.17.2",
198 | "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.2.tgz",
199 | "integrity": "sha512-zHhCWatviizPIq9B7Vh9uvrH6x3sK8itC84HkamnBWoDFJtzBf7SWlpLCZUit72b3os45h6RWQNC9xHRDF8dRA=="
200 | },
201 | "yallist": {
202 | "version": "4.0.0",
203 | "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
204 | "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
205 | }
206 | }
207 | }
208 |
--------------------------------------------------------------------------------
/NOTICE.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | NOTICES AND INFORMATION
5 |
12 |
13 |
14 |
NOTICES AND INFORMATION
15 | Do Not Translate or Localize
16 |
17 | This software incorporates material from third parties.
18 | Microsoft makes certain open source code available at https://3rdpartysource.microsoft.com,
19 | or you may send a check or money order for US $5.00, including the product name,
20 | the open source component name, platform, and version number, to:
21 |
22 |
23 | Source Code Compliance Team
24 | Microsoft Corporation
25 | One Microsoft Way
26 | Redmond, WA 98052
27 | USA
28 |
29 |
30 | Notwithstanding any other terms, you may reverse engineer this software to the extent
31 | required to debug changes to any libraries licensed under the GNU Lesser General Public License.
32 |
33 |
34 | -
35 |
36 |
37 | yaml 2.2.2 - ISC
38 |
39 | https://eemeli.org/yaml/
40 | - Copyright (c) Microsoft Corporation
41 | - Copyright Eemeli Aro <eemeli@gmail.com>
42 |
43 | Copyright Eemeli Aro <eemeli@gmail.com>
44 |
45 | Permission to use, copy, modify, and/or distribute this software for any purpose
46 | with or without fee is hereby granted, provided that the above copyright notice
47 | and this permission notice appear in all copies.
48 |
49 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
50 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
51 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
52 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
53 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
54 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
55 | THIS SOFTWARE.
56 |
57 |
58 |
59 |
60 | -
61 |
62 |
63 | vscode-jsonrpc 8.1.0 - MIT
64 |
65 | https://github.com/Microsoft/vscode-languageserver-node#readme
66 | - Copyright (c) Microsoft Corporation
67 |
68 | Copyright (c) Microsoft Corporation
69 |
70 | All rights reserved.
71 |
72 | MIT License
73 |
74 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
75 |
76 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
77 |
78 | THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
79 |
80 |
81 |
82 |
83 | -
84 |
85 |
86 | vscode-languageserver 8.1.0 - MIT
87 |
88 | https://github.com/Microsoft/vscode-languageserver-node#readme
89 | - Copyright (c) TypeFox and others
90 | - Copyright (c) Microsoft Corporation
91 |
92 | Copyright (c) Microsoft Corporation
93 |
94 | All rights reserved.
95 |
96 | MIT License
97 |
98 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
99 |
100 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
101 |
102 | THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
103 |
104 |
105 |
106 |
107 | -
108 |
109 |
110 | vscode-languageserver-protocol 3.17.3 - MIT
111 |
112 | https://github.com/Microsoft/vscode-languageserver-node#readme
113 | - Copyright (c) Microsoft Corporation
114 | - Copyright (c) TypeFox, Microsoft and others
115 |
116 | Copyright (c) Microsoft Corporation
117 |
118 | All rights reserved.
119 |
120 | MIT License
121 |
122 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
123 |
124 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
125 |
126 | THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
127 |
128 |
129 |
130 |
131 | -
132 |
133 |
134 | vscode-languageserver-textdocument 1.0.8 - MIT
135 |
136 | https://github.com/Microsoft/vscode-languageserver-node#readme
137 | - Copyright (c) Microsoft Corporation
138 |
139 | Copyright (c) Microsoft Corporation
140 |
141 | All rights reserved.
142 |
143 | MIT License
144 |
145 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
146 |
147 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
148 |
149 | THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
150 |
151 |
152 |
153 |
154 | -
155 |
156 |
157 | vscode-languageserver-types 3.17.3 - MIT
158 |
159 | https://github.com/Microsoft/vscode-languageserver-node#readme
160 | - Copyright (c) Microsoft Corporation
161 |
162 | Copyright (c) Microsoft Corporation
163 |
164 | All rights reserved.
165 |
166 | MIT License
167 |
168 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
169 |
170 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
171 |
172 | THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
173 |
174 |
175 |
176 |
177 |
178 |
179 |
--------------------------------------------------------------------------------
/src/test/providers/completion/VolumesCompletionCollection.test.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { CompletionRequest, InsertTextFormat, Position, ResponseError } from 'vscode-languageserver';
7 | import { TestConnection } from '../../TestConnection';
8 | import { ExpectedCompletionItem, requestCompletionsAndCompare, UnexpectedCompletionItem } from './requestCompletionsAndCompare';
9 |
10 | // Completions that are not allowed from VolumesCompletionCollection
11 | const defaultUnexpected: UnexpectedCompletionItem[] = [
12 | {
13 | insertTextCanary: 'services',
14 | },
15 | {
16 | insertTextCanary: 'build',
17 | },
18 | {
19 | insertTextCanary: 'containerPort',
20 | },
21 | ];
22 |
23 | describe('VolumesCompletionCollection', () => {
24 | let testConnection: TestConnection;
25 | before('Prepare a language server for testing', async () => {
26 | testConnection = new TestConnection();
27 | });
28 |
29 | describe('Common scenarios', () => {
30 | it('Should provide completions when in a volume mapping', async () => {
31 | const testObject = `services:
32 | foo:
33 | volumes:
34 | - `;
35 |
36 | const uri = testConnection.sendTextAsYamlDocument(testObject);
37 |
38 | const expected: ExpectedCompletionItem[] = [
39 | {
40 | label: 'hostPath:containerPath:mode',
41 | insertTextCanary: 'hostPath',
42 | insertTextFormat: InsertTextFormat.Snippet,
43 | },
44 | {
45 | label: 'volumeName:containerPath:mode',
46 | insertTextCanary: 'volumeName',
47 | insertTextFormat: InsertTextFormat.Snippet,
48 | },
49 | ];
50 |
51 | const unexpected: UnexpectedCompletionItem[] = [
52 | {
53 | insertTextCanary: '${1:containerPath}',
54 | },
55 | ];
56 |
57 | await requestCompletionsAndCompare(
58 | testConnection,
59 | uri,
60 | Position.create(3, 7), // Immediately after the dash under `volumes:`
61 | expected,
62 | [...defaultUnexpected, ...unexpected]
63 | );
64 |
65 | await requestCompletionsAndCompare(
66 | testConnection,
67 | uri,
68 | Position.create(3, 8), // One space after the dash under `volumes:`
69 | expected,
70 | [...defaultUnexpected, ...unexpected]
71 | );
72 | });
73 |
74 | it('Should provide completions when after the host path / volume name part of a volume mapping', async () => {
75 | const testObject = `services:
76 | foo:
77 | volumes:
78 | - volumeName:
79 | - /host/path:`;
80 |
81 | const uri = testConnection.sendTextAsYamlDocument(testObject);
82 |
83 | const expected: ExpectedCompletionItem[] = [
84 | {
85 | label: ':containerPath:mode',
86 | insertTextCanary: 'containerPath',
87 | insertTextFormat: InsertTextFormat.Snippet,
88 | },
89 | ];
90 |
91 | const unexpected: UnexpectedCompletionItem[] = [
92 | {
93 | insertTextCanary: '${2:containerPath}',
94 | },
95 | ];
96 |
97 | await requestCompletionsAndCompare(
98 | testConnection,
99 | uri,
100 | Position.create(3, 19), // Immediately after `volumeName:`
101 | expected,
102 | [...defaultUnexpected, ...unexpected]
103 | );
104 |
105 | await requestCompletionsAndCompare(
106 | testConnection,
107 | uri,
108 | Position.create(4, 19), // Immediately after `/host/path:`
109 | expected,
110 | [...defaultUnexpected, ...unexpected]
111 | );
112 | });
113 |
114 | it('Should provide completions when after the host path / volume name and container path parts of a volume mapping', async () => {
115 | const testObject = `services:
116 | foo:
117 | volumes:
118 | - volumeName:/container/path:
119 | - /host/path:/container/path:`;
120 |
121 | const uri = testConnection.sendTextAsYamlDocument(testObject);
122 |
123 | const expected: ExpectedCompletionItem[] = [
124 | {
125 | label: ':ro',
126 | insertTextCanary: 'ro',
127 | insertTextFormat: InsertTextFormat.PlainText,
128 | },
129 | {
130 | label: ':rw',
131 | insertTextCanary: 'rw',
132 | insertTextFormat: InsertTextFormat.PlainText,
133 | },
134 | ];
135 |
136 | const unexpected: UnexpectedCompletionItem[] = [
137 | {
138 | insertTextCanary: '${1:containerPath}',
139 | },
140 | {
141 | insertTextCanary: '${2:containerPath}',
142 | },
143 | ];
144 |
145 | await requestCompletionsAndCompare(
146 | testConnection,
147 | uri,
148 | Position.create(3, 35), // Immediately after `volumeName:/container/path:`
149 | expected,
150 | [...defaultUnexpected, ...unexpected]
151 | );
152 |
153 | await requestCompletionsAndCompare(
154 | testConnection,
155 | uri,
156 | Position.create(4, 35), // Immediately after `/host/path:/container/path:`
157 | expected,
158 | [...defaultUnexpected, ...unexpected]
159 | );
160 | });
161 |
162 | it('Should NOT provide completions at the root', async () => {
163 | const testObject = `version: '3.4'
164 | `;
165 |
166 | const uri = testConnection.sendTextAsYamlDocument(testObject);
167 |
168 | const unexpected: UnexpectedCompletionItem[] = [
169 | {
170 | insertTextCanary: 'volumeName',
171 | },
172 | {
173 | insertTextCanary: 'hostPath',
174 | },
175 | {
176 | insertTextCanary: 'containerPath',
177 | },
178 | {
179 | insertTextCanary: 'ro',
180 | },
181 | {
182 | insertTextCanary: 'rw',
183 | },
184 | ];
185 |
186 | await requestCompletionsAndCompare(
187 | testConnection,
188 | uri,
189 | Position.create(1, 0), // The start of the line after `version`
190 | [],
191 | unexpected
192 | );
193 | });
194 |
195 | it('Should NOT provide completions in an empty document', async () => {
196 | const testObject = ``;
197 |
198 | const uri = testConnection.sendTextAsYamlDocument(testObject);
199 |
200 | const unexpected: UnexpectedCompletionItem[] = [
201 | {
202 | insertTextCanary: 'volumeName',
203 | },
204 | {
205 | insertTextCanary: 'hostPath',
206 | },
207 | {
208 | insertTextCanary: 'containerPath',
209 | },
210 | {
211 | insertTextCanary: 'ro',
212 | },
213 | {
214 | insertTextCanary: 'rw',
215 | },
216 | ];
217 |
218 | await requestCompletionsAndCompare(
219 | testConnection,
220 | uri,
221 | Position.create(0, 0), // The start of the first line
222 | [],
223 | unexpected
224 | );
225 | });
226 |
227 | it('Should NOT provide completions under ports', async () => {
228 | const testObject = `services:
229 | foo:
230 | ports:
231 | - `;
232 |
233 | const uri = testConnection.sendTextAsYamlDocument(testObject);
234 |
235 | const unexpected: UnexpectedCompletionItem[] = [
236 | {
237 | insertTextCanary: 'volumeName',
238 | },
239 | {
240 | insertTextCanary: 'hostPath',
241 | },
242 | {
243 | insertTextCanary: 'containerPath',
244 | },
245 | // {
246 | // insertTextCanary: 'ro', // 'ro' is in 'hostPort:containerPort/protocol' so we can't use it as a canary
247 | // },
248 | {
249 | insertTextCanary: 'rw',
250 | },
251 | ];
252 |
253 | await requestCompletionsAndCompare(
254 | testConnection,
255 | uri,
256 | Position.create(3, 7), // Immediately after the dash under `ports:`
257 | [],
258 | unexpected
259 | );
260 |
261 | await requestCompletionsAndCompare(
262 | testConnection,
263 | uri,
264 | Position.create(3, 8), // One space after the dash under `ports:`
265 | [],
266 | unexpected
267 | );
268 | });
269 | });
270 |
271 | describe('Error scenarios', () => {
272 | it('Should return an error for nonexistent files', () => {
273 | return testConnection
274 | .client.sendRequest(CompletionRequest.type, { textDocument: { uri: 'file:///bogus' }, position: Position.create(0, 0) })
275 | .should.eventually.be.rejectedWith(ResponseError);
276 | });
277 | });
278 |
279 | after('Cleanup', () => {
280 | testConnection.dispose();
281 | });
282 | });
283 |
--------------------------------------------------------------------------------
/src/test/utils/telemetry/TelemetryAggregator.test.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import { expect } from 'chai';
7 | import { TelemetryEventNotification } from 'vscode-languageserver-protocol';
8 | import { initEvent, TelemetryEvent } from '../../../client/TelemetryEvent';
9 | import { TelemetryAggregator } from '../../../service/utils/telemetry/TelemetryAggregator';
10 | import { TestConnection } from '../../TestConnection';
11 |
12 | const TelemetryAggregationInterval = 10;
13 |
14 | describe('(Unit) TelemetryAggregator', () => {
15 | let testConnection: TestConnection;
16 | let telemetryAggregator: TelemetryAggregator;
17 | beforeEach('Prepare a language server for testing', async () => {
18 | testConnection = new TestConnection();
19 | telemetryAggregator = new TelemetryAggregator(testConnection.server, TelemetryAggregationInterval);
20 | });
21 |
22 | describe('Common scenarios', () => {
23 | it('Should return aggregated events', async () => {
24 | const inputEvent1: TelemetryEvent = initEvent('aggregated');
25 | inputEvent1.properties.canary = 'canary';
26 | inputEvent1.measurements.duration = 5;
27 |
28 | const inputEvent2: TelemetryEvent = initEvent('aggregated');
29 | inputEvent2.properties.canary = 'canary';
30 | inputEvent2.measurements.duration = 10;
31 |
32 | const expected: TelemetryEvent = initEvent('aggregated');
33 | expected.properties.canary = 'canary';
34 | expected.measurements.count = 2;
35 | expected.measurements.durationMedian = 7;
36 | expected.measurements.durationMu = 1.956;
37 | expected.measurements.durationSigma = 0.49;
38 |
39 | await awaitTelemetryAndCompare(testConnection, telemetryAggregator, [inputEvent1, inputEvent2], expected);
40 | });
41 |
42 | it('Should immediately return error events without aggregation', async () => {
43 | const inputEvent1: TelemetryEvent = initEvent('errorNoAggregation');
44 | inputEvent1.properties.result = 'Failed';
45 | inputEvent1.properties.canary = 'canary';
46 | inputEvent1.measurements.duration = 5;
47 |
48 | const inputEvent2: TelemetryEvent = initEvent('errorNoAggregation');
49 | inputEvent2.properties.result = 'Failed';
50 | inputEvent2.properties.canary = 'canary';
51 | inputEvent2.measurements.duration = 10;
52 |
53 | // This takes advantage of the fact that the listenerPromise below in `awaitTelemetryAndCompare` will immediately resolve with the first event
54 | const expected: TelemetryEvent = initEvent('errorNoAggregation');
55 | expected.properties.result = 'Failed';
56 | expected.properties.canary = 'canary';
57 | expected.measurements.duration = 5;
58 |
59 | await awaitTelemetryAndCompare(testConnection, telemetryAggregator, [inputEvent1, inputEvent2], expected);
60 | });
61 |
62 | it('Should NOT return successful events if suppressIfSuccessful is true', () => {
63 | const inputEvent: TelemetryEvent = initEvent('suppressIfSuccessful-Succeeded');
64 | inputEvent.suppressIfSuccessful = true;
65 |
66 | return awaitTelemetryAndCompare(testConnection, telemetryAggregator, [inputEvent], undefined).should.eventually.be.rejectedWith('timed out');
67 | });
68 |
69 | it('Should return error events if suppressIfSuccessful is true', async () => {
70 | const inputEvent: TelemetryEvent = initEvent('suppressIfSuccessful-Failed');
71 | inputEvent.properties.result = 'Failed';
72 | inputEvent.properties.canary = 'canary';
73 | inputEvent.suppressIfSuccessful = true;
74 | inputEvent.measurements.duration = 5;
75 |
76 | const expected: TelemetryEvent = initEvent('suppressIfSuccessful-Failed');
77 | expected.properties.result = 'Failed';
78 | expected.properties.canary = 'canary';
79 | expected.suppressIfSuccessful = true;
80 | expected.measurements.duration = 5;
81 |
82 | await awaitTelemetryAndCompare(testConnection, telemetryAggregator, [inputEvent], expected);
83 | });
84 |
85 | it('Should NOT return successful events if suppressAll is true', () => {
86 | const inputEvent: TelemetryEvent = initEvent('suppressAll-Succeeded');
87 | inputEvent.suppressAll = true;
88 |
89 | return awaitTelemetryAndCompare(testConnection, telemetryAggregator, [inputEvent], undefined).should.eventually.be.rejectedWith('timed out');
90 | });
91 |
92 | it('Should NOT return error events if suppressAll is true', () => {
93 | const inputEvent: TelemetryEvent = initEvent('suppressAll-Failed');
94 | inputEvent.properties.result = 'Failed';
95 | inputEvent.suppressAll = true;
96 |
97 | return awaitTelemetryAndCompare(testConnection, telemetryAggregator, [inputEvent], undefined).should.eventually.be.rejectedWith('timed out');
98 | });
99 |
100 | it('Should respect eventName grouping strategy', async () => {
101 | const inputEvent1 = initEvent('eventName');
102 | inputEvent1.groupingStrategy = 'eventName';
103 | inputEvent1.properties.canary = 'canary1';
104 | inputEvent1.measurements.duration = 5;
105 |
106 | const inputEvent2: TelemetryEvent = initEvent('eventName');
107 | inputEvent2.groupingStrategy = 'eventName';
108 | inputEvent2.properties.canary = 'canary2';
109 | inputEvent2.measurements.duration = 10;
110 |
111 | const expected: TelemetryEvent = initEvent('eventName');
112 | expected.properties.canary = 'canary2'; // Should be 'canary2' since the later events override the earlier events
113 | expected.measurements.count = 2;
114 | expected.measurements.durationMedian = 7;
115 | expected.measurements.durationMu = 1.956;
116 | expected.measurements.durationSigma = 0.49;
117 |
118 | await awaitTelemetryAndCompare(testConnection, telemetryAggregator, [inputEvent1, inputEvent2], expected);
119 | });
120 |
121 | it('Should respect eventNameAndProperties grouping strategy', async () => {
122 | const inputEvent1 = initEvent('eventNameAndProperties');
123 | inputEvent1.groupingStrategy = 'eventNameAndProperties';
124 | inputEvent1.properties.canary = 'canary1';
125 | inputEvent1.measurements.duration = 5;
126 |
127 | const inputEvent2: TelemetryEvent = initEvent('eventNameAndProperties');
128 | inputEvent2.groupingStrategy = 'eventNameAndProperties';
129 | inputEvent2.properties.canary = 'canary2';
130 | inputEvent2.measurements.duration = 10;
131 |
132 | // This takes advantage of the fact that the listenerPromise below in `awaitTelemetryAndCompare` will resolve with the first event
133 | const expected: TelemetryEvent = initEvent('eventNameAndProperties');
134 | expected.properties.canary = 'canary1'; // Should be 'canary1' since the second event won't be aggregated with this one
135 | expected.measurements.count = 1;
136 | expected.measurements.durationMedian = 5;
137 | expected.measurements.durationMu = 1.609;
138 | expected.measurements.durationSigma = 0;
139 |
140 | await awaitTelemetryAndCompare(testConnection, telemetryAggregator, [inputEvent1, inputEvent2], expected);
141 | });
142 | });
143 |
144 | describe('Error scenarios', () => {
145 | it('Should return a telemetry aggregation error if aggregation fails', async () => {
146 | const inputEvent: TelemetryEvent = initEvent('canary');
147 |
148 | // Deleting the measurements property will cause the aggregation procedure to throw
149 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
150 | delete (inputEvent as any).measurements;
151 |
152 | const expected: TelemetryEvent = initEvent('telemetryaggregatorfailure');
153 | expected.properties.result = 'Failed';
154 | expected.properties.error = 'TypeError';
155 | expected.properties.errorMessage = 'Cannot read properties of undefined (reading \'duration\')';
156 |
157 | await awaitTelemetryAndCompare(testConnection, telemetryAggregator, [inputEvent], expected);
158 | });
159 | });
160 |
161 | afterEach('Cleanup', () => {
162 | testConnection.dispose();
163 | telemetryAggregator.dispose();
164 | });
165 | });
166 |
167 | async function awaitTelemetryAndCompare(testConnection: TestConnection, telemetryAggregator: TelemetryAggregator, input: TelemetryEvent[], expected: TelemetryEvent | undefined): Promise {
168 | let timeout: NodeJS.Timeout | undefined = undefined;
169 |
170 | try {
171 | // Need to connect the listener *before* sending the events, to ensure no timing issues, i.e. with the response being sent before the listener is ready
172 | const listenerPromise = new Promise((resolve) => {
173 | testConnection.client.onNotification(TelemetryEventNotification.type, (telemetry) => {
174 | resolve(telemetry);
175 | });
176 | });
177 |
178 | // A promise that will reject if it times out (if the diagnostics never get sent)
179 | const failurePromise = new Promise((resolve, reject) => {
180 | timeout = setTimeout(() => reject('timed out awaiting aggregated telemetry response'), TelemetryAggregationInterval * 10); // This carries some risk of test fragility but we have to draw a line somewhere (*sigh* halting problem)
181 | });
182 |
183 | for (const inputEvent of input) {
184 | telemetryAggregator.logEvent(inputEvent);
185 | }
186 |
187 | // Now await the listener's completion promise to get the result
188 | const result = await Promise.race([listenerPromise, failurePromise]);
189 |
190 | expect(result).to.be.ok;
191 |
192 | // Since it'd be impossible to check we'll eliminate the stack from the output event
193 | if (result.properties.stack) {
194 | delete result.properties.stack;
195 | }
196 |
197 | result.should.deep.equal(expected);
198 | } finally {
199 | if (timeout) {
200 | clearTimeout(timeout);
201 | }
202 | }
203 | }
204 |
--------------------------------------------------------------------------------
/src/service/ComposeLanguageService.ts:
--------------------------------------------------------------------------------
1 | /*!--------------------------------------------------------------------------------------------
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License. See LICENSE in the project root for license information.
4 | *--------------------------------------------------------------------------------------------*/
5 |
6 | import {
7 | CancellationToken,
8 | Connection,
9 | Disposable,
10 | ErrorCodes,
11 | Event,
12 | InitializeParams,
13 | ResponseError,
14 | ServerCapabilities,
15 | ServerRequestHandler,
16 | TextDocumentChangeEvent,
17 | TextDocuments,
18 | TextDocumentSyncKind,
19 | }
20 | from 'vscode-languageserver';
21 | import { AlternateYamlLanguageServiceClientCapabilities } from '../client/AlternateYamlLanguageServiceClientCapabilities';
22 | import { ComposeLanguageClientCapabilities } from '../client/ClientCapabilities';
23 | import { DocumentSettingsNotificationParams, DocumentSettingsNotification } from '../client/DocumentSettings';
24 | import { initEvent } from '../client/TelemetryEvent';
25 | import { ComposeDocument } from './ComposeDocument';
26 | import { ExtendedParams, TextDocumentParams } from './ExtendedParams';
27 | import { MultiCompletionProvider } from './providers/completion/MultiCompletionProvider';
28 | import { DiagnosticProvider } from './providers/DiagnosticProvider';
29 | import { DocumentFormattingProvider } from './providers/DocumentFormattingProvider';
30 | import { ImageLinkProvider } from './providers/ImageLinkProvider';
31 | import { KeyHoverProvider } from './providers/KeyHoverProvider';
32 | import { ProviderBase } from './providers/ProviderBase';
33 | import { ActionContext, runWithContext } from './utils/ActionContext';
34 | import { TelemetryAggregator } from './utils/telemetry/TelemetryAggregator';
35 |
36 | const DefaultCapabilities: ServerCapabilities = {
37 | // Text document synchronization
38 | textDocumentSync: {
39 | openClose: true,
40 | change: TextDocumentSyncKind.Incremental,
41 | willSave: false,
42 | willSaveWaitUntil: false,
43 | save: false,
44 | },
45 |
46 | // Both basic and advanced completions
47 | completionProvider: {
48 | triggerCharacters: ['-', ':', ' ', '"'],
49 | resolveProvider: false,
50 | },
51 |
52 | // Hover over YAML keys
53 | hoverProvider: true,
54 |
55 | // Links to Docker Hub on image names
56 | documentLinkProvider: {
57 | resolveProvider: false,
58 | },
59 |
60 | // YAML formatting
61 | documentFormattingProvider: true,
62 |
63 | // Workspace features
64 | workspace: {
65 | workspaceFolders: {
66 | supported: true,
67 | },
68 | },
69 | };
70 |
71 | // Default settings for a client with no alternate YAML language service
72 | const DefaultAlternateYamlLanguageServiceClientCapabilities: AlternateYamlLanguageServiceClientCapabilities = {
73 | syntaxValidation: false,
74 | schemaValidation: false,
75 |
76 | basicCompletions: false,
77 | advancedCompletions: false,
78 | hover: false,
79 | imageLinks: false,
80 | formatting: false,
81 | };
82 |
83 | export class ComposeLanguageService implements Disposable {
84 | private readonly documentManager: TextDocuments = new TextDocuments(ComposeDocument.DocumentManagerConfig);
85 | private readonly subscriptions: Disposable[] = [];
86 | private readonly telemetryAggregator: TelemetryAggregator;
87 | private readonly _capabilities: ServerCapabilities = DefaultCapabilities;
88 |
89 | public constructor(public readonly connection: Connection, private readonly clientParams: InitializeParams) {
90 | let altYamlCapabilities = (clientParams.capabilities as ComposeLanguageClientCapabilities).experimental?.alternateYamlLanguageService;
91 |
92 | if (altYamlCapabilities) {
93 | connection.console.info('An alternate YAML language service is present. The Compose language service will not enable features already provided by the alternate.');
94 | } else {
95 | altYamlCapabilities = DefaultAlternateYamlLanguageServiceClientCapabilities;
96 | }
97 |
98 | // Hook up the document listeners, which create a Disposable which will be added to this.subscriptions
99 |
100 | if (altYamlCapabilities.syntaxValidation && altYamlCapabilities.schemaValidation) {
101 | // Noop. No server-side capability needs to be set for diagnostics because it is based on pushing from server to client.
102 | } else {
103 | this.createDocumentManagerHandler(this.documentManager.onDidChangeContent, new DiagnosticProvider(clientParams.initializationOptions?.diagnosticDelay, !altYamlCapabilities.syntaxValidation, !altYamlCapabilities.schemaValidation));
104 | }
105 |
106 | // End of document listeners
107 |
108 | // Hook up all the applicable LSP listeners, which do not create Disposables for some reason
109 |
110 | if (altYamlCapabilities.basicCompletions && altYamlCapabilities.advancedCompletions) {
111 | this._capabilities.completionProvider = undefined;
112 | } else {
113 | this.createLspHandler(this.connection.onCompletion, new MultiCompletionProvider(!altYamlCapabilities.basicCompletions, !altYamlCapabilities.advancedCompletions));
114 | }
115 |
116 | if (altYamlCapabilities.hover) {
117 | this._capabilities.hoverProvider = undefined;
118 | } else {
119 | this.createLspHandler(this.connection.onHover, new KeyHoverProvider());
120 | }
121 |
122 | if (altYamlCapabilities.imageLinks) {
123 | this._capabilities.documentLinkProvider = undefined;
124 | } else {
125 | this.createLspHandler(this.connection.onDocumentLinks, new ImageLinkProvider());
126 | }
127 |
128 | if (altYamlCapabilities.formatting) {
129 | this._capabilities.documentFormattingProvider = undefined;
130 | } else {
131 | this.createLspHandler(this.connection.onDocumentFormatting, new DocumentFormattingProvider());
132 | }
133 |
134 | // End of LSP listeners
135 |
136 | // Hook up one additional notification handler
137 | this.connection.onNotification(DocumentSettingsNotification.method, (params) => this.onDidChangeDocumentSettings(params));
138 |
139 | // Start the document listener
140 | this.documentManager.listen(this.connection);
141 |
142 | // Start the telemetry aggregator
143 | this.subscriptions.push(this.telemetryAggregator = new TelemetryAggregator(this.connection, clientParams.initializationOptions?.telemetryAggregationInterval));
144 | }
145 |
146 | public dispose(): void {
147 | for (const subscription of this.subscriptions) {
148 | subscription.dispose();
149 | }
150 | }
151 |
152 | public get capabilities(): ServerCapabilities {
153 | return this._capabilities;
154 | }
155 |
156 | private onDidChangeDocumentSettings(params: DocumentSettingsNotificationParams): void {
157 | // TODO: Telemetrize this?
158 | const composeDoc = this.documentManager.get(params.textDocument.uri);
159 |
160 | if (composeDoc) {
161 | composeDoc.updateSettings(params);
162 | }
163 | }
164 |
165 | private createLspHandler(
166 | event: (handler: ServerRequestHandler
) => void,
167 | handler: ProviderBase
168 | ): void {
169 | event(async (params, token, workDoneProgress, resultProgress) => {
170 |
171 | return await this.callWithTelemetryAndErrorHandling(handler.constructor.name, async () => {
172 | const doc = this.documentManager.get(params.textDocument.uri);
173 | if (!doc) {
174 | throw new ResponseError(ErrorCodes.InvalidParams, 'Document not found in cache.');
175 | }
176 |
177 | const extendedParams: P & ExtendedParams = {
178 | ...params,
179 | document: doc,
180 | };
181 |
182 | return await Promise.resolve(handler.on(extendedParams, token, workDoneProgress, resultProgress));
183 | });
184 |
185 | });
186 | }
187 |
188 | private createDocumentManagerHandler
>(
189 | event: Event
,
190 | handler: ProviderBase
191 | ): void {
192 | event(async (params) => {
193 |
194 | return await this.callWithTelemetryAndErrorHandling(handler.constructor.name, async () => {
195 | const extendedParams: P & ExtendedParams = {
196 | ...params,
197 | textDocument: params.document.id,
198 | };
199 |
200 | return await Promise.resolve(handler.on(extendedParams, CancellationToken.None));
201 | });
202 |
203 | }, this, this.subscriptions);
204 | }
205 |
206 | private async callWithTelemetryAndErrorHandling(callbackId: string, callback: () => Promise): Promise> {
207 | const actionContext: ActionContext = {
208 | clientCapabilities: this.clientParams.capabilities,
209 | connection: this.connection,
210 | telemetry: initEvent(callbackId),
211 | };
212 |
213 | const startTime = process.hrtime.bigint();
214 |
215 | try {
216 | return await runWithContext(actionContext, callback);
217 | } catch (error) {
218 | let responseError: ResponseError;
219 | let stack: string | undefined;
220 |
221 | if (error instanceof ResponseError) {
222 | responseError = error;
223 | stack = error.stack;
224 | } else if (error instanceof Error) {
225 | responseError = new ResponseError(ErrorCodes.UnknownErrorCode, error.message, error as unknown as E);
226 | stack = error.stack;
227 | } else {
228 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
229 | responseError = new ResponseError(ErrorCodes.InternalError, (error as any).toString ? (error as any).toString() : 'Unknown error');
230 | }
231 |
232 | actionContext.telemetry.properties.result = 'Failed';
233 | actionContext.telemetry.properties.error = responseError.code.toString();
234 | actionContext.telemetry.properties.errorMessage = responseError.message;
235 | actionContext.telemetry.properties.stack = stack;
236 |
237 | return responseError;
238 | } finally {
239 | const endTime = process.hrtime.bigint();
240 | const elapsedMicroseconds = Number((endTime - startTime) / BigInt(1000));
241 | actionContext.telemetry.measurements.duration = elapsedMicroseconds;
242 |
243 | // The aggregator will internally handle suppressing / etc.
244 | this.telemetryAggregator.logEvent(actionContext.telemetry);
245 | }
246 | }
247 | }
248 |
--------------------------------------------------------------------------------