├── .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 | --------------------------------------------------------------------------------