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

NOTICES AND INFORMATION

15 |

Do Not Translate or Localize

16 |

17 | This software incorporates material from third parties. 18 | Microsoft makes certain open source code available at https://3rdpartysource.microsoft.com, 19 | or you may send a check or money order for US $5.00, including the product name, 20 | the open source component name, platform, and version number, to: 21 |

22 |
23 | Source Code Compliance Team
24 | Microsoft Corporation
25 | One Microsoft Way
26 | Redmond, WA 98052
27 | USA 28 |
29 |

30 | Notwithstanding any other terms, you may reverse engineer this software to the extent 31 | required to debug changes to any libraries licensed under the GNU Lesser General Public License. 32 |

33 |
    34 |
  1. 35 |
    36 | 37 | yaml 2.2.2 - ISC 38 | 39 |

    https://eemeli.org/yaml/

    40 |
    • Copyright (c) Microsoft Corporation
    • 41 |
    • Copyright Eemeli Aro <eemeli@gmail.com>
    42 |
     43 |         Copyright Eemeli Aro <eemeli@gmail.com>
     44 | 
     45 | Permission to use, copy, modify, and/or distribute this software for any purpose
     46 | with or without fee is hereby granted, provided that the above copyright notice
     47 | and this permission notice appear in all copies.
     48 | 
     49 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
     50 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
     51 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
     52 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
     53 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
     54 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
     55 | THIS SOFTWARE.
     56 | 
     57 |         
    58 |
    59 |
  2. 60 |
  3. 61 |
    62 | 63 | vscode-jsonrpc 8.1.0 - MIT 64 | 65 |

    https://github.com/Microsoft/vscode-languageserver-node#readme

    66 |
    • Copyright (c) Microsoft Corporation
    67 |
     68 |         Copyright (c) Microsoft Corporation
     69 | 
     70 | All rights reserved.
     71 | 
     72 | MIT License
     73 | 
     74 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
     75 | 
     76 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
     77 | 
     78 | THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
     79 | 
     80 |         
    81 |
    82 |
  4. 83 |
  5. 84 |
    85 | 86 | vscode-languageserver 8.1.0 - MIT 87 | 88 |

    https://github.com/Microsoft/vscode-languageserver-node#readme

    89 |
    • Copyright (c) TypeFox and others
    • 90 |
    • Copyright (c) Microsoft Corporation
    91 |
     92 |         Copyright (c) Microsoft Corporation
     93 | 
     94 | All rights reserved.
     95 | 
     96 | MIT License
     97 | 
     98 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
     99 | 
    100 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
    101 | 
    102 | THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    103 | 
    104 |         
    105 |
    106 |
  6. 107 |
  7. 108 |
    109 | 110 | vscode-languageserver-protocol 3.17.3 - MIT 111 | 112 |

    https://github.com/Microsoft/vscode-languageserver-node#readme

    113 |
    • Copyright (c) Microsoft Corporation
    • 114 |
    • Copyright (c) TypeFox, Microsoft and others
    115 |
    116 |         Copyright (c) Microsoft Corporation
    117 | 
    118 | All rights reserved.
    119 | 
    120 | MIT License
    121 | 
    122 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
    123 | 
    124 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
    125 | 
    126 | THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    127 | 
    128 |         
    129 |
    130 |
  8. 131 |
  9. 132 |
    133 | 134 | vscode-languageserver-textdocument 1.0.8 - MIT 135 | 136 |

    https://github.com/Microsoft/vscode-languageserver-node#readme

    137 |
    • Copyright (c) Microsoft Corporation
    138 |
    139 |         Copyright (c) Microsoft Corporation
    140 | 
    141 | All rights reserved.
    142 | 
    143 | MIT License
    144 | 
    145 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
    146 | 
    147 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
    148 | 
    149 | THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    150 | 
    151 |         
    152 |
    153 |
  10. 154 |
  11. 155 |
    156 | 157 | vscode-languageserver-types 3.17.3 - MIT 158 | 159 |

    https://github.com/Microsoft/vscode-languageserver-node#readme

    160 |
    • Copyright (c) Microsoft Corporation
    161 |
    162 |         Copyright (c) Microsoft Corporation
    163 | 
    164 | All rights reserved.
    165 | 
    166 | MIT License
    167 | 
    168 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
    169 | 
    170 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
    171 | 
    172 | THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    173 | 
    174 |         
    175 |
    176 |
  12. 177 |
