├── .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
│ │ ├── extension.ts
│ │ ├── AlternateYamlLanguageServiceClientFeature.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
│ │ ├── ServiceStartupCodeLensProvider.test.ts
│ │ ├── DiagnosticProvider.test.ts
│ │ └── KeyHoverProvider.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
│ │ ├── ServiceStartupCodeLensProvider.ts
│ │ ├── ImageLinkProvider.ts
│ │ └── KeyHoverProvider.ts
│ ├── utils
│ │ ├── yamlRangeToLspRange.ts
│ │ ├── Lazy.ts
│ │ ├── ActionContext.ts
│ │ ├── debounce.ts
│ │ └── telemetry
│ │ │ ├── logNormal.ts
│ │ │ └── TelemetryAggregator.ts
│ └── ExtendedParams.ts
├── client
│ ├── ClientCapabilities.ts
│ ├── AlternateYamlLanguageServiceClientCapabilities.ts
│ ├── DocumentSettings.ts
│ └── TelemetryEvent.ts
└── server.ts
├── .gitattributes
├── .azure-pipelines
├── compliance
│ ├── CredScanSuppressions.json
│ ├── tsaoptions.json
│ └── PoliCheckExclusions.xml
├── 1esmain.yml
└── release-npm.yml
├── bin
└── docker-compose-langserver
├── tsconfig.json
├── LICENSE
├── package.json
├── SECURITY.md
├── .eslintrc.json
├── README.md
└── CHANGELOG.md
/.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": "explicit"
10 | },
11 | "typescript.preferences.importModuleSpecifier": "relative",
12 | "git.branchProtection": [
13 | "main",
14 | "rel/*"
15 | ],
16 | }
17 |
--------------------------------------------------------------------------------
/.azure-pipelines/compliance/tsaoptions.json:
--------------------------------------------------------------------------------
1 | {
2 | "tsaVersion": "TsaV2",
3 | "codeBase": "NewOrUpdate",
4 | "codeBaseName": "compose-language-service",
5 | "tsaStamp": "DevDiv",
6 | "notificationAliases": [
7 | "DockerToolsTeam@microsoft.com"
8 | ],
9 | "instanceUrl": "https://devdiv.visualstudio.com",
10 | "projectName": "DevDiv",
11 | "areaPath": "DevDiv\\VS Azure Tools\\Container Tools",
12 | "iterationPath": "DevDiv",
13 | "allTools": true
14 | }
15 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 | app_id: ${{ secrets.AZURETOOLS_VSCODE_BOT_APP_ID }}
23 | app_installation_id: ${{ secrets.AZURETOOLS_VSCODE_BOT_APP_INSTALLATION_ID }}
24 | app_private_key: ${{ secrets.AZURETOOLS_VSCODE_BOT_APP_PRIVATE_KEY }}
25 | daysSinceClose: 45
26 | daysSinceUpdate: 7
27 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.azure-pipelines/1esmain.yml:
--------------------------------------------------------------------------------
1 | # Trigger the build whenever `main` or `rel/*` is updated
2 | trigger:
3 | - main
4 | - rel/*
5 |
6 | # Disable PR trigger
7 | pr: none
8 |
9 | # Scheduled nightly build of `main`
10 | schedules:
11 | - cron: "0 0 * * *"
12 | displayName: Nightly scheduled build
13 | always: false # Don't rebuild if there haven't been changes
14 | branches:
15 | include:
16 | - main
17 |
18 | # `resources` specifies the location of templates to pick up, use it to get AzExt templates
19 | resources:
20 | repositories:
21 | - repository: azExtTemplates
22 | type: github
23 | name: microsoft/vscode-azuretools
24 | ref: main
25 | endpoint: GitHub-AzureTools # The service connection to use when accessing this repository
26 |
27 | variables:
28 | # Required by MicroBuild template
29 | - name: TeamName
30 | value: "Container Tools Team"
31 |
32 | # Use those templates
33 | extends:
34 | template: azure-pipelines/1esmain.yml@azExtTemplates
35 | parameters:
36 | enableSigning: false
37 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 serviceStartupCodeLens: boolean,
19 | readonly hover: boolean,
20 | readonly imageLinks: boolean,
21 | readonly formatting: boolean,
22 | };
23 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 | - name: publishVersion
11 | displayName: Publish Version
12 | type: string
13 | - name: dryRun
14 | displayName: Dry Run
15 | type: boolean
16 | default: false
17 |
18 | # Grab the base templates from https://github.com/microsoft/vscode-azuretools/tree/main/azure-pipelines
19 | resources:
20 | repositories:
21 | - repository: templates
22 | type: github
23 | name: microsoft/vscode-azuretools
24 | ref: main
25 | endpoint: GitHub-AzureTools
26 | pipelines:
27 | - pipeline: build # This must be "build"
28 | source: \Azure Tools\VSCode\Packages\compose-language-service # name of the pipeline that produces the artifacts
29 |
30 | # Use those base templates
31 | extends:
32 | template: azure-pipelines/1es-release-npm.yml@templates
33 | parameters:
34 | packageToPublish: ${{ parameters.packageToPublish }}
35 | publishVersion: ${{ parameters.publishVersion }}
36 | dryRun: ${{ parameters.dryRun }}
37 | OwnerAlias: "devinb"
38 | ApproverAlias: "bwater"
39 |
--------------------------------------------------------------------------------
/.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 | app_id: ${{ secrets.AZURETOOLS_VSCODE_BOT_APP_ID }}
23 | app_installation_id: ${{ secrets.AZURETOOLS_VSCODE_BOT_APP_INSTALLATION_ID }}
24 | app_private_key: ${{ secrets.AZURETOOLS_VSCODE_BOT_APP_PRIVATE_KEY }}
25 | label: info-needed
26 | closeDays: 14
27 | 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!"
28 | pingDays: 80
29 | 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."
30 |
--------------------------------------------------------------------------------
/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.4.0",
5 | "publisher": "ms-azuretools",
6 | "description": "Language service for Docker Compose documents",
7 | "license": "MIT",
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/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/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 | serviceStartupCodeLens: false, // YAML extension does not have service startup for compose docs
30 | hover: false, // YAML extension provides hover, but the compose spec lacks descriptions -- https://github.com/compose-spec/compose-spec/issues/138
31 | imageLinks: false, // YAML extension does not have image hyperlinks for compose docs
32 | formatting: true,
33 | };
34 |
35 | capabilities.experimental = {
36 | ...capabilities.experimental,
37 | alternateYamlLanguageService: altYamlClientCapabilities,
38 | };
39 | }
40 | }
41 |
42 | public initialize(): void {
43 | // Noop
44 | }
45 |
46 | public dispose(): void {
47 | // Noop
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Docker Compose Language Service
2 |
3 | ## Overview
4 |
5 | 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 [Container Tools](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-containers) extension for Visual Studio Code.
6 |
7 | ## Features
8 |
9 | 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.
10 |
11 | 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.
12 |
13 | The language service is a work-in-progress, and will continue adding new features and functionality each release.
14 |
15 | ## Contributing
16 |
17 | This project welcomes contributions and suggestions. Most contributions require you to agree to a
18 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
19 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com.
20 |
21 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide
22 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions
23 | provided by the bot. You will only need to do this once across all repos using our CLA.
24 |
25 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
26 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or
27 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.
28 |
29 | ## Telemetry
30 |
31 | 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=521839) to learn more. If you don’t wish to send usage data to Microsoft, you can set the `telemetry.telemetryLevel` setting to `off`. Learn more in our [FAQ](https://code.visualstudio.com/docs/supporting/faq#_how-to-disable-telemetry-reporting).
32 |
33 | 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.
34 |
35 | ## Trademarks
36 |
37 | This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft
38 | trademarks or logos is subject to and must follow
39 | [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general).
40 | Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship.
41 | Any use of third-party trademarks or logos are subject to those third-party's policies.
42 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## 0.4.0 - 17 April 2025
2 | ### Breaking Changes
3 | * The service startup CodeLens has changed the command it will call to `vscode-containers.compose.up`.
4 |
5 | ## 0.3.0 - 17 December 2024
6 | ### Breaking Changes
7 | * New properties have been added to `AlternateYamlLanguageServiceClientCapabilities`
8 |
9 | ### Added
10 | * CodeLenses are now created for starting individual services or all services right from the compose document. [#157](https://github.com/microsoft/compose-language-service/issues/157)
11 |
12 | ### Fixed
13 | * The `!reset` and `!override` YAML tags will no longer yield warnings. [#145](https://github.com/microsoft/compose-language-service/issues/145)
14 |
15 | ## 0.2.0 - 10 May 2023
16 | ### Breaking Changes
17 | * The `ComposeLanguageClientCapabilities` type has been moved from `lib/client/DocumentSettings` to `lib/client/ClientCapabilities`
18 |
19 | ### Added
20 | * 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)
21 |
22 | ## 0.1.3 - 13 February 2023
23 | ### Added
24 | * Added an executable to launch the language server. [#114](https://github.com/microsoft/compose-language-service/issues/114)
25 |
26 | ## 0.1.2 - 20 July 2022
27 | ### Changed
28 | * 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)
29 |
30 | ## 0.1.1 - 8 April 2022
31 | ### Added
32 | * Completions for the `profiles` section within a service. [#94](https://github.com/microsoft/compose-language-service/pull/94)
33 |
34 | ### Fixed
35 | * Formatting should no longer remove document end markers. [#93](https://github.com/microsoft/compose-language-service/issues/93)
36 |
37 | ## 0.1.0 - 14 February 2022
38 | ### Fixed
39 | * Merge keys are now allowed. [#78](https://github.com/microsoft/compose-language-service/issues/78)
40 | * Better error messages. [#88](https://github.com/microsoft/compose-language-service/pull/88)
41 |
42 | ## 0.0.5-alpha - 15 December 2021
43 | ### Added
44 | * Completions under the `build` section within a service. [#48](https://github.com/microsoft/compose-language-service/issues/48)
45 |
46 | ### Fixed
47 | * `null` will no longer be inserted on empty maps. [#65](https://github.com/microsoft/compose-language-service/issues/65)
48 | * Lines longer than 80 characters will no longer be wrapped. [#70](https://github.com/microsoft/compose-language-service/issues/70)
49 | * Completions will no longer be suggested on lines that are already complete. [#68](https://github.com/microsoft/compose-language-service/issues/68)
50 |
51 | ## 0.0.4-alpha - 8 November 2021
52 | ### Fixed
53 | * Removes test-scenario postinstall script as it was preventing installation.
54 |
55 | ## 0.0.3-alpha - 8 November 2021
56 | ### Fixed
57 | * A handful of minor bugs relating to position logic (especially affecting hover).
58 |
59 | ## 0.0.2-alpha - 29 October 2021
60 | ### Added
61 | * Significantly more completions have been added.
62 |
63 | ### Removed
64 | * Removed signature help for ports, in favor of completions instead.
65 |
66 | ## 0.0.1-alpha - 20 September 2021
67 | ### Added
68 | * Initial release!
69 | * Hyperlinks to Docker Hub for images
70 | * Hover info for many common Compose keys
71 | * Signature help for ports
72 | * Completions for volume mappings
73 | * Diagnostics (currently validates correct YAML only, does not enforce Compose schema)
74 | * Document formatting
75 |
--------------------------------------------------------------------------------
/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": "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": "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-containers", // Keep the Container Tools 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": "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/ServiceStartupCodeLensProvider.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, CodeLens, CodeLensParams } from 'vscode-languageserver';
7 | import { ProviderBase } from './ProviderBase';
8 | import { ExtendedParams } from '../ExtendedParams';
9 | import { getCurrentContext } from '../utils/ActionContext';
10 | import { isMap, isPair, isScalar } from 'yaml';
11 | import { yamlRangeToLspRange } from '../utils/yamlRangeToLspRange';
12 |
13 | export class ServiceStartupCodeLensProvider extends ProviderBase {
14 | public on(params: CodeLensParams & ExtendedParams, token: CancellationToken): CodeLens[] | undefined {
15 | const ctx = getCurrentContext();
16 | ctx.telemetry.properties.isActivationEvent = 'true'; // This happens automatically so we'll treat it as isActivationEvent === true
17 |
18 | const results: CodeLens[] = [];
19 |
20 | if (!params.document.yamlDocument.value.has('services')) {
21 | return undefined;
22 | }
23 |
24 | // First add the run-all from the main "services" node
25 | const documentMap = params.document.yamlDocument.value.contents;
26 | if (isMap(documentMap)) {
27 | const servicesNode = documentMap.items.find(item => {
28 | return isScalar(item.key) && item.key.value === 'services';
29 | });
30 |
31 | if (isPair(servicesNode)) {
32 | const servicesKey = servicesNode.key;
33 |
34 | if (isScalar(servicesKey) && servicesKey.range && isMap(servicesNode.value)) {
35 | const lens = CodeLens.create(yamlRangeToLspRange(params.document.textDocument, servicesKey.range));
36 | lens.command = {
37 | title: '$(run-all) Run All Services',
38 | command: 'vscode-containers.compose.up',
39 | arguments: [
40 | /* dockerComposeFileUri: */ params.document.uri
41 | ],
42 | };
43 | results.push(lens);
44 | }
45 | }
46 | }
47 |
48 | // Check for cancellation
49 | if (token.isCancellationRequested) {
50 | return undefined;
51 | }
52 |
53 | // Then add the run-single for each service
54 | const serviceMap = params.document.yamlDocument.value.getIn(['services']);
55 | if (isMap(serviceMap)) {
56 | for (const service of serviceMap.items) {
57 | // Within each loop we'll check for cancellation (though this is expected to be very fast)
58 | if (token.isCancellationRequested) {
59 | return undefined;
60 | }
61 |
62 | if (isScalar(service.key) && typeof service.key.value === 'string' && service.key.range) {
63 | const lens = CodeLens.create(yamlRangeToLspRange(params.document.textDocument, service.key.range));
64 | lens.command = {
65 | title: '$(play) Run Service',
66 | command: 'vscode-containers.compose.up.subset',
67 | arguments: [ // Arguments are from here: https://github.com/microsoft/vscode-docker/blob/a45a3dfc8e582f563292a707bbe56f616f7fedeb/src/commands/compose/compose.ts#L79
68 | /* dockerComposeFileUri: */ params.document.uri,
69 | /* selectedComposeFileUris: */ undefined,
70 | /* preselectedServices: */[service.key.value],
71 | ],
72 | };
73 | results.push(lens);
74 | }
75 | }
76 | }
77 |
78 | return results;
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/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/ServiceStartupCodeLensProvider.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 { CodeLensRequest, CodeLens, DocumentUri, Range, ResponseError } from 'vscode-languageserver';
8 | import { TestConnection } from '../TestConnection';
9 |
10 | interface ExpectedServiceStartupCodeLens {
11 | range: Range;
12 | command: {
13 | command: string;
14 | }
15 | }
16 |
17 | describe('ServiceStartupCodeLensProvider', () => {
18 | let testConnection: TestConnection;
19 | before('Prepare a language server for testing', async () => {
20 | testConnection = new TestConnection();
21 | });
22 |
23 | describe('Common scenarios', () => {
24 | it('Should provide a code lens to start all services at the root services node', async () => {
25 | const testObject = {
26 | services: {}
27 | };
28 |
29 | const expected = [
30 | {
31 | range: Range.create(0, 0, 0, 8),
32 | command: {
33 | command: 'vscode-containers.compose.up'
34 | }
35 | },
36 | ];
37 |
38 | const uri = testConnection.sendObjectAsYamlDocument(testObject);
39 | await requestServiceStartupCodeLensesAndCompare(testConnection, uri, expected);
40 | });
41 |
42 | it('Should provide a code lens for starting each service', async () => {
43 | const testObject = {
44 | version: '123',
45 | services: {
46 | abc: {
47 | image: 'alpine'
48 | },
49 | def: {
50 | image: 'mysql:latest'
51 | },
52 | }
53 | };
54 |
55 | const expected = [
56 | {
57 | range: Range.create(1, 0, 1, 8),
58 | command: {
59 | command: 'vscode-containers.compose.up'
60 | }
61 | },
62 | {
63 | range: Range.create(2, 2, 2, 5),
64 | command: {
65 | command: 'vscode-containers.compose.up.subset',
66 | }
67 | },
68 | {
69 | range: Range.create(4, 2, 4, 5),
70 | command: {
71 | command: 'vscode-containers.compose.up.subset',
72 | }
73 | },
74 | ];
75 |
76 | const uri = testConnection.sendObjectAsYamlDocument(testObject);
77 | await requestServiceStartupCodeLensesAndCompare(testConnection, uri, expected);
78 | });
79 | });
80 |
81 | describe('Error scenarios', () => {
82 | it('Should return an error for nonexistent files', () => {
83 | return testConnection
84 | .client.sendRequest(CodeLensRequest.type, { textDocument: { uri: 'file:///bogus' } })
85 | .should.eventually.be.rejectedWith(ResponseError);
86 | });
87 |
88 | it('Should NOT provide service startup code lenses if `services` isn\'t present', async () => {
89 | const uri = testConnection.sendObjectAsYamlDocument({});
90 | await requestServiceStartupCodeLensesAndCompare(testConnection, uri, undefined);
91 | });
92 |
93 | it('Should NOT provide service startup code lenses if `services` isn\'t a map', async () => {
94 | const testObject = {
95 | services: 'a'
96 | };
97 |
98 | const uri = testConnection.sendObjectAsYamlDocument(testObject);
99 | await requestServiceStartupCodeLensesAndCompare(testConnection, uri, []);
100 | });
101 | });
102 |
103 | after('Cleanup', () => {
104 | testConnection.dispose();
105 | });
106 | });
107 |
108 | async function requestServiceStartupCodeLensesAndCompare(testConnection: TestConnection, uri: DocumentUri, expected: ExpectedServiceStartupCodeLens[] | undefined): Promise {
109 | const result = await testConnection.client.sendRequest(CodeLensRequest.type, { textDocument: { uri } }) as CodeLens[];
110 |
111 | if (expected === undefined) {
112 | expect(result).to.be.null;
113 | return;
114 | }
115 |
116 | expect(result).to.be.ok; // Should always be OK result even if 0 code lenses
117 | /* eslint-disable @typescript-eslint/no-non-null-assertion */
118 | result.length.should.equal(expected!.length);
119 |
120 | if (expected!.length) {
121 | // Each diagnostic should have a matching range and content canary in the results
122 | for (const expectedCodeLens of expected!) {
123 | result.some(actualCodeLens => lensesMatch(actualCodeLens, expectedCodeLens)).should.be.true;
124 | }
125 | }
126 | /* eslint-enable @typescript-eslint/no-non-null-assertion */
127 | }
128 |
129 | function lensesMatch(actual: CodeLens, expected: ExpectedServiceStartupCodeLens): boolean {
130 | return (
131 | actual.command?.command === expected.command.command &&
132 | actual.range.start.line === expected.range.start.line &&
133 | actual.range.start.character === expected.range.start.character &&
134 | actual.range.end.line === expected.range.end.line &&
135 | actual.range.end.character === expected.range.end.character
136 | );
137 | }
138 |
--------------------------------------------------------------------------------
/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: 'pull_policy:',
157 | insertText: 'pull_policy: ${1:pullPolicyName}$0',
158 | insertTextFormat: InsertTextFormat.Snippet,
159 | isAdvancedComposeCompletion: false,
160 | },
161 | {
162 | label: 'volumes:',
163 | insertText: 'volumes:\n\t-$0',
164 | insertTextFormat: InsertTextFormat.Snippet,
165 | insertTextMode: InsertTextMode.adjustIndentation,
166 | isAdvancedComposeCompletion: false,
167 | },
168 | ]
169 | );
170 |
--------------------------------------------------------------------------------
/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/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 | describe('Custom Tags', async () => {
111 | it('Should provide nothing for valid compose documents with known custom tags', async () => {
112 | const validComposeWithTags = `version: '123'
113 |
114 | services:
115 | foo:
116 | image: !reset bar
117 | ports: !override
118 | - 1234
119 | environment: !override
120 | foo: bar
121 | `;
122 |
123 | const expected = undefined;
124 | await awaitDiagnosticsAndCompare(testConnection, validComposeWithTags, expected);
125 | });
126 |
127 | it('Should provide something for valid compose documents with unknown custom tags', async () => {
128 | const validComposeWithUnknownTags = `version: '123'
129 |
130 | services:
131 | foo:
132 | image: bar
133 | ports:
134 | - 1234
135 | environment: !unknowntag
136 | foo: bar
137 | `;
138 |
139 | const expected: ExpectedDiagnostic[] = [
140 | {
141 | range: Range.create(7, 17, 7, 28),
142 | contentCanary: 'Unresolved tag',
143 | }
144 | ];
145 |
146 | await awaitDiagnosticsAndCompare(testConnection, validComposeWithUnknownTags, expected);
147 | });
148 | });
149 |
150 | after('Cleanup', () => {
151 | testConnection.dispose();
152 | noDiagnosticsTestConnection.dispose();
153 | });
154 | });
155 |
156 | async function awaitDiagnosticsAndCompare(testConnection: TestConnection, testObject: string | unknown, expected: ExpectedDiagnostic[] | undefined): Promise {
157 | let timeout: NodeJS.Timeout | undefined = undefined;
158 |
159 | try {
160 | // 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
161 | const listenerPromise = new Promise((resolve) => {
162 | testConnection.client.onNotification(PublishDiagnosticsNotification.type, (diagnosticParams) => {
163 | resolve(diagnosticParams);
164 | });
165 | });
166 |
167 | // Now that the listener is connected, send the document
168 | let uri: DocumentUri;
169 | if (typeof (testObject) === 'string') {
170 | uri = testConnection.sendTextAsYamlDocument(testObject);
171 | } else {
172 | uri = testConnection.sendObjectAsYamlDocument(testObject);
173 | }
174 |
175 | // A promise that will reject if it times out (if the diagnostics never get sent)
176 | const failurePromise = new Promise((resolve, reject) => {
177 | 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)
178 | });
179 |
180 | // Now await the listener's completion promise to get the result
181 | const result = await Promise.race([listenerPromise, failurePromise]);
182 |
183 | expect(result).to.be.ok;
184 | result.uri.should.equal(uri);
185 |
186 | expect(result.diagnostics).to.be.ok;
187 | result.diagnostics.length.should.equal((expected ?? []).length);
188 |
189 | if (expected?.length) {
190 | // Each diagnostic should have a matching range and content canary in the results
191 | for (const expectedDiagnostic of expected) {
192 | result.diagnostics.some(actualDiagnostic => diagnosticsMatch(actualDiagnostic, expectedDiagnostic)).should.be.true;
193 | }
194 | }
195 | } finally {
196 | if (timeout) {
197 | clearTimeout(timeout);
198 | }
199 | }
200 | }
201 |
202 | function diagnosticsMatch(actual: Diagnostic, expected: ExpectedDiagnostic): boolean {
203 | return (
204 | actual.message.indexOf(expected.contentCanary) >= 0 &&
205 | actual.range.start.line === expected.range.start.line &&
206 | actual.range.start.character === expected.range.start.character &&
207 | actual.range.end.line === expected.range.end.line &&
208 | actual.range.end.character === expected.range.end.character
209 | );
210 | }
211 |
--------------------------------------------------------------------------------
/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.12",
33 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
34 | "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
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.12",
136 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
137 | "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
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 |
--------------------------------------------------------------------------------
/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/test/providers/KeyHoverProvider.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 { DocumentUri, Hover, HoverRequest, MarkupContent, Position, Range, ResponseError } from 'vscode-languageserver';
8 | import { TestConnection } from '../TestConnection';
9 |
10 | interface ExpectedHover {
11 | range: Range;
12 | contentsCanary: string; // A string which ought to be in the contents of the hover text
13 | }
14 |
15 | describe('KeyHoverProvider', () => {
16 | let testConnection: TestConnection;
17 | before('Prepare a language server for testing', async () => {
18 | testConnection = new TestConnection();
19 | });
20 |
21 | describe('Common scenarios', () => {
22 | it('Should provide hovers for known keys', async () => {
23 | const testObject = {
24 | version: '123',
25 | services: {
26 | abcd: {
27 | image: 'foo',
28 | build: '.',
29 | ports: [
30 | '1234',
31 | '5678:9012',
32 | ],
33 | },
34 | },
35 | };
36 |
37 | const uri = testConnection.sendObjectAsYamlDocument(testObject);
38 |
39 | const position1 = Position.create(0, 3); // Inside `version`
40 | const expected1: ExpectedHover = {
41 | range: Range.create(0, 0, 0, 7),
42 | contentsCanary: 'version',
43 | };
44 |
45 | const position2 = Position.create(1, 0); // First character of `services`
46 | const expected2: ExpectedHover = {
47 | range: Range.create(1, 0, 1, 8),
48 | contentsCanary: 'services',
49 | };
50 |
51 | const position3 = Position.create(1, 7); // Last character of `services`
52 | const expected3: ExpectedHover = {
53 | range: Range.create(1, 0, 1, 8),
54 | contentsCanary: 'services',
55 | };
56 |
57 | const position4 = Position.create(3, 4); // First character of `image`
58 | const expected4: ExpectedHover = {
59 | range: Range.create(3, 4, 3, 9),
60 | contentsCanary: 'image',
61 | };
62 |
63 | const position5 = Position.create(3, 8); // Last character of `image`
64 | const expected5: ExpectedHover = {
65 | range: Range.create(3, 4, 3, 9),
66 | contentsCanary: 'image',
67 | };
68 |
69 | await requestHoverAndCompare(testConnection, uri, position1, expected1);
70 | await requestHoverAndCompare(testConnection, uri, position2, expected2);
71 | await requestHoverAndCompare(testConnection, uri, position3, expected3);
72 | await requestHoverAndCompare(testConnection, uri, position4, expected4);
73 | await requestHoverAndCompare(testConnection, uri, position5, expected5);
74 | });
75 |
76 | it('Should NOT provide hovers for unknown keys', async () => {
77 | const testObject = {
78 | foo: '123',
79 | services: {
80 | abcd: {
81 | image: 'foo',
82 | build: '.',
83 | ports: [
84 | '1234',
85 | '5678:9012',
86 | ],
87 | },
88 | },
89 | };
90 |
91 | const uri = testConnection.sendObjectAsYamlDocument(testObject);
92 |
93 | const position1 = Position.create(2, 4); // In service `abcd`
94 | const expected1 = undefined;
95 |
96 | const position2 = Position.create(0, 2); // In key `foo`
97 | const expected2 = undefined;
98 |
99 | await requestHoverAndCompare(testConnection, uri, position1, expected1);
100 | await requestHoverAndCompare(testConnection, uri, position2, expected2);
101 | });
102 |
103 | it('Should NOT provide hovers for values', async () => {
104 | const testObject = {
105 | version: '123',
106 | services: {
107 | abcd: {
108 | image: 'foo',
109 | build: '.',
110 | ports: [
111 | '1234',
112 | '5678:9012',
113 | ],
114 | },
115 | },
116 | };
117 |
118 | const uri = testConnection.sendObjectAsYamlDocument(testObject);
119 |
120 | const position1 = Position.create(0, 11); // In version '123'
121 | const expected1 = undefined;
122 |
123 | const position2 = Position.create(6, 11); // In port `1234`
124 | const expected2 = undefined;
125 |
126 | const position3 = Position.create(7, 8); // First character of port `5678:9012`
127 | const expected3 = undefined;
128 |
129 | const position4 = Position.create(3, 13); // Last character of image `foo`
130 | const expected4 = undefined;
131 |
132 | await requestHoverAndCompare(testConnection, uri, position1, expected1);
133 | await requestHoverAndCompare(testConnection, uri, position2, expected2);
134 | await requestHoverAndCompare(testConnection, uri, position3, expected3);
135 | await requestHoverAndCompare(testConnection, uri, position4, expected4);
136 | });
137 |
138 | it('Should NOT provide hovers for separators', async () => {
139 | const testObject = {
140 | version: '123',
141 | services: {
142 | abcd: {
143 | image: 'foo',
144 | build: '.',
145 | ports: [
146 | '1234',
147 | '5678:9012',
148 | ],
149 | },
150 | },
151 | };
152 |
153 | const uri = testConnection.sendObjectAsYamlDocument(testObject);
154 |
155 | const position1 = Position.create(0, 7); // In version's separator (the colon)
156 | const expected1 = undefined;
157 |
158 | const position2 = Position.create(6, 6); // In port 1234's separator (the dash)
159 | const expected2 = undefined;
160 |
161 | await requestHoverAndCompare(testConnection, uri, position1, expected1);
162 | await requestHoverAndCompare(testConnection, uri, position2, expected2);
163 | });
164 |
165 | it('Should NOT provide hovers for comments', async () => {
166 | const testObject = `version: '123'
167 | # Hello world!
168 | services:
169 | foo:
170 | image: redis`;
171 |
172 | const uri = testConnection.sendTextAsYamlDocument(testObject);
173 |
174 | const position1 = Position.create(1, 0); // In the comment's #
175 | const expected1 = undefined;
176 |
177 | const position2 = Position.create(1, 1); // Inbetween the comment's # and the body
178 | const expected2 = undefined;
179 |
180 | const position3 = Position.create(1, 4); // In the comment body
181 | const expected3 = undefined;
182 |
183 | await requestHoverAndCompare(testConnection, uri, position1, expected1);
184 | await requestHoverAndCompare(testConnection, uri, position2, expected2);
185 | await requestHoverAndCompare(testConnection, uri, position3, expected3);
186 | });
187 |
188 | it('Should NOT provide hovers for whitespace', async () => {
189 | const testObject = {
190 | version: '123',
191 | services: {
192 | abcd: {
193 | image: 'foo',
194 | build: '.',
195 | ports: [
196 | '1234',
197 | '5678:9012',
198 | ],
199 | },
200 | },
201 | };
202 |
203 | const uri = testConnection.sendObjectAsYamlDocument(testObject);
204 |
205 | const position1 = Position.create(2, 2); // Whitespace left of `abcd`
206 | const expected1 = undefined;
207 |
208 | const position2 = Position.create(3, 10); // Whitespace between `image` and `foo`
209 | const expected2 = undefined;
210 |
211 | const position3 = Position.create(3, 2); // Whitespace left of `image`
212 | const expected3 = undefined;
213 |
214 | await requestHoverAndCompare(testConnection, uri, position1, expected1);
215 | await requestHoverAndCompare(testConnection, uri, position2, expected2);
216 | await requestHoverAndCompare(testConnection, uri, position3, expected3);
217 | });
218 | });
219 |
220 | describe('Error scenarios', () => {
221 | it('Should return an error for nonexistent files', () => {
222 | return testConnection
223 | .client.sendRequest(HoverRequest.type, { textDocument: { uri: 'file:///bogus' }, position: Position.create(0, 0) })
224 | .should.eventually.be.rejectedWith(ResponseError);
225 | });
226 |
227 | it('Should NOT provide hovers for nonscalar keys', async () => {
228 | const testObject = `version: '123'
229 | ? [services, foo]
230 | :
231 | abcd:
232 | image: foo
233 | build: .`;
234 |
235 | const uri = testConnection.sendTextAsYamlDocument(testObject);
236 |
237 | const position1 = Position.create(1, 6); // Inside key `[services, foo]`
238 | const expected1 = undefined;
239 |
240 | await requestHoverAndCompare(testConnection, uri, position1, expected1);
241 | });
242 | });
243 |
244 | after('Cleanup', () => {
245 | testConnection.dispose();
246 | });
247 | });
248 |
249 | async function requestHoverAndCompare(testConnection: TestConnection, uri: DocumentUri, position: Position, expected: ExpectedHover | undefined): Promise {
250 | const result = await testConnection.client.sendRequest(HoverRequest.type, { textDocument: { uri }, position: position }) as Hover | undefined;
251 |
252 | if (expected === undefined) {
253 | expect(result).to.not.be.ok;
254 | } else {
255 | expect(result).to.be.ok;
256 | expect(result?.range).to.be.ok;
257 |
258 | /* eslint-disable @typescript-eslint/no-non-null-assertion */
259 | result!.range!.should.deep.equal(expected.range);
260 |
261 | MarkupContent.is(result!.contents).should.be.true;
262 | (result!.contents as MarkupContent).value.should.contain(expected.contentsCanary);
263 | /* eslint-enable @typescript-eslint/no-non-null-assertion */
264 | }
265 | }
266 |
--------------------------------------------------------------------------------