Skip to content

Commit

Permalink
Switch from native snippets to snippet CompletionItems
Browse files Browse the repository at this point in the history
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 microsoft/vscode#13735
  • Loading branch information
ckant committed Jul 21, 2020
1 parent 16a9e45 commit 9f85fad
Show file tree
Hide file tree
Showing 41 changed files with 514 additions and 24 deletions.
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 1 addition & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -135,16 +135,6 @@
}
}
},
"snippets": [
{
"language": "javascript",
"path": "./snippets/out/snippets.json"
},
{
"language": "typescript",
"path": "./snippets/out/snippets.json"
}
],
"debuggers": [
{
"type": "aws-sam",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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."
}
Original file line number Diff line number Diff line change
@@ -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."
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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."
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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."
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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."
}
Original file line number Diff line number Diff line change
@@ -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."
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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."
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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."
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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."
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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."
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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)."
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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."
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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)."
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -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."
}
5 changes: 5 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
44 changes: 44 additions & 0 deletions src/snippets/activation.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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))
)
)
}
23 changes: 23 additions & 0 deletions src/snippets/commands/insertSnippet.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
recordSnippetInsert(input)
}

function recordSnippetInsert(input: InsertSnippetCommandInput) {
// TODO add telemetry
getLogger().info(`Inserted snippet: %O`, input)
}
46 changes: 46 additions & 0 deletions src/snippets/completableSnippet.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
74 changes: 74 additions & 0 deletions src/snippets/snippetCompletionItemProvider.ts
Original file line number Diff line number Diff line change
@@ -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<vscode.CompletionItem[] | vscode.CompletionList> {
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)
}
}
30 changes: 30 additions & 0 deletions src/snippets/snippetParser.ts
Original file line number Diff line number Diff line change
@@ -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<Snippet[]> {
const json: { [key: string]: Snippet } = await fs.readJson(file)
return Object.values(json)
}
Loading

0 comments on commit 9f85fad

Please sign in to comment.