diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..1ca151e --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,29 @@ +#------------------------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information. +#------------------------------------------------------------------------------------------------------------- + +FROM debian:latest +# FROM debian:8 + +# Avoid warnings by switching to noninteractive +ENV DEBIAN_FRONTEND=noninteractive + +# Configure apt and install packages +RUN apt-get update \ + && apt-get -y install --no-install-recommends apt-utils 2>&1 \ + # + # Install the Azure CLI + && apt-get install -y apt-transport-https curl gnupg2 lsb-release \ + && echo "deb [arch=amd64] https://packages.microsoft.com/repos/azure-cli/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/azure-cli.list \ + && curl -sL https://packages.microsoft.com/keys/microsoft.asc | apt-key add - 2>/dev/null \ + && apt-get update + +RUN apt-get install -y azure-cli +# RUN apt-get install -y azure-cli=2.0.67-1~jessie +# RUN apt-get install -y azure-cli=2.0.64-1~jessie +# RUN apt-get install -y azure-cli=2.0.63-1~jessie +# RUN apt-get install -y azure-cli=2.0.26-1~jessie + +# Switch back to dialog for any ad-hoc use of apt-get +ENV DEBIAN_FRONTEND=dialog diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..4a62381 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,5 @@ +// See https://aka.ms/vscode-remote/devcontainer.json for format details. +{ + "name": "Azure CLI", + "dockerFile": "Dockerfile", +} \ No newline at end of file diff --git a/.devcontainer/docker-compose.test.yml b/.devcontainer/docker-compose.test.yml new file mode 100644 index 0000000..50626d7 --- /dev/null +++ b/.devcontainer/docker-compose.test.yml @@ -0,0 +1,13 @@ +version: '3.4' +services: + vscode-azurecli-test: + build: + context: . + dockerfile: Dockerfile + working_dir: "/workspace" + volumes: + - "${PWD}:/workspace" + command: "./service/run_tests" + +# docker-compose -f .devcontainer/docker-compose.test.yml build --no-cache --pull +# docker-compose -f .devcontainer/docker-compose.test.yml run vscode-azurecli-test diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..8160068 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +// A launch configuration that compiles the extension and then opens it inside a new window +{ + "version": "0.1.0", + "configurations": [ + { + "name": "Launch Extension", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": ["--extensionDevelopmentPath=${workspaceRoot}" ], + "stopOnEntry": false, + "sourceMaps": true, + "outFiles": [ "${workspaceRoot}/out/src/**/*.js" ], + "preLaunchTask": "npm: watch" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..67f8e75 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +// Place your settings in this file to overwrite default and user settings. +{ + "files.exclude": { + "out": false // set this to true to hide the "out" folder with the compiled JS files + }, + "search.exclude": { + "out": true // set this to false to include "out" folder in search results + }, + "python.pythonPath": "~/lib/azure-cli/bin/python" +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..604e38f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,20 @@ +// See https://go.microsoft.com/fwlink/?LinkId=733558 +// for the documentation about the tasks.json format +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "watch", + "problemMatcher": "$tsc-watch", + "isBackground": true, + "presentation": { + "reveal": "never" + }, + "group": { + "kind": "build", + "isDefault": true + } + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..291192e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,111 @@ +{ + "name": "azurecli", + "version": "0.6.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "azurecli", + "version": "0.6.0", + "license": "MIT", + "dependencies": { + "elegant-spinner": "2.0.0", + "jmespath": "0.15.0", + "semver": "7.5.2" + }, + "devDependencies": { + "@types/jmespath": "0.15.0", + "@types/node": "10.14.1", + "@types/semver": "5.5.0", + "@types/vscode": "^1.75.0", + "typescript": "^5.3.3" + }, + "engines": { + "vscode": "^1.75.0" + } + }, + "node_modules/@types/jmespath": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@types/jmespath/-/jmespath-0.15.0.tgz", + "integrity": "sha1-SgFikJaoYjHIkaDi3szBX1PJKR0=", + "dev": true + }, + "node_modules/@types/node": { + "version": "10.14.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.1.tgz", + "integrity": "sha512-Rymt08vh1GaW4vYB6QP61/5m/CFLGnFZP++bJpWbiNxceNa6RBipDmb413jvtSf/R1gg5a/jQVl2jY4XVRscEA==", + "dev": true + }, + "node_modules/@types/semver": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-5.5.0.tgz", + "integrity": "sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ==", + "dev": true + }, + "node_modules/@types/vscode": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.85.0.tgz", + "integrity": "sha512-CF/RBon/GXwdfmnjZj0WTUMZN5H6YITOfBCP4iEZlOtVQXuzw6t7Le7+cR+7JzdMrnlm7Mfp49Oj2TuSXIWo3g==", + "dev": true + }, + "node_modules/elegant-spinner": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/elegant-spinner/-/elegant-spinner-2.0.0.tgz", + "integrity": "sha512-5YRYHhvhYzV/FC4AiMdeSIg3jAYGq9xFvbhZMpPlJoBsfYgrw2DSCYeXfat6tYBu45PWiyRr3+flaCPPmviPaA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/jmespath": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", + "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz", + "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } +} diff --git a/package.json b/package.json index d6ddef5..191899e 100644 --- a/package.json +++ b/package.json @@ -1,131 +1,152 @@ { - "name": "azurecli", - "displayName": "Azure CLI Tools", - "description": "Tools for developing and running commands of the Azure CLI.", - "main": "./out/src/extension", - "scripts": { - "compile": "tsc -p ./" - }, - "contributes": { - "commands": [ - { - "category": "Azure CLI", - "command": "ms-azurecli.runLineInTerminal", - "title": "%runLineInTerminal.title%" - }, - { - "category": "Azure CLI", - "command": "ms-azurecli.runLineInEditor", - "title": "%runLineInEditor.title%" - }, - { - "category": "Azure CLI", - "command": "ms-azurecli.toggleLiveQuery", - "title": "%toggleLiveQuery.title%" - }, - { - "category": "Azure CLI", - "command": "ms-azurecli.installAzureCLI", - "title": "%installAzureCLI.title%" - } - ], - "configuration": { - "properties": { - "azureCLI.lineContinuationCharacter": { - "default": "", - "description": "%azureCLI.lineContinuationCharacter.description%", - "scope": "resource", - "type": "string" - }, - "azureCLI.showResultInNewEditor": { - "default": false, - "description": "%azureCLI.showResultInNewEditor.description%", - "scope": "resource", - "type": "boolean" - } - }, - "title": "%configuration.title%", - "type": "object" - }, - "grammars": [ - { - "language": "azcli", - "path": "./syntaxes/azcli.tmLanguage.json", - "scopeName": "source.azcli" - } - ], - "keybindings": [ - { - "command": "ms-azurecli.runLineInTerminal", - "key": "ctrl+'", - "mac": "cmd+'", - "when": "editorTextFocus && editorLangId == 'azcli'" - }, - { - "command": "ms-azurecli.runLineInEditor", - "key": "ctrl+shift+'", - "mac": "cmd+shift+'", - "when": "editorTextFocus && editorLangId == 'azcli'" - }, - { - "command": "ms-azurecli.toggleLiveQuery", - "key": "ctrl+;", - "mac": "cmd+;", - "when": "editorTextFocus && editorLangId == 'azcli'" - } - ], - "languages": [ - { - "aliases": [ - "Azure CLI Scrapbook", - "azcli" - ], - "configuration": "./language-configuration.json", - "extensions": [ - ".azcli" - ], - "id": "azcli" - } - ], - "menus": { - "commandPalette": [ - { - "command": "ms-azurecli.runLineInTerminal", - "when": "editorLangId == 'azcli'" - }, - { - "command": "ms-azurecli.runLineInEditor", - "when": "editorLangId == 'azcli'" - }, - { - "command": "ms-azurecli.toggleLiveQuery", - "when": "editorLangId == 'azcli'" - } - ], - "editor/context": [ - { - "command": "ms-azurecli.runLineInTerminal", - "group": "2_run", - "when": "editorLangId == 'azcli'" - }, - { - "command": "ms-azurecli.runLineInEditor", - "group": "2_run", - "when": "editorLangId == 'azcli'" - } - ] - } - }, - "dependencies": { - "elegant-spinner": "2.0.0", - "jmespath": "0.15.0", - "semver": "7.5.2" - }, - "devDependencies": { - "@types/jmespath": "0.15.0", - "@types/node": "10.14.1", - "@types/semver": "5.5.0" - }, - "icon": "images/azure_icon.png", - "l10n": "./l10n" + "name": "azurecli", + "displayName": "Azure CLI Tools", + "description": "Tools for developing and running commands of the Azure CLI.", + "version": "0.6.0", + "icon": "images/azure_icon.png", + "publisher": "ms-vscode", + "repository": { + "type": "git", + "url": "https://github.com/Microsoft/vscode-azurecli.git" + }, + "license": "MIT", + "bugs": { + "url": "https://github.com/Microsoft/vscode-azurecli/issues" + }, + "engines": { + "vscode": "^1.75.0" + }, + "categories": [ + "Programming Languages", + "Snippets", + "Azure" + ], + "main": "./out/src/extension", + "l10n": "./l10n", + "contributes": { + "languages": [ + { + "id": "azcli", + "aliases": [ + "Azure CLI Scrapbook", + "azcli" + ], + "extensions": [ + ".azcli" + ], + "configuration": "./language-configuration.json" + } + ], + "grammars": [ + { + "language": "azcli", + "scopeName": "source.azcli", + "path": "./syntaxes/azcli.tmLanguage.json" + } + ], + "commands": [ + { + "category": "Azure CLI", + "command": "ms-azurecli.runLineInTerminal", + "title": "%runLineInTerminal.title%" + }, + { + "category": "Azure CLI", + "command": "ms-azurecli.runLineInEditor", + "title": "%runLineInEditor.title%" + }, + { + "category": "Azure CLI", + "command": "ms-azurecli.toggleLiveQuery", + "title": "%toggleLiveQuery.title%" + }, + { + "category": "Azure CLI", + "command": "ms-azurecli.installAzureCLI", + "title": "%installAzureCLI.title%" + } + ], + "keybindings": [ + { + "command": "ms-azurecli.runLineInTerminal", + "key": "ctrl+'", + "mac": "cmd+'", + "when": "editorTextFocus && editorLangId == 'azcli'" + }, + { + "command": "ms-azurecli.runLineInEditor", + "key": "ctrl+shift+'", + "mac": "cmd+shift+'", + "when": "editorTextFocus && editorLangId == 'azcli'" + }, + { + "command": "ms-azurecli.toggleLiveQuery", + "key": "ctrl+;", + "mac": "cmd+;", + "when": "editorTextFocus && editorLangId == 'azcli'" + } + ], + "configuration": { + "type": "object", + "title": "%configuration.title%", + "properties": { + "azureCLI.showResultInNewEditor": { + "type": "boolean", + "default": false, + "scope": "resource", + "description": "%azureCLI.showResultInNewEditor.description%" + }, + "azureCLI.lineContinuationCharacter": { + "type": "string", + "default": "", + "scope": "resource", + "description": "%azureCLI.lineContinuationCharacter.description%" + } + } + }, + "menus": { + "editor/context": [ + { + "command": "ms-azurecli.runLineInTerminal", + "group": "2_run", + "when": "editorLangId == 'azcli'" + }, + { + "command": "ms-azurecli.runLineInEditor", + "group": "2_run", + "when": "editorLangId == 'azcli'" + } + ], + "commandPalette": [ + { + "command": "ms-azurecli.runLineInTerminal", + "when": "editorLangId == 'azcli'" + }, + { + "command": "ms-azurecli.runLineInEditor", + "when": "editorLangId == 'azcli'" + }, + { + "command": "ms-azurecli.toggleLiveQuery", + "when": "editorLangId == 'azcli'" + } + ] + } + }, + "scripts": { + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./" + }, + "devDependencies": { + "@types/jmespath": "0.15.0", + "@types/node": "10.14.1", + "@types/semver": "5.5.0", + "@types/vscode": "^1.75.0", + "typescript": "^5.3.3" + }, + "dependencies": { + "jmespath": "0.15.0", + "semver": "7.5.2", + "elegant-spinner": "2.0.0" + } } diff --git a/service/tests/__init__.py b/service/tests/__init__.py new file mode 100644 index 0000000..34913fb --- /dev/null +++ b/service/tests/__init__.py @@ -0,0 +1,4 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- diff --git a/service/tests/test_tooling.py b/service/tests/test_tooling.py new file mode 100644 index 0000000..8ceffd1 --- /dev/null +++ b/service/tests/test_tooling.py @@ -0,0 +1,129 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +import unittest +import collections +try: + collectionsAbc = collections.abc +except AttributeError: + collectionsAbc = collections + +from azservice.tooling import GLOBAL_ARGUMENTS, initialize, load_command_table, get_help, get_current_subscription, get_configured_defaults, get_defaults, is_required, run_argument_value_completer, get_arguments + +TEST_GROUP = 'webapp' +TEST_COMMAND = 'webapp create' +TEST_ARGUMENT = 'plan' +TEST_ARGUMENT_OPTIONS = ['--plan', '-p'] +TEST_OPTIONAL_ARGUMENT = 'runtime' +TEST_GLOBAL_ARGUMENT = 'output' +TEST_GLOBAL_ARGUMENT_OPTIONS = ['--output', '-o'] +TEST_ARGUMENT_WITH_DEFAULT = 'deployment_source_branch' +TEST_ARGUMENT_WITHOUT_DEFAULT = 'deployment_source_url' +TEST_COMMAND_WITH_CHOICES = 'appservice plan create' +TEST_ARGUMENT_WITH_CHOICES = 'sku' +TEST_COMMAND_WITH_COMPLETER = 'account set' +TEST_ARGUMENT_WITH_COMPLETER = 'subscription' + + +class ToolingTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + initialize() + cls.command_table = load_command_table() + + @classmethod + def tearDownClass(cls): + cls.command_table = None + + def test_group_help(self): + help = get_help(TEST_GROUP) + self.assertIsNotNone(help) + self.assertTrue(help.get('short-summary')) + + def test_command(self): + command = self.command_table.get(TEST_COMMAND) + self.assertIsNotNone(command) + self.assertEqual(TEST_COMMAND, command.name) + + def test_command_help(self): + help = get_help(TEST_COMMAND) + self.assertIsNotNone(help) + self.assertTrue(help.get('short-summary')) + examples = help.get('examples') + self.assertNotEqual(0, len(examples)) + self.assertTrue(examples[0]['name']) + self.assertTrue(examples[0]['text']) + + def test_argument(self): + command = self.command_table.get(TEST_COMMAND) + self.assertIsNotNone(command) + argument = get_arguments(command).get(TEST_ARGUMENT) + self.assertIsNotNone(argument) + self.assertSequenceEqual(TEST_ARGUMENT_OPTIONS, argument.options_list) + + def test_argument_help(self): + command = self.command_table.get(TEST_COMMAND) + self.assertIsNotNone(command) + argument = get_arguments(command).get(TEST_ARGUMENT) + self.assertIsNotNone(argument) + self.assertTrue(argument.type.settings.get('help')) + + def test_global_argument(self): + argument = GLOBAL_ARGUMENTS.get(TEST_GLOBAL_ARGUMENT) + self.assertIsNotNone(argument) + self.assertSequenceEqual(TEST_GLOBAL_ARGUMENT_OPTIONS, argument['options']) + self.assertTrue(argument['help']) + self.assertNotEqual(0, len(argument['choices'])) + + def test_required_argument(self): + command = self.command_table.get(TEST_COMMAND) + self.assertIsNotNone(command) + self.assertTrue(is_required(get_arguments(command).get(TEST_ARGUMENT))) + self.assertFalse(is_required(get_arguments(command).get(TEST_OPTIONAL_ARGUMENT))) + + def test_is_linux_optional(self): + command = self.command_table.get('appservice plan create') + self.assertIsNotNone(command) + self.assertFalse(is_required(get_arguments(command).get('is_linux'))) + + def test_argument_defaults(self): + command = self.command_table.get(TEST_COMMAND) + self.assertIsNotNone(command) + defaults = get_defaults(get_arguments(command)) + self.assertIsNotNone(defaults) + self.assertTrue(defaults.get(TEST_ARGUMENT_WITH_DEFAULT)) + self.assertFalse(defaults.get(TEST_ARGUMENT_WITHOUT_DEFAULT)) + + def test_argument_choices(self): + command = self.command_table.get(TEST_COMMAND_WITH_CHOICES) + self.assertIsNotNone(command) + argument = get_arguments(command)[TEST_ARGUMENT_WITH_CHOICES] + self.assertIsNotNone(argument) + self.assertIsNotNone(argument.choices) + self.assertIsNone(argument.completer) + self.assertNotEqual(0, len(argument.choices)) + + def test_argument_completer(self): + command = self.command_table.get(TEST_COMMAND_WITH_COMPLETER) + self.assertIsNotNone(command) + argument = get_arguments(command)[TEST_ARGUMENT_WITH_COMPLETER] + self.assertIsNotNone(argument) + self.assertIsNone(argument.choices) + self.assertIsNotNone(argument.completer) + values = run_argument_value_completer(command, argument, {}) + self.assertTrue(isinstance(values, collectionsAbc.Sequence)) + + def test_current_subscription(self): + subscription = get_current_subscription() + self.assertTrue(subscription is None or isinstance(subscription, str)) + + def test_configured_defaults(self): + defaults = get_configured_defaults() + self.assertTrue(isinstance(defaults, dict)) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/azService.ts b/src/azService.ts new file mode 100644 index 0000000..eaf0a64 --- /dev/null +++ b/src/azService.ts @@ -0,0 +1,206 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { spawn, ChildProcess, SpawnOptions } from 'child_process'; +import { join } from 'path'; +import * as semver from 'semver'; + +import { exec, realpath, exists, readdir } from './utils'; + +const isWindows = process.platform === 'win32'; + +export type CompletionKind = 'group' | 'command' | 'argument_name' | 'argument_value' | 'snippet'; + +export interface Completion { + name: string; + kind: CompletionKind; + detail?: string; + documentation?: string; + snippet?: string; + sortText?: string; +} + +export type Arguments = Record; + +export interface CompletionQuery { + subcommand?: string; + argument?: string; + arguments?: Arguments +} + +export interface Status { + message: string; +} + +interface StatusQuery { + request: 'status'; +} + +export interface HoverText { + paragraphs: (string | { language: string; value: string })[]; +} + +export interface Command { + subcommand: string; + argument?: string; +} + +interface HoverQuery { + request: 'hover'; + command: Command; +} + +interface Message { + sequence: number; + data: T; +} + +export class AzService { + + private process: Promise | undefined; + private data = ''; + private listeners: { [sequence: number]: ((err: undefined | any, response: Message | undefined) => void); } = {}; + private nextSequenceNumber = 1; + + constructor(azNotFound: (wrongVersion: boolean) => void) { + this.getProcess() + .catch(err => { + console.log(err); + azNotFound(err === 'wrongVersion'); + }); + } + + async getCompletions(query: CompletionQuery, onCancel: (handle: () => void) => void): Promise { + try { + return this.send(query, onCancel); + } catch (err) { + console.error(err); + return []; + } + } + + async getStatus(): Promise { + return this.send({ request: 'status' }); + } + + async getHover(command: Command, onCancel: (handle: () => void) => void): Promise { + return this.send({ + request: 'hover', + command + }, onCancel); + } + + private async send(data: T, onCancel?: (handle: () => void) => void): Promise { + const process = await this.getProcess(); + return new Promise((resolve, reject) => { + if (onCancel) { + onCancel(() => reject('canceled')); + } + const sequence = this.nextSequenceNumber++; + this.listeners[sequence] = (err, response) => { + if (err) { + reject(err); + } else { + try { + resolve(response!.data); + } catch (err) { + reject(err); + } + } + }; + const request: Message = { sequence, data }; + const str = JSON.stringify(request); + process.stdin.write(str + '\n', 'utf8'); + }); + } + + private async getProcess(): Promise { + if (this.process) { + return this.process; + } + return this.process = (async () => { + const { stdout } = await exec('az --version'); + let version = ( + /azure-cli\s+\(([^)]+)\)/m.exec(stdout) + || /azure-cli\s+(\S+)/m.exec(stdout) + || [] + )[1]; + if (version) { + const r = /[^-][a-z]/ig; + if (r.exec(version)) { + version = version.substr(0, r.lastIndex - 1) + '-' + version.substr(r.lastIndex - 1); + } + } + if (version && semver.valid(version) && !semver.gte(version, '2.0.5')) { + throw 'wrongVersion'; + } + const pythonLocation = (/^Python location '([^']*)'/m.exec(stdout) || [])[1]; + const processOptions = await this.getSpawnProcessOptions(); + return this.spawn(pythonLocation, processOptions); + })().catch(err => { + this.process = undefined; + throw err; + }); + } + + private async getSpawnProcessOptions() { + if (process.platform === 'darwin') { + try { + const which = await exec('which az'); + const binPath = await realpath(which.stdout.trim()); + const cellarBasePath = '/usr/local/Cellar/azure-cli/'; + if (binPath.startsWith(cellarBasePath)) { + const installPath = binPath.substr(0, binPath.indexOf('/', cellarBasePath.length)); + const libPath = `${installPath}/libexec/lib`; + const entries = await readdir(libPath); + for (const entry of entries) { + const packagesPath = `${libPath}/${entry}/site-packages`; + if (await exists(packagesPath)) { + return { env: { 'PYTHONPATH': packagesPath } }; + } + } + } + } catch (err) { + console.error(err); + } + } + return undefined; + } + + private spawn(pythonLocation: string, processOptions?: SpawnOptions) { + const process = spawn(join(__dirname, `../../service/az-service${isWindows ? '.bat' : ''}`), [pythonLocation], processOptions); + process.stdout.setEncoding('utf8'); + process.stdout.on('data', data => { + this.data += data; + const nl = this.data.indexOf('\n'); + if (nl !== -1) { + const line = this.data.substr(0, nl); + this.data = this.data.substr(nl + 1); + const response = JSON.parse(line); + const listener = this.listeners[response.sequence]; + if (listener) { + delete this.listeners[response.sequence]; + listener(undefined, response); + } + } + }); + process.stderr.setEncoding('utf8'); + process.stderr.on('data', data => { + console.error(data); + }); + process.on('error', err => { + console.error(err); + }); + process.on('exit', (code, signal) => { + console.error(`Exit code ${code}, signal ${signal}`); + this.process = undefined; + for (const sequence in this.listeners) { + const listener = this.listeners[sequence]; + delete this.listeners[sequence]; + listener(`Python process terminated with exit code ${code}, signal ${signal}.`, undefined); + } + }); + return process; + } +} \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts new file mode 100644 index 0000000..5708bf2 --- /dev/null +++ b/src/extension.ts @@ -0,0 +1,486 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as jmespath from 'jmespath'; +import { HoverProvider, Hover, SnippetString, StatusBarAlignment, StatusBarItem, ExtensionContext, TextDocument, TextDocumentChangeEvent, Disposable, TextEditor, Selection, languages, commands, Range, ViewColumn, Position, CancellationToken, ProviderResult, CompletionItem, CompletionList, CompletionItemKind, CompletionItemProvider, window, workspace, env, Uri, WorkspaceEdit, l10n, } from 'vscode'; +import * as process from "process"; + +import { AzService, CompletionKind, Arguments, Status } from './azService'; +import { parse, findNode } from './parser'; +import { exec } from './utils'; +import * as spinner from 'elegant-spinner'; + +export function activate(context: ExtensionContext) { + const azService = new AzService(azNotFound); + context.subscriptions.push(languages.registerCompletionItemProvider('azcli', new AzCompletionItemProvider(azService), ' ')); + context.subscriptions.push(languages.registerHoverProvider('azcli', new AzHoverProvider(azService))); + const status = new StatusBarInfo(azService); + context.subscriptions.push(status); + context.subscriptions.push(new RunLineInTerminal()); + context.subscriptions.push(new RunLineInEditor(status)); + context.subscriptions.push(commands.registerCommand('ms-azurecli.installAzureCLI', installAzureCLI)); +} + +const completionKinds: Record = { + group: CompletionItemKind.Module, + command: CompletionItemKind.Function, + argument_name: CompletionItemKind.Variable, + argument_value: CompletionItemKind.EnumMember, + snippet: CompletionItemKind.Snippet +}; + +class AzCompletionItemProvider implements CompletionItemProvider { + + constructor(private azService: AzService) { + } + + provideCompletionItems(document: TextDocument, position: Position, token: CancellationToken): ProviderResult { + const line = document.lineAt(position).text; + const parsed = parse(line); + const start = parsed.subcommand[0]; + if (start && start.offset + start.length < position.character && start.text !== 'az') { + return; + } + const node = findNode(parsed, position.character - 1); + if (node && node.kind === 'comment') { + return; + } + // TODO: Use the above instead of parsing again. + const upToCursor = line.substr(0, position.character); + const rawSubcommand = (/^\s*(([^-\s][^\s]*\s+)*)/.exec(upToCursor) || [])[1]; + if (typeof rawSubcommand !== 'string') { + return Promise.resolve([]); + } + const subcommand = rawSubcommand.trim() + .split(/\s+/); + const args = this.getArguments(line); + const argument = (/\s(--?[^\s]+)\s+[^-\s]*$/.exec(upToCursor) || [])[1]; + const prefix = (/(^|\s)([^\s]*)$/.exec(upToCursor) || [])[2]; + const lead = /^-*/.exec(prefix)![0]; + return this.azService.getCompletions(subcommand[0] === 'az' ? { subcommand: subcommand.slice(1).join(' '), argument, arguments: args } : {}, token.onCancellationRequested) + .then(completions => completions.map(({ name, kind, detail, documentation, snippet, sortText }) => { + const item = new CompletionItem(name, completionKinds[kind]); + if (snippet) { + item.insertText = new SnippetString(snippet); + } else if (lead) { + item.insertText = name.substr(lead.length); + } + if (detail) { + item.detail = detail; + } + if (documentation) { + item.documentation = documentation; + } + if (sortText) { + item.sortText = sortText; + } + return item; + })); + } + + private getArguments(line: string) { + const args: Arguments = {}; + let name: string | undefined; + for (const match of allMatches(/-[^\s"']*|"[^"]*"|'[^']*'|[^\s"']+/g, line, 0)) { + if (match.startsWith('-')) { + name = match as string; + if (!(name in args)) { + args[name] = null; + } + } else { + if (name) { + args[name] = match; + } + name = undefined; + } + } + return args; + } +} + +class AzHoverProvider implements HoverProvider { + + constructor(private azService: AzService) { + } + + provideHover(document: TextDocument, position: Position, token: CancellationToken): ProviderResult { + const line = document.lineAt(position.line).text; + const command = parse(line); + const list = command.subcommand; + if (list.length && list[0].text === 'az') { + const node = findNode(command, position.character); + if (node) { + if (node.kind === 'subcommand') { + const i = list.indexOf(node); + if (i > 0) { + const subcommand = list.slice(1, i + 1) + .map(node => node.text).join(' '); + return this.azService.getHover({ subcommand }, token.onCancellationRequested) + .then(text => text && new Hover(text.paragraphs, new Range(position.line, node.offset, position.line, node.offset + node.length))); + } + } else if (node.kind === 'argument_name') { + const subcommand = command.subcommand.slice(1) + .map(node => node.text).join(' '); + return this.azService.getHover({ subcommand, argument: node.text }, token.onCancellationRequested) + .then(text => text && new Hover(text.paragraphs, new Range(position.line, node.offset, position.line, node.offset + node.length))); + } + } + } + } +} + +class RunLineInTerminal { + + private disposables: Disposable[] = []; + + constructor() { + this.disposables.push(commands.registerTextEditorCommand('ms-azurecli.runLineInTerminal', editor => this.run(editor))); + } + + private run(editor: TextEditor) { + return commands.executeCommand('workbench.action.terminal.runSelectedText'); + } + + dispose() { + this.disposables.forEach(disposable => disposable.dispose()); + } +} + +class RunLineInEditor { + + private resultDocument: TextDocument | undefined; + private parsedResult: object | undefined; + private queryEnabled = false; + private query: string | undefined; + private disposables: Disposable[] = []; + private commandRunningStatusBarItem: StatusBarItem; + private statusBarUpdateInterval!: NodeJS.Timer; + private statusBarSpinner = spinner(); + private hideStatusBarItemTimeout! : NodeJS.Timeout; + private statusBarItemText : string = ''; + // using backtick (`) as continuation character on Windows, backslash (\) on other systems + private continuationCharacter : string = process.platform === "win32" ? "`" : "\\"; + + constructor(private status: StatusBarInfo) { + this.disposables.push(commands.registerTextEditorCommand('ms-azurecli.toggleLiveQuery', editor => this.toggleQuery(editor))); + this.disposables.push(commands.registerTextEditorCommand('ms-azurecli.runLineInEditor', editor => this.run(editor))); + this.disposables.push(workspace.onDidCloseTextDocument(document => this.close(document))); + this.disposables.push(workspace.onDidChangeTextDocument(event => this.change(event))); + + this.commandRunningStatusBarItem = window.createStatusBarItem(StatusBarAlignment.Left); + this.disposables.push(this.commandRunningStatusBarItem); + } + + private runningCommandCount : number = 0; + private run(source: TextEditor) { + this.refreshContinuationCharacter(); + const command = this.getSelectedCommand(source); + if (command.length > 0) { + this.runningCommandCount += 1; + const t0 = Date.now(); + if (this.runningCommandCount === 1) { + this.statusBarItemText = l10n.t('Azure CLI: Waiting for response'); + this.statusBarUpdateInterval = setInterval(() => { + if (this.runningCommandCount === 1) { + this.commandRunningStatusBarItem.text = `${this.statusBarItemText} ${this.statusBarSpinner()}`; + } + else { + this.commandRunningStatusBarItem.text = `${this.statusBarItemText} [${this.runningCommandCount}] ${this.statusBarSpinner()}`; + } + }, 50); + } + this.commandRunningStatusBarItem.show(); + clearTimeout(this.hideStatusBarItemTimeout); + + this.parsedResult = undefined; + this.query = undefined; // TODO + return this.findResultDocument() + .then(document => window.showTextDocument(document, ViewColumn.Two, true)) + .then(target => replaceContent(target, JSON.stringify({ [l10n.t('Running command')]: command }) + '\n') + .then(() => exec(command)) + .then(({ stdout }) => stdout, ({ stdout, stderr }) => JSON.stringify({ stderr, stdout }, null, ' ')) + .then(content => replaceContent(target, content) + .then(() => this.parsedResult = JSON.parse(content)) + .then(undefined, err => {}) + ) + .then(() => this.commandFinished(t0)) + ) + .then(undefined, console.error); + } + } + + private refreshContinuationCharacter() { + // the continuation character setting can be changed after the extension is loaded + const settingsContinuationCharacter = workspace.getConfiguration('azureCLI', null).get('lineContinuationCharacter', ""); + if (settingsContinuationCharacter.length > 0) { + this.continuationCharacter = settingsContinuationCharacter; + } + else { + this.continuationCharacter = process.platform === "win32" ? "`" : "\\"; + } + } + + private getSelectedCommand(source: TextEditor) { + const commandPrefix = "az"; + + if (source.selection.isEmpty) { + var lineNumber = source.selection.active.line; + if (source.document.lineAt(lineNumber).text.length === 0) { + window.showInformationMessage(l10n.t("Please put the cursor on a line that contains a command (or part of a command).")); + return ""; + } + + // look upwards find the start of the command (if necessary) + while(!source.document.lineAt(lineNumber).text.trim().toLowerCase().startsWith(commandPrefix)) { + lineNumber--; + } + + // this will be the first (maybe only) line of the command + var command = this.stripComments(source.document.lineAt(lineNumber).text); + + while (command.trim().endsWith(this.continuationCharacter)) { + // concatenate all lines into a single command + lineNumber ++; + command = command.trim().slice(0, -1) + this.stripComments(source.document.lineAt(lineNumber).text); + } + return command; + } + else { + // execute only the selected text + const selectionStart = source.selection.start; + const selectionEnd = source.selection.end; + if (selectionStart.line === selectionEnd.line) { + // single line command + return this.stripComments(source.document.getText(new Range(selectionStart, selectionEnd))); + } + else { + // multiline command + command = this.stripComments(source.document.lineAt(selectionStart.line).text.substring(selectionStart.character)); + for (let index = selectionStart.line+1; index <= selectionEnd.line; index++) { + if (command.trim().endsWith(this.continuationCharacter)) { + command = command.trim().slice(0, -1); // remove continuation character from command + } + + var line = this.stripComments(source.document.lineAt(index).text); + + if (line.trim().toLowerCase().startsWith(commandPrefix)) { + window.showErrorMessage(l10n.t("Multiple command selection not supported")); + return ""; + } + + // append this line to the command string + if (index === selectionEnd.line) { + command = command + line.substring(0, selectionEnd.character); // only append up to the end of the selection + } + else { + command = command + line; + } + } + return command; + } + } + } + + private stripComments(text: string) { + if (text.trim().startsWith("#")) { + return this.continuationCharacter; // don't let a whole line comment terminate a sequence of command fragments + } + + var i = text.indexOf("#"); + if (i !== -1) { + // account for hash characters that are embedded in strings in the JMESPath query + while (this.isEmbeddedInString(text, i)) { + i = text.indexOf("#", i + 1); // find next # + } + return text.substring(0, i); + } + + // no comment found + return text; + } + + // true if the specified position is in a string literal (surrounded by single quotes) + private isEmbeddedInString(text: string, position: number) : boolean { + var stringStart = text.indexOf("'"); // start of string literal + if (stringStart !== -1) { + while (stringStart !== -1) { + var stringEnd = text.indexOf("'", stringStart + 1); // end of string literal + if ((stringEnd !== -1) && (stringStart < position) && (stringEnd > position)) { + return true; // the given position is embedded in a string literal + } + stringStart = text.indexOf("'", stringEnd + 1); + } + } + return false; + } + + private commandFinished(startTime: number) { + this.runningCommandCount -= 1 + this.statusBarItemText = l10n.t('Azure CLI: Executed in {0} milliseconds', Date.now() - startTime); + this.commandRunningStatusBarItem.text = this.statusBarItemText; + + if (this.runningCommandCount === 0) { + clearInterval(this.statusBarUpdateInterval); + + // hide status bar item after 10 seconds to keep status bar uncluttered + this.hideStatusBarItemTimeout = setTimeout(() => this.commandRunningStatusBarItem.hide(), 10000); + } + } + + private toggleQuery(source: TextEditor) { + this.queryEnabled = !this.queryEnabled; + this.status.liveQuery = this.queryEnabled; + this.status.update(); + this.updateResult(); + } + + private findResultDocument() { + const showResultInNewEditor = workspace.getConfiguration('azureCLI', null).get('showResultInNewEditor', false) + if (this.resultDocument && !showResultInNewEditor) { + return Promise.resolve(this.resultDocument); + } + return workspace.openTextDocument({ language: 'json' }) + .then(document => this.resultDocument = document); + } + + private close(document: TextDocument) { + if (document === this.resultDocument) { + this.resultDocument = undefined; + } + } + + private change(e: TextDocumentChangeEvent) { + if (e.document.languageId === 'azcli' && e.contentChanges.length === 1) { + const change = e.contentChanges[0]; + const range = change.range; + if (range.start.line === range.end.line) { + const line = e.document.lineAt(range.start.line).text; + const query = this.getQueryArgument(line); + if (query !== this.query) { + this.query = query; + if (this.queryEnabled) { + this.updateResult(); + } + } + } + } + } + + private updateResult() { + if (this.resultDocument && this.parsedResult) { + const resultEditor = window.visibleTextEditors.find(editor => editor.document === this.resultDocument); + if (resultEditor) { + try { + const result = this.queryEnabled && this.query ? jmespath.search(this.parsedResult, this.query) : this.parsedResult; + replaceContent(resultEditor, JSON.stringify(result, null, ' ')) + .then(undefined, console.error); + } catch (err: any) { + if (!(err && err.name === 'ParserError')) { + // console.error(err); Ignore because jmespath sometimes fails on partial queries. + } + } + } + } + } + + private getQueryArgument(line: string) { + return (/\s--query\s+("([^"]*)"|'([^']*)'|([^\s"']+))/.exec(line) as string[] || []) + .filter(group => !!group)[2]; + } + + dispose() { + this.disposables.forEach(disposable => disposable.dispose()); + } +} + +class StatusBarInfo { + + private info: StatusBarItem; + private status?: Status; + public liveQuery = false; + + private timer?: NodeJS.Timer; + private disposables: Disposable[] = []; + + constructor(private azService: AzService) { + this.disposables.push(this.info = window.createStatusBarItem(StatusBarAlignment.Left)); + this.disposables.push(window.onDidChangeActiveTextEditor(() => this.update())); + this.disposables.push({ dispose: () => this.timer && clearTimeout(this.timer) }); + this.refresh() + .catch(console.error); + } + + public async refresh() { + if (this.timer) { + clearTimeout(this.timer); + } + this.status = await this.azService.getStatus(); + this.update(); + this.timer = setTimeout(() => { + this.refresh() + .catch(console.error); + }, 5000); + } + + public update() { + const texts: string[] = []; + if (this.status && this.status.message) { + texts.push(this.status.message); + } + if (this.liveQuery) { + texts.push('Live Query'); + } + this.info.text = texts.join(', '); + const editor = window.activeTextEditor; + const show = this.info.text && editor && editor.document.languageId === 'azcli'; + this.info[show ? 'show' : 'hide'](); + } + + dispose() { + this.disposables.forEach(disposable => disposable.dispose()); + } +} + +function allMatches(regex: RegExp, string: string, group: number) { + return { + [Symbol.iterator]: function* () { + let m; + while (m = regex.exec(string)) { + yield m[group]; + } + } + } +} + +function replaceContent(editor: TextEditor, content: string) { + const document = editor.document; + const all = new Range(new Position(0, 0), document.lineAt(document.lineCount - 1).range.end); + const edit = new WorkspaceEdit(); + edit.replace(document.uri, all, content); + return workspace.applyEdit(edit) + .then(() => editor.selections = [new Selection(0, 0, 0, 0)]); +} + +async function azNotFound(wrongVersion: boolean): Promise { + const message = + wrongVersion + ? l10n.t("'az' >= 2.0.5 required, please update your installation.") + : l10n.t("'az' not found on PATH, please make sure it is installed."); + const result = await window.showInformationMessage(message, + { + title: l10n.t('Documentation'), + run: installAzureCLI + } + ); + if (result && result.run) { + result.run(); + } +} + +function installAzureCLI() { + env.openExternal(Uri.parse('https://aka.ms/GetTheAzureCLI')); +} + +export function deactivate() { +} diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..d407190 --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,81 @@ +import { never } from './utils'; + +export type TokenKind = 'subcommand' | 'argument_name' | 'argument_value' | 'comment'; + +export interface Token { + kind: TokenKind; + offset: number; + length: number; + text: string; +} + +export interface Argument { + name?: Token; + value?: Token; +} + +export interface Command { + tokens: Token[]; + subcommand: Token[]; + arguments: Argument[]; + comment?: Token; +} + +export function parse(line: string) { + const regex = /"[^"]*"|'[^']*'|\#.*|[^\s"'#]+/g; + const tokens: Token[] = []; + let subcommand = true; + let m; + while (m = regex.exec(line)) { + const text = m[0]; + const length = text.length; + const isArgument = text.startsWith('-'); + const isComment = text.startsWith('#'); + if (isArgument || isComment) { + subcommand = false; + } + tokens.push({ + kind: subcommand ? 'subcommand' : isArgument ? 'argument_name' : + isComment ? 'comment' : 'argument_value', + offset: regex.lastIndex - length, + length, + text + }); + } + + const command: Command = { + tokens, + subcommand: [], + arguments: [] + }; + const args = command.arguments; + + for (const token of tokens) { + switch (token.kind) { + case 'subcommand': + command.subcommand.push(token); + break; + case 'argument_name': + args.push({ name: token }); + break; + case 'argument_value': + if (args.length && !('value' in args[args.length - 1])) { + args[args.length - 1].value = token; + } else { + args.push({ value: token }); + } + break; + case 'comment': + command.comment = token; + break; + default: + never(token.kind); + } + } + + return command; +} + +export function findNode(command: Command, offset: number) { + return command.tokens.find(token => token.offset <= offset && token.offset + token.length > offset); +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..ede4cf2 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,50 @@ +import * as cp from 'child_process'; +import * as fs from 'fs'; + +export interface ExecResult { + error: Error | null; + stdout: string; + stderr: string; +} + +export function exec(command: string) { + return new Promise((resolve, reject) => { + cp.exec(command, (error, stdout, stderr) => { + (error ? reject : resolve)({ error, stdout, stderr }); + }); + }); +} + +export function realpath(path: string) { + return new Promise((resolve, reject) => { + fs.realpath(path, (error, resolvedPath) => { + if (error) { + reject(error); + } else { + resolve(resolvedPath); + } + }); + }); +} + +export function exists(path: string) { + return new Promise(resolve => { + fs.exists(path, resolve); + }); +} + +export function readdir(path: string) { + return new Promise((resolve, reject) => { + fs.readdir(path, (error, files) => { + if (error) { + reject(error); + } else { + resolve(files); + } + }); + }); +} + +export function never(n: never) { + throw new Error(`Should not happen: ${n}`); +}