178 | 179 | -------------------------------------------------------------------------------- /src/test/providers/completion/VolumesCompletionCollection.test.ts: -------------------------------------------------------------------------------- 1 | /*!-------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import { CompletionRequest, InsertTextFormat, Position, ResponseError } from 'vscode-languageserver'; 7 | import { TestConnection } from '../../TestConnection'; 8 | import { ExpectedCompletionItem, requestCompletionsAndCompare, UnexpectedCompletionItem } from './requestCompletionsAndCompare'; 9 | 10 | // Completions that are not allowed from VolumesCompletionCollection 11 | const defaultUnexpected: UnexpectedCompletionItem[] = [ 12 | { 13 | insertTextCanary: 'services', 14 | }, 15 | { 16 | insertTextCanary: 'build', 17 | }, 18 | { 19 | insertTextCanary: 'containerPort', 20 | }, 21 | ]; 22 | 23 | describe('VolumesCompletionCollection', () => { 24 | let testConnection: TestConnection; 25 | before('Prepare a language server for testing', async () => { 26 | testConnection = new TestConnection(); 27 | }); 28 | 29 | describe('Common scenarios', () => { 30 | it('Should provide completions when in a volume mapping', async () => { 31 | const testObject = `services: 32 | foo: 33 | volumes: 34 | - `; 35 | 36 | const uri = testConnection.sendTextAsYamlDocument(testObject); 37 | 38 | const expected: ExpectedCompletionItem[] = [ 39 | { 40 | label: 'hostPath:containerPath:mode', 41 | insertTextCanary: 'hostPath', 42 | insertTextFormat: InsertTextFormat.Snippet, 43 | }, 44 | { 45 | label: 'volumeName:containerPath:mode', 46 | insertTextCanary: 'volumeName', 47 | insertTextFormat: InsertTextFormat.Snippet, 48 | }, 49 | ]; 50 | 51 | const unexpected: UnexpectedCompletionItem[] = [ 52 | { 53 | insertTextCanary: '${1:containerPath}', 54 | }, 55 | ]; 56 | 57 | await requestCompletionsAndCompare( 58 | testConnection, 59 | uri, 60 | Position.create(3, 7), // Immediately after the dash under `volumes:` 61 | expected, 62 | [...defaultUnexpected, ...unexpected] 63 | ); 64 | 65 | await requestCompletionsAndCompare( 66 | testConnection, 67 | uri, 68 | Position.create(3, 8), // One space after the dash under `volumes:` 69 | expected, 70 | [...defaultUnexpected, ...unexpected] 71 | ); 72 | }); 73 | 74 | it('Should provide completions when after the host path / volume name part of a volume mapping', async () => { 75 | const testObject = `services: 76 | foo: 77 | volumes: 78 | - volumeName: 79 | - /host/path:`; 80 | 81 | const uri = testConnection.sendTextAsYamlDocument(testObject); 82 | 83 | const expected: ExpectedCompletionItem[] = [ 84 | { 85 | label: ':containerPath:mode', 86 | insertTextCanary: 'containerPath', 87 | insertTextFormat: InsertTextFormat.Snippet, 88 | }, 89 | ]; 90 | 91 | const unexpected: UnexpectedCompletionItem[] = [ 92 | { 93 | insertTextCanary: '${2:containerPath}', 94 | }, 95 | ]; 96 | 97 | await requestCompletionsAndCompare( 98 | testConnection, 99 | uri, 100 | Position.create(3, 19), // Immediately after `volumeName:` 101 | expected, 102 | [...defaultUnexpected, ...unexpected] 103 | ); 104 | 105 | await requestCompletionsAndCompare( 106 | testConnection, 107 | uri, 108 | Position.create(4, 19), // Immediately after `/host/path:` 109 | expected, 110 | [...defaultUnexpected, ...unexpected] 111 | ); 112 | }); 113 | 114 | it('Should provide completions when after the host path / volume name and container path parts of a volume mapping', async () => { 115 | const testObject = `services: 116 | foo: 117 | volumes: 118 | - volumeName:/container/path: 119 | - /host/path:/container/path:`; 120 | 121 | const uri = testConnection.sendTextAsYamlDocument(testObject); 122 | 123 | const expected: ExpectedCompletionItem[] = [ 124 | { 125 | label: ':ro', 126 | insertTextCanary: 'ro', 127 | insertTextFormat: InsertTextFormat.PlainText, 128 | }, 129 | { 130 | label: ':rw', 131 | insertTextCanary: 'rw', 132 | insertTextFormat: InsertTextFormat.PlainText, 133 | }, 134 | ]; 135 | 136 | const unexpected: UnexpectedCompletionItem[] = [ 137 | { 138 | insertTextCanary: '${1:containerPath}', 139 | }, 140 | { 141 | insertTextCanary: '${2:containerPath}', 142 | }, 143 | ]; 144 | 145 | await requestCompletionsAndCompare( 146 | testConnection, 147 | uri, 148 | Position.create(3, 35), // Immediately after `volumeName:/container/path:` 149 | expected, 150 | [...defaultUnexpected, ...unexpected] 151 | ); 152 | 153 | await requestCompletionsAndCompare( 154 | testConnection, 155 | uri, 156 | Position.create(4, 35), // Immediately after `/host/path:/container/path:` 157 | expected, 158 | [...defaultUnexpected, ...unexpected] 159 | ); 160 | }); 161 | 162 | it('Should NOT provide completions at the root', async () => { 163 | const testObject = `version: '3.4' 164 | `; 165 | 166 | const uri = testConnection.sendTextAsYamlDocument(testObject); 167 | 168 | const unexpected: UnexpectedCompletionItem[] = [ 169 | { 170 | insertTextCanary: 'volumeName', 171 | }, 172 | { 173 | insertTextCanary: 'hostPath', 174 | }, 175 | { 176 | insertTextCanary: 'containerPath', 177 | }, 178 | { 179 | insertTextCanary: 'ro', 180 | }, 181 | { 182 | insertTextCanary: 'rw', 183 | }, 184 | ]; 185 | 186 | await requestCompletionsAndCompare( 187 | testConnection, 188 | uri, 189 | Position.create(1, 0), // The start of the line after `version` 190 | [], 191 | unexpected 192 | ); 193 | }); 194 | 195 | it('Should NOT provide completions in an empty document', async () => { 196 | const testObject = ``; 197 | 198 | const uri = testConnection.sendTextAsYamlDocument(testObject); 199 | 200 | const unexpected: UnexpectedCompletionItem[] = [ 201 | { 202 | insertTextCanary: 'volumeName', 203 | }, 204 | { 205 | insertTextCanary: 'hostPath', 206 | }, 207 | { 208 | insertTextCanary: 'containerPath', 209 | }, 210 | { 211 | insertTextCanary: 'ro', 212 | }, 213 | { 214 | insertTextCanary: 'rw', 215 | }, 216 | ]; 217 | 218 | await requestCompletionsAndCompare( 219 | testConnection, 220 | uri, 221 | Position.create(0, 0), // The start of the first line 222 | [], 223 | unexpected 224 | ); 225 | }); 226 | 227 | it('Should NOT provide completions under ports', async () => { 228 | const testObject = `services: 229 | foo: 230 | ports: 231 | - `; 232 | 233 | const uri = testConnection.sendTextAsYamlDocument(testObject); 234 | 235 | const unexpected: UnexpectedCompletionItem[] = [ 236 | { 237 | insertTextCanary: 'volumeName', 238 | }, 239 | { 240 | insertTextCanary: 'hostPath', 241 | }, 242 | { 243 | insertTextCanary: 'containerPath', 244 | }, 245 | // { 246 | // insertTextCanary: 'ro', // 'ro' is in 'hostPort:containerPort/protocol' so we can't use it as a canary 247 | // }, 248 | { 249 | insertTextCanary: 'rw', 250 | }, 251 | ]; 252 | 253 | await requestCompletionsAndCompare( 254 | testConnection, 255 | uri, 256 | Position.create(3, 7), // Immediately after the dash under `ports:` 257 | [], 258 | unexpected 259 | ); 260 | 261 | await requestCompletionsAndCompare( 262 | testConnection, 263 | uri, 264 | Position.create(3, 8), // One space after the dash under `ports:` 265 | [], 266 | unexpected 267 | ); 268 | }); 269 | }); 270 | 271 | describe('Error scenarios', () => { 272 | it('Should return an error for nonexistent files', () => { 273 | return testConnection 274 | .client.sendRequest(CompletionRequest.type, { textDocument: { uri: 'file:///bogus' }, position: Position.create(0, 0) }) 275 | .should.eventually.be.rejectedWith(ResponseError); 276 | }); 277 | }); 278 | 279 | after('Cleanup', () => { 280 | testConnection.dispose(); 281 | }); 282 | }); 283 | -------------------------------------------------------------------------------- /src/test/utils/telemetry/TelemetryAggregator.test.ts: -------------------------------------------------------------------------------- 1 | /*!-------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import { expect } from 'chai'; 7 | import { TelemetryEventNotification } from 'vscode-languageserver-protocol'; 8 | import { initEvent, TelemetryEvent } from '../../../client/TelemetryEvent'; 9 | import { TelemetryAggregator } from '../../../service/utils/telemetry/TelemetryAggregator'; 10 | import { TestConnection } from '../../TestConnection'; 11 | 12 | const TelemetryAggregationInterval = 10; 13 | 14 | describe('(Unit) TelemetryAggregator', () => { 15 | let testConnection: TestConnection; 16 | let telemetryAggregator: TelemetryAggregator; 17 | beforeEach('Prepare a language server for testing', async () => { 18 | testConnection = new TestConnection(); 19 | telemetryAggregator = new TelemetryAggregator(testConnection.server, TelemetryAggregationInterval); 20 | }); 21 | 22 | describe('Common scenarios', () => { 23 | it('Should return aggregated events', async () => { 24 | const inputEvent1: TelemetryEvent = initEvent('aggregated'); 25 | inputEvent1.properties.canary = 'canary'; 26 | inputEvent1.measurements.duration = 5; 27 | 28 | const inputEvent2: TelemetryEvent = initEvent('aggregated'); 29 | inputEvent2.properties.canary = 'canary'; 30 | inputEvent2.measurements.duration = 10; 31 | 32 | const expected: TelemetryEvent = initEvent('aggregated'); 33 | expected.properties.canary = 'canary'; 34 | expected.measurements.count = 2; 35 | expected.measurements.durationMedian = 7; 36 | expected.measurements.durationMu = 1.956; 37 | expected.measurements.durationSigma = 0.49; 38 | 39 | await awaitTelemetryAndCompare(testConnection, telemetryAggregator, [inputEvent1, inputEvent2], expected); 40 | }); 41 | 42 | it('Should immediately return error events without aggregation', async () => { 43 | const inputEvent1: TelemetryEvent = initEvent('errorNoAggregation'); 44 | inputEvent1.properties.result = 'Failed'; 45 | inputEvent1.properties.canary = 'canary'; 46 | inputEvent1.measurements.duration = 5; 47 | 48 | const inputEvent2: TelemetryEvent = initEvent('errorNoAggregation'); 49 | inputEvent2.properties.result = 'Failed'; 50 | inputEvent2.properties.canary = 'canary'; 51 | inputEvent2.measurements.duration = 10; 52 | 53 | // This takes advantage of the fact that the listenerPromise below in `awaitTelemetryAndCompare` will immediately resolve with the first event 54 | const expected: TelemetryEvent = initEvent('errorNoAggregation'); 55 | expected.properties.result = 'Failed'; 56 | expected.properties.canary = 'canary'; 57 | expected.measurements.duration = 5; 58 | 59 | await awaitTelemetryAndCompare(testConnection, telemetryAggregator, [inputEvent1, inputEvent2], expected); 60 | }); 61 | 62 | it('Should NOT return successful events if suppressIfSuccessful is true', () => { 63 | const inputEvent: TelemetryEvent = initEvent('suppressIfSuccessful-Succeeded'); 64 | inputEvent.suppressIfSuccessful = true; 65 | 66 | return awaitTelemetryAndCompare(testConnection, telemetryAggregator, [inputEvent], undefined).should.eventually.be.rejectedWith('timed out'); 67 | }); 68 | 69 | it('Should return error events if suppressIfSuccessful is true', async () => { 70 | const inputEvent: TelemetryEvent = initEvent('suppressIfSuccessful-Failed'); 71 | inputEvent.properties.result = 'Failed'; 72 | inputEvent.properties.canary = 'canary'; 73 | inputEvent.suppressIfSuccessful = true; 74 | inputEvent.measurements.duration = 5; 75 | 76 | const expected: TelemetryEvent = initEvent('suppressIfSuccessful-Failed'); 77 | expected.properties.result = 'Failed'; 78 | expected.properties.canary = 'canary'; 79 | expected.suppressIfSuccessful = true; 80 | expected.measurements.duration = 5; 81 | 82 | await awaitTelemetryAndCompare(testConnection, telemetryAggregator, [inputEvent], expected); 83 | }); 84 | 85 | it('Should NOT return successful events if suppressAll is true', () => { 86 | const inputEvent: TelemetryEvent = initEvent('suppressAll-Succeeded'); 87 | inputEvent.suppressAll = true; 88 | 89 | return awaitTelemetryAndCompare(testConnection, telemetryAggregator, [inputEvent], undefined).should.eventually.be.rejectedWith('timed out'); 90 | }); 91 | 92 | it('Should NOT return error events if suppressAll is true', () => { 93 | const inputEvent: TelemetryEvent = initEvent('suppressAll-Failed'); 94 | inputEvent.properties.result = 'Failed'; 95 | inputEvent.suppressAll = true; 96 | 97 | return awaitTelemetryAndCompare(testConnection, telemetryAggregator, [inputEvent], undefined).should.eventually.be.rejectedWith('timed out'); 98 | }); 99 | 100 | it('Should respect eventName grouping strategy', async () => { 101 | const inputEvent1 = initEvent('eventName'); 102 | inputEvent1.groupingStrategy = 'eventName'; 103 | inputEvent1.properties.canary = 'canary1'; 104 | inputEvent1.measurements.duration = 5; 105 | 106 | const inputEvent2: TelemetryEvent = initEvent('eventName'); 107 | inputEvent2.groupingStrategy = 'eventName'; 108 | inputEvent2.properties.canary = 'canary2'; 109 | inputEvent2.measurements.duration = 10; 110 | 111 | const expected: TelemetryEvent = initEvent('eventName'); 112 | expected.properties.canary = 'canary2'; // Should be 'canary2' since the later events override the earlier events 113 | expected.measurements.count = 2; 114 | expected.measurements.durationMedian = 7; 115 | expected.measurements.durationMu = 1.956; 116 | expected.measurements.durationSigma = 0.49; 117 | 118 | await awaitTelemetryAndCompare(testConnection, telemetryAggregator, [inputEvent1, inputEvent2], expected); 119 | }); 120 | 121 | it('Should respect eventNameAndProperties grouping strategy', async () => { 122 | const inputEvent1 = initEvent('eventNameAndProperties'); 123 | inputEvent1.groupingStrategy = 'eventNameAndProperties'; 124 | inputEvent1.properties.canary = 'canary1'; 125 | inputEvent1.measurements.duration = 5; 126 | 127 | const inputEvent2: TelemetryEvent = initEvent('eventNameAndProperties'); 128 | inputEvent2.groupingStrategy = 'eventNameAndProperties'; 129 | inputEvent2.properties.canary = 'canary2'; 130 | inputEvent2.measurements.duration = 10; 131 | 132 | // This takes advantage of the fact that the listenerPromise below in `awaitTelemetryAndCompare` will resolve with the first event 133 | const expected: TelemetryEvent = initEvent('eventNameAndProperties'); 134 | expected.properties.canary = 'canary1'; // Should be 'canary1' since the second event won't be aggregated with this one 135 | expected.measurements.count = 1; 136 | expected.measurements.durationMedian = 5; 137 | expected.measurements.durationMu = 1.609; 138 | expected.measurements.durationSigma = 0; 139 | 140 | await awaitTelemetryAndCompare(testConnection, telemetryAggregator, [inputEvent1, inputEvent2], expected); 141 | }); 142 | }); 143 | 144 | describe('Error scenarios', () => { 145 | it('Should return a telemetry aggregation error if aggregation fails', async () => { 146 | const inputEvent: TelemetryEvent = initEvent('canary'); 147 | 148 | // Deleting the measurements property will cause the aggregation procedure to throw 149 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 150 | delete (inputEvent as any).measurements; 151 | 152 | const expected: TelemetryEvent = initEvent('telemetryaggregatorfailure'); 153 | expected.properties.result = 'Failed'; 154 | expected.properties.error = 'TypeError'; 155 | expected.properties.errorMessage = 'Cannot read properties of undefined (reading \'duration\')'; 156 | 157 | await awaitTelemetryAndCompare(testConnection, telemetryAggregator, [inputEvent], expected); 158 | }); 159 | }); 160 | 161 | afterEach('Cleanup', () => { 162 | testConnection.dispose(); 163 | telemetryAggregator.dispose(); 164 | }); 165 | }); 166 | 167 | async function awaitTelemetryAndCompare(testConnection: TestConnection, telemetryAggregator: TelemetryAggregator, input: TelemetryEvent[], expected: TelemetryEvent | undefined): Promise { 168 | let timeout: NodeJS.Timeout | undefined = undefined; 169 | 170 | try { 171 | // Need to connect the listener *before* sending the events, to ensure no timing issues, i.e. with the response being sent before the listener is ready 172 | const listenerPromise = new Promise((resolve) => { 173 | testConnection.client.onNotification(TelemetryEventNotification.type, (telemetry) => { 174 | resolve(telemetry); 175 | }); 176 | }); 177 | 178 | // A promise that will reject if it times out (if the diagnostics never get sent) 179 | const failurePromise = new Promise((resolve, reject) => { 180 | timeout = setTimeout(() => reject('timed out awaiting aggregated telemetry response'), TelemetryAggregationInterval * 10); // This carries some risk of test fragility but we have to draw a line somewhere (*sigh* halting problem) 181 | }); 182 | 183 | for (const inputEvent of input) { 184 | telemetryAggregator.logEvent(inputEvent); 185 | } 186 | 187 | // Now await the listener's completion promise to get the result 188 | const result = await Promise.race([listenerPromise, failurePromise]); 189 | 190 | expect(result).to.be.ok; 191 | 192 | // Since it'd be impossible to check we'll eliminate the stack from the output event 193 | if (result.properties.stack) { 194 | delete result.properties.stack; 195 | } 196 | 197 | result.should.deep.equal(expected); 198 | } finally { 199 | if (timeout) { 200 | clearTimeout(timeout); 201 | } 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/service/ComposeLanguageService.ts: -------------------------------------------------------------------------------- 1 | /*!-------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation. All rights reserved. 3 | * Licensed under the MIT License. See LICENSE in the project root for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | import { 7 | CancellationToken, 8 | Connection, 9 | Disposable, 10 | ErrorCodes, 11 | Event, 12 | InitializeParams, 13 | ResponseError, 14 | ServerCapabilities, 15 | ServerRequestHandler, 16 | TextDocumentChangeEvent, 17 | TextDocuments, 18 | TextDocumentSyncKind, 19 | } 20 | from 'vscode-languageserver'; 21 | import { AlternateYamlLanguageServiceClientCapabilities } from '../client/AlternateYamlLanguageServiceClientCapabilities'; 22 | import { ComposeLanguageClientCapabilities } from '../client/ClientCapabilities'; 23 | import { DocumentSettingsNotificationParams, DocumentSettingsNotification } from '../client/DocumentSettings'; 24 | import { initEvent } from '../client/TelemetryEvent'; 25 | import { ComposeDocument } from './ComposeDocument'; 26 | import { ExtendedParams, TextDocumentParams } from './ExtendedParams'; 27 | import { MultiCompletionProvider } from './providers/completion/MultiCompletionProvider'; 28 | import { DiagnosticProvider } from './providers/DiagnosticProvider'; 29 | import { DocumentFormattingProvider } from './providers/DocumentFormattingProvider'; 30 | import { ImageLinkProvider } from './providers/ImageLinkProvider'; 31 | import { KeyHoverProvider } from './providers/KeyHoverProvider'; 32 | import { ProviderBase } from './providers/ProviderBase'; 33 | import { ActionContext, runWithContext } from './utils/ActionContext'; 34 | import { TelemetryAggregator } from './utils/telemetry/TelemetryAggregator'; 35 | 36 | const DefaultCapabilities: ServerCapabilities = { 37 | // Text document synchronization 38 | textDocumentSync: { 39 | openClose: true, 40 | change: TextDocumentSyncKind.Incremental, 41 | willSave: false, 42 | willSaveWaitUntil: false, 43 | save: false, 44 | }, 45 | 46 | // Both basic and advanced completions 47 | completionProvider: { 48 | triggerCharacters: ['-', ':', ' ', '"'], 49 | resolveProvider: false, 50 | }, 51 | 52 | // Hover over YAML keys 53 | hoverProvider: true, 54 | 55 | // Links to Docker Hub on image names 56 | documentLinkProvider: { 57 | resolveProvider: false, 58 | }, 59 | 60 | // YAML formatting 61 | documentFormattingProvider: true, 62 | 63 | // Workspace features 64 | workspace: { 65 | workspaceFolders: { 66 | supported: true, 67 | }, 68 | }, 69 | }; 70 | 71 | // Default settings for a client with no alternate YAML language service 72 | const DefaultAlternateYamlLanguageServiceClientCapabilities: AlternateYamlLanguageServiceClientCapabilities = { 73 | syntaxValidation: false, 74 | schemaValidation: false, 75 | 76 | basicCompletions: false, 77 | advancedCompletions: false, 78 | hover: false, 79 | imageLinks: false, 80 | formatting: false, 81 | }; 82 | 83 | export class ComposeLanguageService implements Disposable { 84 | private readonly documentManager: TextDocuments = new TextDocuments(ComposeDocument.DocumentManagerConfig); 85 | private readonly subscriptions: Disposable[] = []; 86 | private readonly telemetryAggregator: TelemetryAggregator; 87 | private readonly _capabilities: ServerCapabilities = DefaultCapabilities; 88 | 89 | public constructor(public readonly connection: Connection, private readonly clientParams: InitializeParams) { 90 | let altYamlCapabilities = (clientParams.capabilities as ComposeLanguageClientCapabilities).experimental?.alternateYamlLanguageService; 91 | 92 | if (altYamlCapabilities) { 93 | connection.console.info('An alternate YAML language service is present. The Compose language service will not enable features already provided by the alternate.'); 94 | } else { 95 | altYamlCapabilities = DefaultAlternateYamlLanguageServiceClientCapabilities; 96 | } 97 | 98 | // Hook up the document listeners, which create a Disposable which will be added to this.subscriptions 99 | 100 | if (altYamlCapabilities.syntaxValidation && altYamlCapabilities.schemaValidation) { 101 | // Noop. No server-side capability needs to be set for diagnostics because it is based on pushing from server to client. 102 | } else { 103 | this.createDocumentManagerHandler(this.documentManager.onDidChangeContent, new DiagnosticProvider(clientParams.initializationOptions?.diagnosticDelay, !altYamlCapabilities.syntaxValidation, !altYamlCapabilities.schemaValidation)); 104 | } 105 | 106 | // End of document listeners 107 | 108 | // Hook up all the applicable LSP listeners, which do not create Disposables for some reason 109 | 110 | if (altYamlCapabilities.basicCompletions && altYamlCapabilities.advancedCompletions) { 111 | this._capabilities.completionProvider = undefined; 112 | } else { 113 | this.createLspHandler(this.connection.onCompletion, new MultiCompletionProvider(!altYamlCapabilities.basicCompletions, !altYamlCapabilities.advancedCompletions)); 114 | } 115 | 116 | if (altYamlCapabilities.hover) { 117 | this._capabilities.hoverProvider = undefined; 118 | } else { 119 | this.createLspHandler(this.connection.onHover, new KeyHoverProvider()); 120 | } 121 | 122 | if (altYamlCapabilities.imageLinks) { 123 | this._capabilities.documentLinkProvider = undefined; 124 | } else { 125 | this.createLspHandler(this.connection.onDocumentLinks, new ImageLinkProvider()); 126 | } 127 | 128 | if (altYamlCapabilities.formatting) { 129 | this._capabilities.documentFormattingProvider = undefined; 130 | } else { 131 | this.createLspHandler(this.connection.onDocumentFormatting, new DocumentFormattingProvider()); 132 | } 133 | 134 | // End of LSP listeners 135 | 136 | // Hook up one additional notification handler 137 | this.connection.onNotification(DocumentSettingsNotification.method, (params) => this.onDidChangeDocumentSettings(params)); 138 | 139 | // Start the document listener 140 | this.documentManager.listen(this.connection); 141 | 142 | // Start the telemetry aggregator 143 | this.subscriptions.push(this.telemetryAggregator = new TelemetryAggregator(this.connection, clientParams.initializationOptions?.telemetryAggregationInterval)); 144 | } 145 | 146 | public dispose(): void { 147 | for (const subscription of this.subscriptions) { 148 | subscription.dispose(); 149 | } 150 | } 151 | 152 | public get capabilities(): ServerCapabilities { 153 | return this._capabilities; 154 | } 155 | 156 | private onDidChangeDocumentSettings(params: DocumentSettingsNotificationParams): void { 157 | // TODO: Telemetrize this? 158 | const composeDoc = this.documentManager.get(params.textDocument.uri); 159 | 160 | if (composeDoc) { 161 | composeDoc.updateSettings(params); 162 | } 163 | } 164 | 165 | private createLspHandler

( 166 | event: (handler: ServerRequestHandler) => void, 167 | handler: ProviderBase

168 | ): void { 169 | event(async (params, token, workDoneProgress, resultProgress) => { 170 | 171 | return await this.callWithTelemetryAndErrorHandling(handler.constructor.name, async () => { 172 | const doc = this.documentManager.get(params.textDocument.uri); 173 | if (!doc) { 174 | throw new ResponseError(ErrorCodes.InvalidParams, 'Document not found in cache.'); 175 | } 176 | 177 | const extendedParams: P & ExtendedParams = { 178 | ...params, 179 | document: doc, 180 | }; 181 | 182 | return await Promise.resolve(handler.on(extendedParams, token, workDoneProgress, resultProgress)); 183 | }); 184 | 185 | }); 186 | } 187 | 188 | private createDocumentManagerHandler

>( 189 | event: Event

, 190 | handler: ProviderBase

191 | ): void { 192 | event(async (params) => { 193 | 194 | return await this.callWithTelemetryAndErrorHandling(handler.constructor.name, async () => { 195 | const extendedParams: P & ExtendedParams = { 196 | ...params, 197 | textDocument: params.document.id, 198 | }; 199 | 200 | return await Promise.resolve(handler.on(extendedParams, CancellationToken.None)); 201 | }); 202 | 203 | }, this, this.subscriptions); 204 | } 205 | 206 | private async callWithTelemetryAndErrorHandling(callbackId: string, callback: () => Promise): Promise> { 207 | const actionContext: ActionContext = { 208 | clientCapabilities: this.clientParams.capabilities, 209 | connection: this.connection, 210 | telemetry: initEvent(callbackId), 211 | }; 212 | 213 | const startTime = process.hrtime.bigint(); 214 | 215 | try { 216 | return await runWithContext(actionContext, callback); 217 | } catch (error) { 218 | let responseError: ResponseError; 219 | let stack: string | undefined; 220 | 221 | if (error instanceof ResponseError) { 222 | responseError = error; 223 | stack = error.stack; 224 | } else if (error instanceof Error) { 225 | responseError = new ResponseError(ErrorCodes.UnknownErrorCode, error.message, error as unknown as E); 226 | stack = error.stack; 227 | } else { 228 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 229 | responseError = new ResponseError(ErrorCodes.InternalError, (error as any).toString ? (error as any).toString() : 'Unknown error'); 230 | } 231 | 232 | actionContext.telemetry.properties.result = 'Failed'; 233 | actionContext.telemetry.properties.error = responseError.code.toString(); 234 | actionContext.telemetry.properties.errorMessage = responseError.message; 235 | actionContext.telemetry.properties.stack = stack; 236 | 237 | return responseError; 238 | } finally { 239 | const endTime = process.hrtime.bigint(); 240 | const elapsedMicroseconds = Number((endTime - startTime) / BigInt(1000)); 241 | actionContext.telemetry.measurements.duration = elapsedMicroseconds; 242 | 243 | // The aggregator will internally handle suppressing / etc. 244 | this.telemetryAggregator.logEvent(actionContext.telemetry); 245 | } 246 | } 247 | } 248 | --------------------------------------------------------------------------------