From 9f85fadab26fad65257559f594e448080262e975 Mon Sep 17 00:00:00 2001 From: Chris Kant Date: Wed, 8 Jul 2020 22:00:36 -0700 Subject: [PATCH] Switch from native snippets to snippet CompletionItems The snippets are matched by (case-insensitive) prefix Snippets are only shown if one of the following is true: - The first word of the prefix is >= 3 chars and 3 chars of the first word have been typed (foo matches foobar). - The first word of the prefix is < 3 chars and the entire first word has been typed (e.g. s3 matches s3). Caveat: VSCode requires at least 1 snippet to be shown in order for snippets to be shown after more text is typed. Therefore, if only 1 or 2 chars are typed that match (but don't complete) the first word of the prefix, 1 snippet will always be shown. See https://github.com/microsoft/vscode/issues/13735 --- package-lock.json | 13 ++ package.json | 11 +- package.nls.json | 1 + .../{aws => }/dynamodb/batchGetItem/body.js | 0 .../dynamodb/batchGetItem/metadata.json | 2 +- .../{aws => }/dynamodb/batchWriteItem/body.js | 0 .../dynamodb/batchWriteItem/metadata.json | 2 +- .../{aws => }/dynamodb/createTable/body.js | 0 .../dynamodb/createTable/metadata.json | 2 +- .../src/{aws => }/dynamodb/deleteItem/body.js | 0 .../dynamodb/deleteItem/metadata.json | 2 +- .../{aws => }/dynamodb/deleteTable/body.js | 0 .../dynamodb/deleteTable/metadata.json | 2 +- .../{aws => }/dynamodb/describeLimits/body.js | 0 .../dynamodb/describeLimits/metadata.json | 2 +- .../{aws => }/dynamodb/describeTable/body.js | 0 .../dynamodb/describeTable/metadata.json | 2 +- .../src/{aws => }/dynamodb/getItem/body.js | 0 .../{aws => }/dynamodb/getItem/metadata.json | 2 +- .../src/{aws => }/dynamodb/listTables/body.js | 0 .../dynamodb/listTables/metadata.json | 2 +- .../src/{aws => }/dynamodb/putItem/body.js | 0 .../{aws => }/dynamodb/putItem/metadata.json | 2 +- snippets/src/{aws => }/dynamodb/query/body.js | 0 .../{aws => }/dynamodb/query/metadata.json | 2 +- snippets/src/{aws => }/dynamodb/scan/body.js | 0 .../src/{aws => }/dynamodb/scan/metadata.json | 2 +- .../src/{aws => }/dynamodb/updateItem/body.js | 0 .../dynamodb/updateItem/metadata.json | 2 +- .../{aws => }/dynamodb/updateTable/body.js | 0 .../dynamodb/updateTable/metadata.json | 2 +- src/extension.ts | 5 + src/snippets/activation.ts | 44 ++++++ src/snippets/commands/insertSnippet.ts | 23 +++ src/snippets/completableSnippet.ts | 46 ++++++ src/snippets/snippetCompletionItemProvider.ts | 74 +++++++++ src/snippets/snippetParser.ts | 30 ++++ src/snippets/snippetProvider.ts | 26 ++++ src/test/snippets/completableSnippet.test.ts | 41 +++++ .../snippetCompletionItemProvider.test.ts | 147 ++++++++++++++++++ src/test/snippets/snippetProvider.test.ts | 49 ++++++ 41 files changed, 514 insertions(+), 24 deletions(-) rename snippets/src/{aws => }/dynamodb/batchGetItem/body.js (100%) rename snippets/src/{aws => }/dynamodb/batchGetItem/metadata.json (80%) rename snippets/src/{aws => }/dynamodb/batchWriteItem/body.js (100%) rename snippets/src/{aws => }/dynamodb/batchWriteItem/metadata.json (86%) rename snippets/src/{aws => }/dynamodb/createTable/body.js (100%) rename snippets/src/{aws => }/dynamodb/createTable/metadata.json (85%) rename snippets/src/{aws => }/dynamodb/deleteItem/body.js (100%) rename snippets/src/{aws => }/dynamodb/deleteItem/metadata.json (83%) rename snippets/src/{aws => }/dynamodb/deleteTable/body.js (100%) rename snippets/src/{aws => }/dynamodb/deleteTable/metadata.json (92%) rename snippets/src/{aws => }/dynamodb/describeLimits/body.js (100%) rename snippets/src/{aws => }/dynamodb/describeLimits/metadata.json (81%) rename snippets/src/{aws => }/dynamodb/describeTable/body.js (100%) rename snippets/src/{aws => }/dynamodb/describeTable/metadata.json (80%) rename snippets/src/{aws => }/dynamodb/getItem/body.js (100%) rename snippets/src/{aws => }/dynamodb/getItem/metadata.json (86%) rename snippets/src/{aws => }/dynamodb/listTables/body.js (100%) rename snippets/src/{aws => }/dynamodb/listTables/metadata.json (83%) rename snippets/src/{aws => }/dynamodb/putItem/body.js (100%) rename snippets/src/{aws => }/dynamodb/putItem/metadata.json (93%) rename snippets/src/{aws => }/dynamodb/query/body.js (100%) rename snippets/src/{aws => }/dynamodb/query/metadata.json (85%) rename snippets/src/{aws => }/dynamodb/scan/body.js (100%) rename snippets/src/{aws => }/dynamodb/scan/metadata.json (87%) rename snippets/src/{aws => }/dynamodb/updateItem/body.js (100%) rename snippets/src/{aws => }/dynamodb/updateItem/metadata.json (90%) rename snippets/src/{aws => }/dynamodb/updateTable/body.js (100%) rename snippets/src/{aws => }/dynamodb/updateTable/metadata.json (78%) create mode 100644 src/snippets/activation.ts create mode 100644 src/snippets/commands/insertSnippet.ts create mode 100644 src/snippets/completableSnippet.ts create mode 100644 src/snippets/snippetCompletionItemProvider.ts create mode 100644 src/snippets/snippetParser.ts create mode 100644 src/snippets/snippetProvider.ts create mode 100644 src/test/snippets/completableSnippet.test.ts create mode 100644 src/test/snippets/snippetCompletionItemProvider.test.ts create mode 100644 src/test/snippets/snippetProvider.test.ts diff --git a/package-lock.json b/package-lock.json index 3ae85c1b5ee..d85d6fa009b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6394,6 +6394,14 @@ } } }, + "mnemonist": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.38.0.tgz", + "integrity": "sha512-OrqILDYOEGVFooAbGid3/P9jdjWuZONlGHVyjfZnvg65+ZQ/QM5dOms+yADY/WURd1NFhCqjf/VJGFlnJToLJQ==", + "requires": { + "obliterator": "^1.6.1" + } + }, "mocha": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/mocha/-/mocha-7.1.2.tgz", @@ -7089,6 +7097,11 @@ "isobject": "^3.0.1" } }, + "obliterator": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-1.6.1.tgz", + "integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/package.json b/package.json index 9bfb4897d0a..cd7e010cb01 100644 --- a/package.json +++ b/package.json @@ -135,16 +135,6 @@ } } }, - "snippets": [ - { - "language": "javascript", - "path": "./snippets/out/snippets.json" - }, - { - "language": "typescript", - "path": "./snippets/out/snippets.json" - } - ], "debuggers": [ { "type": "aws-sam", @@ -941,6 +931,7 @@ "js-yaml": "^3.13.1", "jsonc-parser": "^2.0.2", "lodash": "^4.17.19", + "mnemonist": "^0.38.0", "moment": "^2.24.0", "original-fs": "^1.1.0", "portfinder": "^1.0.25", diff --git a/package.nls.json b/package.nls.json index 07fac7ae04a..817e756d7ef 100644 --- a/package.nls.json +++ b/package.nls.json @@ -351,6 +351,7 @@ "AWS.samcli.initWizard.schemas.failed_to_load_resources": "Error loading schemas in registry {0}.", "AWS.samcli.initWizard.source.error.notFound": "Project created successfully, but main source code file not found: {0}", "AWS.samcli.initWizard.source.error.notInWorkspace": "Could not open file '{0}'. If this file exists on disk, try adding it to your workspace.", + "AWS.snippets.label": "{0} (Snippet)", "AWS.stepfunctions.visualisation.errors.rendering": "There was an error rendering State Machine Graph, check logs for details.", "AWS.stepfunctions.visualisation.errors.rename": "State machine visualization closed due to file renaming or closure.", "AWS.submitFeedback.title": "Submit Quick Feedback", diff --git a/snippets/src/aws/dynamodb/batchGetItem/body.js b/snippets/src/dynamodb/batchGetItem/body.js similarity index 100% rename from snippets/src/aws/dynamodb/batchGetItem/body.js rename to snippets/src/dynamodb/batchGetItem/body.js diff --git a/snippets/src/aws/dynamodb/batchGetItem/metadata.json b/snippets/src/dynamodb/batchGetItem/metadata.json similarity index 80% rename from snippets/src/aws/dynamodb/batchGetItem/metadata.json rename to snippets/src/dynamodb/batchGetItem/metadata.json index f8d9028438b..4d088112560 100644 --- a/snippets/src/aws/dynamodb/batchGetItem/metadata.json +++ b/snippets/src/dynamodb/batchGetItem/metadata.json @@ -1,4 +1,4 @@ { - "prefix": "aws.dynamodb.batchGetItem", + "prefix": "dynamodb.batchGetItem", "description": "The BatchGetItem operation returns the attributes of one or more items from one or more tables. You identify requested items by primary key." } diff --git a/snippets/src/aws/dynamodb/batchWriteItem/body.js b/snippets/src/dynamodb/batchWriteItem/body.js similarity index 100% rename from snippets/src/aws/dynamodb/batchWriteItem/body.js rename to snippets/src/dynamodb/batchWriteItem/body.js diff --git a/snippets/src/aws/dynamodb/batchWriteItem/metadata.json b/snippets/src/dynamodb/batchWriteItem/metadata.json similarity index 86% rename from snippets/src/aws/dynamodb/batchWriteItem/metadata.json rename to snippets/src/dynamodb/batchWriteItem/metadata.json index 19d477917aa..b7868b74935 100644 --- a/snippets/src/aws/dynamodb/batchWriteItem/metadata.json +++ b/snippets/src/dynamodb/batchWriteItem/metadata.json @@ -1,4 +1,4 @@ { - "prefix": "aws.dynamodb.batchWriteItem", + "prefix": "dynamodb.batchWriteItem", "description": "The BatchWriteItem operation puts or deletes multiple items in one or more tables. A single call to BatchWriteItem can write up to 16 MB of data, which can comprise as many as 25 put or delete requests. Individual items to be written can be as large as 400 KB." } diff --git a/snippets/src/aws/dynamodb/createTable/body.js b/snippets/src/dynamodb/createTable/body.js similarity index 100% rename from snippets/src/aws/dynamodb/createTable/body.js rename to snippets/src/dynamodb/createTable/body.js diff --git a/snippets/src/aws/dynamodb/createTable/metadata.json b/snippets/src/dynamodb/createTable/metadata.json similarity index 85% rename from snippets/src/aws/dynamodb/createTable/metadata.json rename to snippets/src/dynamodb/createTable/metadata.json index 3ded1c3fa7b..e824170fc09 100644 --- a/snippets/src/aws/dynamodb/createTable/metadata.json +++ b/snippets/src/dynamodb/createTable/metadata.json @@ -1,4 +1,4 @@ { - "prefix": "aws.dynamodb.createTable", + "prefix": "dynamodb.createTable", "description": "The CreateTable operation adds a new table to your account. In an AWS account, table names must be unique within each Region. That is, you can have two tables with same name if you create the tables in different Regions." } diff --git a/snippets/src/aws/dynamodb/deleteItem/body.js b/snippets/src/dynamodb/deleteItem/body.js similarity index 100% rename from snippets/src/aws/dynamodb/deleteItem/body.js rename to snippets/src/dynamodb/deleteItem/body.js diff --git a/snippets/src/aws/dynamodb/deleteItem/metadata.json b/snippets/src/dynamodb/deleteItem/metadata.json similarity index 83% rename from snippets/src/aws/dynamodb/deleteItem/metadata.json rename to snippets/src/dynamodb/deleteItem/metadata.json index 73dd99639c7..2b47f3e84d4 100644 --- a/snippets/src/aws/dynamodb/deleteItem/metadata.json +++ b/snippets/src/dynamodb/deleteItem/metadata.json @@ -1,4 +1,4 @@ { - "prefix": "aws.dynamodb.deleteItem", + "prefix": "dynamodb.deleteItem", "description": "Deletes a single item in a table by primary key. You can perform a conditional delete operation that deletes the item if it exists, or if it has an expected attribute value." } diff --git a/snippets/src/aws/dynamodb/deleteTable/body.js b/snippets/src/dynamodb/deleteTable/body.js similarity index 100% rename from snippets/src/aws/dynamodb/deleteTable/body.js rename to snippets/src/dynamodb/deleteTable/body.js diff --git a/snippets/src/aws/dynamodb/deleteTable/metadata.json b/snippets/src/dynamodb/deleteTable/metadata.json similarity index 92% rename from snippets/src/aws/dynamodb/deleteTable/metadata.json rename to snippets/src/dynamodb/deleteTable/metadata.json index fedb1197594..1dbd4fe9927 100644 --- a/snippets/src/aws/dynamodb/deleteTable/metadata.json +++ b/snippets/src/dynamodb/deleteTable/metadata.json @@ -1,4 +1,4 @@ { - "prefix": "aws.dynamodb.deleteTable", + "prefix": "dynamodb.deleteTable", "description": "The DeleteTable operation deletes a table and all of its items. After a DeleteTable request, the specified table is in the DELETING state until DynamoDB completes the deletion. If the table is in the ACTIVE state, you can delete it. If a table is in CREATING or UPDATING states, then DynamoDB returns a ResourceInUseException. If the specified table does not exist, DynamoDB returns a ResourceNotFoundException. If table is already in the DELETING state, no error is returned." } diff --git a/snippets/src/aws/dynamodb/describeLimits/body.js b/snippets/src/dynamodb/describeLimits/body.js similarity index 100% rename from snippets/src/aws/dynamodb/describeLimits/body.js rename to snippets/src/dynamodb/describeLimits/body.js diff --git a/snippets/src/aws/dynamodb/describeLimits/metadata.json b/snippets/src/dynamodb/describeLimits/metadata.json similarity index 81% rename from snippets/src/aws/dynamodb/describeLimits/metadata.json rename to snippets/src/dynamodb/describeLimits/metadata.json index 55004c4aa96..8b6737f27d6 100644 --- a/snippets/src/aws/dynamodb/describeLimits/metadata.json +++ b/snippets/src/dynamodb/describeLimits/metadata.json @@ -1,4 +1,4 @@ { - "prefix": "aws.dynamodb.describeLimits", + "prefix": "dynamodb.describeLimits", "description": "Returns the current provisioned-capacity limits for your AWS account in a Region, both for the Region as a whole and for any one DynamoDB table that you create there." } diff --git a/snippets/src/aws/dynamodb/describeTable/body.js b/snippets/src/dynamodb/describeTable/body.js similarity index 100% rename from snippets/src/aws/dynamodb/describeTable/body.js rename to snippets/src/dynamodb/describeTable/body.js diff --git a/snippets/src/aws/dynamodb/describeTable/metadata.json b/snippets/src/dynamodb/describeTable/metadata.json similarity index 80% rename from snippets/src/aws/dynamodb/describeTable/metadata.json rename to snippets/src/dynamodb/describeTable/metadata.json index 7de3a57f2b4..606609cef34 100644 --- a/snippets/src/aws/dynamodb/describeTable/metadata.json +++ b/snippets/src/dynamodb/describeTable/metadata.json @@ -1,4 +1,4 @@ { - "prefix": "aws.dynamodb.describeTable", + "prefix": "dynamodb.describeTable", "description": "Returns information about the table, including the current status of the table, when it was created, the primary key schema, and any indexes on the table." } diff --git a/snippets/src/aws/dynamodb/getItem/body.js b/snippets/src/dynamodb/getItem/body.js similarity index 100% rename from snippets/src/aws/dynamodb/getItem/body.js rename to snippets/src/dynamodb/getItem/body.js diff --git a/snippets/src/aws/dynamodb/getItem/metadata.json b/snippets/src/dynamodb/getItem/metadata.json similarity index 86% rename from snippets/src/aws/dynamodb/getItem/metadata.json rename to snippets/src/dynamodb/getItem/metadata.json index 5962bb54d5c..6d6305d36a8 100644 --- a/snippets/src/aws/dynamodb/getItem/metadata.json +++ b/snippets/src/dynamodb/getItem/metadata.json @@ -1,4 +1,4 @@ { - "prefix": "aws.dynamodb.getItem", + "prefix": "dynamodb.getItem", "description": "The GetItem operation returns a set of attributes for the item with the given primary key. If there is no matching item, GetItem does not return any data and there will be no Item element in the response." } diff --git a/snippets/src/aws/dynamodb/listTables/body.js b/snippets/src/dynamodb/listTables/body.js similarity index 100% rename from snippets/src/aws/dynamodb/listTables/body.js rename to snippets/src/dynamodb/listTables/body.js diff --git a/snippets/src/aws/dynamodb/listTables/metadata.json b/snippets/src/dynamodb/listTables/metadata.json similarity index 83% rename from snippets/src/aws/dynamodb/listTables/metadata.json rename to snippets/src/dynamodb/listTables/metadata.json index 2ad8a0b859d..b3b2f978be4 100644 --- a/snippets/src/aws/dynamodb/listTables/metadata.json +++ b/snippets/src/dynamodb/listTables/metadata.json @@ -1,4 +1,4 @@ { - "prefix": "aws.dynamodb.listTables", + "prefix": "dynamodb.listTables", "description": "Returns an array of table names associated with the current account and endpoint. The output from ListTables is paginated, with each page returning a maximum of 100 table names." } diff --git a/snippets/src/aws/dynamodb/putItem/body.js b/snippets/src/dynamodb/putItem/body.js similarity index 100% rename from snippets/src/aws/dynamodb/putItem/body.js rename to snippets/src/dynamodb/putItem/body.js diff --git a/snippets/src/aws/dynamodb/putItem/metadata.json b/snippets/src/dynamodb/putItem/metadata.json similarity index 93% rename from snippets/src/aws/dynamodb/putItem/metadata.json rename to snippets/src/dynamodb/putItem/metadata.json index 933c828477e..d8edbc08254 100644 --- a/snippets/src/aws/dynamodb/putItem/metadata.json +++ b/snippets/src/dynamodb/putItem/metadata.json @@ -1,4 +1,4 @@ { - "prefix": "aws.dynamodb.putItem", + "prefix": "dynamodb.putItem", "description": "Creates a new item, or replaces an old item with a new item. If an item that has the same primary key as the new item already exists in the specified table, the new item completely replaces the existing item. You can perform a conditional put operation (add a new item if one with the specified primary key doesn't exist), or replace an existing item if it has certain attribute values. You can return the item's attribute values in the same operation, using the ReturnValues parameter." } diff --git a/snippets/src/aws/dynamodb/query/body.js b/snippets/src/dynamodb/query/body.js similarity index 100% rename from snippets/src/aws/dynamodb/query/body.js rename to snippets/src/dynamodb/query/body.js diff --git a/snippets/src/aws/dynamodb/query/metadata.json b/snippets/src/dynamodb/query/metadata.json similarity index 85% rename from snippets/src/aws/dynamodb/query/metadata.json rename to snippets/src/dynamodb/query/metadata.json index 3bc45e8fb9f..4aa0c70ff6d 100644 --- a/snippets/src/aws/dynamodb/query/metadata.json +++ b/snippets/src/dynamodb/query/metadata.json @@ -1,4 +1,4 @@ { - "prefix": "aws.dynamodb.query", + "prefix": "dynamodb.query", "description": "The Query operation finds items based on primary key values. You can query any table or secondary index that has a composite primary key (a partition key and a sort key)." } diff --git a/snippets/src/aws/dynamodb/scan/body.js b/snippets/src/dynamodb/scan/body.js similarity index 100% rename from snippets/src/aws/dynamodb/scan/body.js rename to snippets/src/dynamodb/scan/body.js diff --git a/snippets/src/aws/dynamodb/scan/metadata.json b/snippets/src/dynamodb/scan/metadata.json similarity index 87% rename from snippets/src/aws/dynamodb/scan/metadata.json rename to snippets/src/dynamodb/scan/metadata.json index ab6edcd0e3f..a7fab51d0b3 100644 --- a/snippets/src/aws/dynamodb/scan/metadata.json +++ b/snippets/src/dynamodb/scan/metadata.json @@ -1,4 +1,4 @@ { - "prefix": "aws.dynamodb.scan", + "prefix": "dynamodb.scan", "description": "The Scan operation returns one or more items and item attributes by accessing every item in a table or a secondary index. To have DynamoDB return fewer items, you can provide a FilterExpression operation." } diff --git a/snippets/src/aws/dynamodb/updateItem/body.js b/snippets/src/dynamodb/updateItem/body.js similarity index 100% rename from snippets/src/aws/dynamodb/updateItem/body.js rename to snippets/src/dynamodb/updateItem/body.js diff --git a/snippets/src/aws/dynamodb/updateItem/metadata.json b/snippets/src/dynamodb/updateItem/metadata.json similarity index 90% rename from snippets/src/aws/dynamodb/updateItem/metadata.json rename to snippets/src/dynamodb/updateItem/metadata.json index 4db00591181..c735ffabcef 100644 --- a/snippets/src/aws/dynamodb/updateItem/metadata.json +++ b/snippets/src/dynamodb/updateItem/metadata.json @@ -1,4 +1,4 @@ { - "prefix": "aws.dynamodb.updateItem", + "prefix": "dynamodb.updateItem", "description": "Edits an existing item's attributes, or adds a new item to the table if it does not already exist. You can put, delete, or add attribute values. You can also perform a conditional update on an existing item (insert a new attribute name-value pair if it doesn't exist, or replace an existing name-value pair if it has certain expected attribute values)." } diff --git a/snippets/src/aws/dynamodb/updateTable/body.js b/snippets/src/dynamodb/updateTable/body.js similarity index 100% rename from snippets/src/aws/dynamodb/updateTable/body.js rename to snippets/src/dynamodb/updateTable/body.js diff --git a/snippets/src/aws/dynamodb/updateTable/metadata.json b/snippets/src/dynamodb/updateTable/metadata.json similarity index 78% rename from snippets/src/aws/dynamodb/updateTable/metadata.json rename to snippets/src/dynamodb/updateTable/metadata.json index edf64c70cd7..42155fcf5e5 100644 --- a/snippets/src/aws/dynamodb/updateTable/metadata.json +++ b/snippets/src/dynamodb/updateTable/metadata.json @@ -1,4 +1,4 @@ { - "prefix": "aws.dynamodb.updateTable", + "prefix": "dynamodb.updateTable", "description": "Modifies the provisioned throughput settings, global secondary indexes, or DynamoDB Streams settings for a given table." } diff --git a/src/extension.ts b/src/extension.ts index d1a48ae5161..bd6178e7e5a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -9,6 +9,7 @@ import * as nls from 'vscode-nls' import { activate as activateAwsExplorer } from './awsexplorer/activation' import { activate as activateCdk } from './cdk/activation' +import { activate as activateSnippets } from './snippets/activation' import { initialize as initializeCredentials, loginWithMostRecentCredentials } from './credentials/activation' import { initializeAwsCredentialsStatusBarItem } from './credentials/awsCredentialsStatusBarItem' import { LoginManager } from './credentials/loginManager' @@ -194,6 +195,10 @@ export async function activate(context: vscode.ExtensionContext) { await activateStepFunctions(context, awsContext, toolkitOutputChannel) }) + setImmediate(async () => { + await activateSnippets(context) + }) + showWelcomeMessage(context) await loginWithMostRecentCredentials(toolkitSettings, loginManager) diff --git a/src/snippets/activation.ts b/src/snippets/activation.ts new file mode 100644 index 00000000000..c569256713b --- /dev/null +++ b/src/snippets/activation.ts @@ -0,0 +1,44 @@ +/*! + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'path' +import * as vscode from 'vscode' +import { insertSnippetCommand, InsertSnippetCommandInput } from './commands/insertSnippet' +import { CompletableSnippet } from './completableSnippet' +import { SnippetCompletionItemProvider } from './snippetCompletionItemProvider' +import { parseSnippetsJson } from './snippetParser' +import { SnippetProvider } from './snippetProvider' + +/** + * Activates snippet code completion items. + * + * Parses and serves snippets in the compiled JSON snippet file(s). + * + * @param extensionContext VS Code extension context. + */ +export async function activate(extensionContext: vscode.ExtensionContext): Promise { + // For now, all snippets are in a single file + // The JS snippets and TS snippets are currently identical + const snippets = await parseSnippetsJson( + path.join(extensionContext.extensionPath, 'snippets', 'out', 'snippets.json') + ) + const javascriptSnippets = snippets.map(snippet => new CompletableSnippet(snippet, 'javascript')) + const typescriptSnippets = snippets.map(snippet => new CompletableSnippet(snippet, 'typescript')) + + extensionContext.subscriptions.push( + vscode.commands.registerCommand( + 'snippet.insert', + async (input: InsertSnippetCommandInput) => await insertSnippetCommand(input) + ), + vscode.languages.registerCompletionItemProvider( + { language: 'javascript' }, + new SnippetCompletionItemProvider(new SnippetProvider(javascriptSnippets)) + ), + vscode.languages.registerCompletionItemProvider( + { language: 'typescript' }, + new SnippetCompletionItemProvider(new SnippetProvider(typescriptSnippets)) + ) + ) +} diff --git a/src/snippets/commands/insertSnippet.ts b/src/snippets/commands/insertSnippet.ts new file mode 100644 index 00000000000..d6c214ef299 --- /dev/null +++ b/src/snippets/commands/insertSnippet.ts @@ -0,0 +1,23 @@ +/*! + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getLogger } from '../../shared/logger' + +export interface InsertSnippetCommandInput { + snippetPrefix: string + snippetLanguage: string +} + +/** + * Records telemetry after a snippet is inserted. + */ +export async function insertSnippetCommand(input: InsertSnippetCommandInput): Promise { + recordSnippetInsert(input) +} + +function recordSnippetInsert(input: InsertSnippetCommandInput) { + // TODO add telemetry + getLogger().info(`Inserted snippet: %O`, input) +} diff --git a/src/snippets/completableSnippet.ts b/src/snippets/completableSnippet.ts new file mode 100644 index 00000000000..a34cf42d1d0 --- /dev/null +++ b/src/snippets/completableSnippet.ts @@ -0,0 +1,46 @@ +/*! + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { InsertSnippetCommandInput } from './commands/insertSnippet' +import { Snippet } from './snippetParser' +import { localize } from '../shared/utilities/vsCodeUtils' + +const PREFIX_WORD_SEPARATOR = '.' // e.g. 'dynamodb.getItem' has words 'dynamodb' and 'getItem' + +/** + * Represents a precomputed snippet {@link CompletionItem} along with metadata for indexing. + */ +export class CompletableSnippet { + public readonly item: vscode.CompletionItem + public readonly prefixLower: string + public readonly firstWordLower: string + + public constructor(snippet: Snippet, language: string) { + this.item = this.createCompletionItem(snippet, language) + this.prefixLower = snippet.prefix.toLocaleLowerCase() + this.firstWordLower = this.prefixLower.split(PREFIX_WORD_SEPARATOR)[0] + } + + private createCompletionItem(snippet: Snippet, language: string): vscode.CompletionItem { + const label = localize('AWS.snippets.label', '{0} (Snippet)', snippet.prefix) + const item = new vscode.CompletionItem(label, vscode.CompletionItemKind.Snippet) + item.detail = snippet.description + item.sortText = `z-${snippet.prefix}` + item.filterText = snippet.prefix + + const code = snippet.body.join('\n') + item.documentation = new vscode.MarkdownString().appendCodeblock(code) + item.insertText = new vscode.SnippetString(code) + + const commandInput: InsertSnippetCommandInput = { snippetPrefix: snippet.prefix, snippetLanguage: language } + item.command = { + title: 'Insert Snippet', + command: 'snippet.insert', + arguments: [commandInput], + } + return item + } +} diff --git a/src/snippets/snippetCompletionItemProvider.ts b/src/snippets/snippetCompletionItemProvider.ts new file mode 100644 index 00000000000..55e1cfcb0b7 --- /dev/null +++ b/src/snippets/snippetCompletionItemProvider.ts @@ -0,0 +1,74 @@ +/*! + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as _ from 'lodash' +import * as vscode from 'vscode' +import { SnippetProvider } from './snippetProvider' + +const MIN_MATCH_LENGTH = 3 + +/** + * Provides AWS code snippets as {@link module:vscode.CompletionItem}s. + * + * The snippets are matched by (case-insensitive) prefix against items in the given {@link SnippetProvider}. + * Snippets are only shown if one of the following is true: + * - The first word of the prefix is >= 3 chars and 3 chars of the first word have been typed (foo matches foobar). + * - The first word of the prefix is < 3 chars and the entire first word has been typed (e.g. s3 matches s3). + * + * Caveat: VSCode requires at least 1 snippet to be shown in order for snippets to be shown after more text is typed. + * Therefore, if only 1 or 2 chars are typed that match (but don't complete) the first word of the prefix, + * 1 snippet will always be shown. + * @see https://github.com/microsoft/vscode/issues/13735 + */ +export class SnippetCompletionItemProvider implements vscode.CompletionItemProvider { + public constructor(private readonly snippetProvider: SnippetProvider) {} + + public async provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + _token: vscode.CancellationToken, + context: vscode.CompletionContext + ): Promise { + if (context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter && context.triggerCharacter === ' ') { + return [] + } + + const typedText = this.typedTextBeforePosition(document, position) + if (!typedText) { + return [] + } + + return this.findMatchingSnippets(typedText) + } + + private typedTextBeforePosition(document: vscode.TextDocument, position: vscode.Position): string { + const lineText = document.lineAt(position.line).text + const lastSpacePosition = lineText.lastIndexOf(' ') + const afterSpacesPosition = lastSpacePosition < 0 ? 0 : lastSpacePosition + 1 + + return lineText.substring(afterSpacesPosition, position.character) + } + + private findMatchingSnippets(typedText: string): vscode.CompletionItem[] | vscode.CompletionList { + const typedTextLower = typedText.toLocaleLowerCase() + const snippetCandidates = this.snippetProvider.findByPrefix(typedTextLower) + if (_.isEmpty(snippetCandidates)) { + return [] + } + + const isSufficientTextTyped = typedTextLower.length >= MIN_MATCH_LENGTH + const matchingSnippets = isSufficientTextTyped + ? snippetCandidates + : snippetCandidates.filter(snippet => typedTextLower.length === snippet.firstWordLower.length) + + // VSCode won't call back again if empty array is returned, so always return at least 1 (potential) future match + // See https://github.com/microsoft/vscode/issues/13735 + if (_.isEmpty(matchingSnippets)) { + return new vscode.CompletionList([snippetCandidates[0].item], true) + } + + return matchingSnippets.map(item => item.item) + } +} diff --git a/src/snippets/snippetParser.ts b/src/snippets/snippetParser.ts new file mode 100644 index 00000000000..7dd53536414 --- /dev/null +++ b/src/snippets/snippetParser.ts @@ -0,0 +1,30 @@ +/*! + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs-extra' + +/** + * The format for user-contributed and extension-contributed snippets in VSCode. + * + * The snippets are objects in JSON files. + * + * @property prefix trigger word that displays the snippet in IntelliSense. + * @property description a description of the snippet displayed by IntelliSense. + * @property body one or more lines of content, which will be joined as multiple lines upon insertion. + * Newlines and embedded tabs will be formatted according to the context in which the snippet is inserted. + * + * @see https://code.visualstudio.com/docs/editor/userdefinedsnippets + * @see https://code.visualstudio.com/api/language-extensions/snippet-guide + */ +export interface Snippet { + prefix: string + description: string + body: string[] +} + +export async function parseSnippetsJson(file: string): Promise { + const json: { [key: string]: Snippet } = await fs.readJson(file) + return Object.values(json) +} diff --git a/src/snippets/snippetProvider.ts b/src/snippets/snippetProvider.ts new file mode 100644 index 00000000000..653e3f8e5e9 --- /dev/null +++ b/src/snippets/snippetProvider.ts @@ -0,0 +1,26 @@ +/*! + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TrieMap } from 'mnemonist' +import { CompletableSnippet } from './completableSnippet' + +/** + * An index for fast lookup of {@link CompletableSnippet}s based on their prefixes. + */ +export class SnippetProvider { + private readonly snippetTrieMap: TrieMap + + public constructor(snippets: CompletableSnippet[]) { + this.snippetTrieMap = new TrieMap() + snippets.forEach(snippet => this.snippetTrieMap.set(snippet.prefixLower, snippet)) + } + + /** + * Returns {@link CompletableSnippet}s whose {@link #prefixLower} begin with the given {@param prefixLower}. + */ + public findByPrefix(prefixLower: string): CompletableSnippet[] { + return this.snippetTrieMap.find(prefixLower).map(match => match[1]) + } +} diff --git a/src/test/snippets/completableSnippet.test.ts b/src/test/snippets/completableSnippet.test.ts new file mode 100644 index 00000000000..4324417c34a --- /dev/null +++ b/src/test/snippets/completableSnippet.test.ts @@ -0,0 +1,41 @@ +/*! + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as vscode from 'vscode' +import { InsertSnippetCommandInput } from '../../snippets/commands/insertSnippet' +import { CompletableSnippet } from '../../snippets/completableSnippet' + +describe('CompletableSnippet', () => { + it('constructs a snippet', () => { + const { item, prefixLower, firstWordLower } = new CompletableSnippet( + { + prefix: 'PreFix.for.Snippet', + description: 'description', + body: ['some', 'code'], + }, + 'javascript' + ) + + assert.strictEqual(item.label, 'PreFix.for.Snippet (Snippet)') + assert.strictEqual(item.kind, vscode.CompletionItemKind.Snippet) + assert.strictEqual((item.documentation as vscode.MarkdownString).value, '\n```\nsome\ncode\n```\n') + assert.strictEqual((item.insertText as vscode.SnippetString).value, 'some\ncode') + assert.strictEqual(item.detail, 'description') + assert.strictEqual(item.sortText, 'z-PreFix.for.Snippet') + assert.strictEqual(item.filterText, 'PreFix.for.Snippet') + + const expectedCommandInput: InsertSnippetCommandInput = { + snippetPrefix: 'PreFix.for.Snippet', + snippetLanguage: 'javascript', + } + assert.strictEqual(item.command?.title, 'Insert Snippet') + assert.strictEqual(item.command?.command, 'snippet.insert') + assert.deepStrictEqual(item.command?.arguments, [expectedCommandInput]) + + assert.strictEqual(prefixLower, 'prefix.for.snippet') + assert.strictEqual(firstWordLower, 'prefix') + }) +}) diff --git a/src/test/snippets/snippetCompletionItemProvider.test.ts b/src/test/snippets/snippetCompletionItemProvider.test.ts new file mode 100644 index 00000000000..6a635a3ef74 --- /dev/null +++ b/src/test/snippets/snippetCompletionItemProvider.test.ts @@ -0,0 +1,147 @@ +/*! + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as vscode from 'vscode' +import * as _ from 'lodash' +import { CompletableSnippet } from '../../snippets/completableSnippet' +import { SnippetCompletionItemProvider } from '../../snippets/snippetCompletionItemProvider' +import { SnippetProvider } from '../../snippets/snippetProvider' +import { instance, mock, when } from '../utilities/mockito' + +describe('SnippetCompletionItemProvider', () => { + const lineNumber = 42 + const cancellationToken = {} as vscode.CancellationToken + const completionContext: vscode.CompletionContext = { triggerKind: vscode.CompletionTriggerKind.Invoke } + + const dynamodb = { + getItem: snippet('dynamodb.getItem'), + putItem: snippet('dynamodb.putItem'), + } + + const s3 = { + getObject: snippet('s3.getObject'), + putObject: snippet('s3.putObject'), + } + + let mockDocument: vscode.TextDocument + + beforeEach(() => { + mockDocument = mock() + }) + + describe('provideCompletionItems', () => { + it('returns all items when text matches normal prefix', async () => { + when(mockDocument.lineAt(lineNumber)).thenReturn(textLine('DyNamO')) + + const snippets = (await snippetProvider(dynamodb.getItem, dynamodb.putItem).provideCompletionItems( + instance(mockDocument), + cursorPosition(6), + cancellationToken, + completionContext + )) as vscode.CompletionItem[] + + assert.deepStrictEqual( + _.sortBy(snippets, snippet => snippet.label), + [dynamodb.getItem.item, dynamodb.putItem.item] + ) + }) + + it('returns all items, disregarding text before last space', async () => { + when(mockDocument.lineAt(lineNumber)).thenReturn(textLine('S3 DyNamO')) + + const snippets = await snippetProvider(dynamodb.getItem).provideCompletionItems( + instance(mockDocument), + cursorPosition(9), + cancellationToken, + completionContext + ) + + assert.deepStrictEqual(snippets, [dynamodb.getItem.item]) + }) + + it('returns all items when text matches short prefix', async () => { + when(mockDocument.lineAt(lineNumber)).thenReturn(textLine('S3')) + + const snippets = (await snippetProvider(s3.getObject, s3.putObject).provideCompletionItems( + instance(mockDocument), + cursorPosition(2), + cancellationToken, + completionContext + )) as vscode.CompletionItem[] + + assert.deepStrictEqual( + _.sortBy(snippets, snippet => snippet.label), + [s3.getObject.item, s3.putObject.item] + ) + }) + + it('returns single item in incomplete list when text is too short to match prefix', async () => { + when(mockDocument.lineAt(lineNumber)).thenReturn(textLine('dy')) + + const snippets = (await snippetProvider(dynamodb.getItem, dynamodb.putItem).provideCompletionItems( + instance(mockDocument), + cursorPosition(2), + cancellationToken, + completionContext + )) as vscode.CompletionList + + const [onlySnippet, ...otherSnippets] = snippets.items + assert.ok(onlySnippet === dynamodb.getItem.item || onlySnippet === dynamodb.putItem.item) + assert.deepStrictEqual(otherSnippets, []) + + assert.strictEqual(snippets.isIncomplete, true) + }) + + it('returns no items when triggered by a space', async () => { + when(mockDocument.lineAt(lineNumber)).thenReturn(textLine('dynamo')) + + const snippets = await snippetProvider(dynamodb.getItem).provideCompletionItems( + instance(mockDocument), + cursorPosition(6), + cancellationToken, + { triggerKind: vscode.CompletionTriggerKind.TriggerCharacter, triggerCharacter: ' ' } + ) + + assert.deepStrictEqual(snippets, []) + }) + + it('returns no items when cursor is placed before text', async () => { + when(mockDocument.lineAt(lineNumber)).thenReturn(textLine('dynamo')) + + const snippets = await snippetProvider(dynamodb.getItem).provideCompletionItems( + instance(mockDocument), + cursorPosition(0), + cancellationToken, + completionContext + ) + + assert.deepStrictEqual(snippets, []) + }) + }) + + function snippet(prefix: string): CompletableSnippet { + return new CompletableSnippet( + { + prefix, + description: 'description', + body: ['body'], + }, + 'language' + ) + } + + function textLine(text: string): vscode.TextLine { + return { text } as vscode.TextLine + } + + function cursorPosition(character: number): vscode.Position { + return new vscode.Position(lineNumber, character) + } + + function snippetProvider(...snippets: CompletableSnippet[]): SnippetCompletionItemProvider { + return new SnippetCompletionItemProvider(new SnippetProvider(snippets)) + } +}) diff --git a/src/test/snippets/snippetProvider.test.ts b/src/test/snippets/snippetProvider.test.ts new file mode 100644 index 00000000000..b78c660fb2a --- /dev/null +++ b/src/test/snippets/snippetProvider.test.ts @@ -0,0 +1,49 @@ +/*! + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as _ from 'lodash' +import { CompletableSnippet } from '../../snippets/completableSnippet' +import { SnippetProvider } from '../../snippets/snippetProvider' + +describe('SnippetProvider', () => { + describe('findByPrefix', () => { + const firstSnippet = snippetWithPrefix('foo.bar') + const secondSnippet = snippetWithPrefix('foobar') + const firstUnmatchedSnippet = snippetWithPrefix('foxbar') + const secondUnmatchedSnippet = snippetWithPrefix('barfoo') + + const snippetProvider = new SnippetProvider([ + firstSnippet, + firstUnmatchedSnippet, + secondSnippet, + secondUnmatchedSnippet, + ]) + + it('returns snippets that match prefix', () => { + const snippets = snippetProvider.findByPrefix('foo') + assert.deepStrictEqual( + _.sortBy(snippets, snippet => snippet.prefixLower), + [firstSnippet, secondSnippet] + ) + }) + + it('returns empty array when no snippets match prefix', () => { + const snippets = snippetProvider.findByPrefix('food') + assert.deepStrictEqual(snippets, []) + }) + }) +}) + +function snippetWithPrefix(prefix: string): CompletableSnippet { + return new CompletableSnippet( + { + prefix, + description: 'description', + body: ['body'], + }, + 'language' + ) +}