diff --git a/src/vs/platform/actions/test/common/menuService.test.ts b/src/vs/platform/actions/test/common/menuService.test.ts index 87eb693b611..64d33dcc129 100644 --- a/src/vs/platform/actions/test/common/menuService.test.ts +++ b/src/vs/platform/actions/test/common/menuService.test.ts @@ -219,11 +219,19 @@ suite('MenuService', function () { MenuRegistry.addCommand({ id: 'b', title: 'Implicit' }); - const [first, second] = MenuRegistry.getMenuItems(MenuId.CommandPalette); - assert.equal(first.command.id, 'a'); - assert.equal(first.command.title, 'Explicit'); - - assert.equal(second.command.id, 'b'); - assert.equal(second.command.title, 'Implicit'); + let foundA = false; + let foundB = false; + for (const item of MenuRegistry.getMenuItems(MenuId.CommandPalette)) { + if (item.command.id === 'a') { + assert.equal(item.command.title, 'Explicit'); + foundA = true; + } + if (item.command.id === 'b') { + assert.equal(item.command.title, 'Implicit'); + foundB = true; + } + } + assert.equal(foundA, true); + assert.equal(foundB, true); }); }); diff --git a/src/vs/workbench/parts/snippets/electron-browser/TMSnippets.ts b/src/vs/workbench/parts/snippets/electron-browser/TMSnippets.ts deleted file mode 100644 index 65e7fd1eba1..00000000000 --- a/src/vs/workbench/parts/snippets/electron-browser/TMSnippets.ts +++ /dev/null @@ -1,199 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -'use strict'; - -import * as nls from 'vs/nls'; -import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; -import { parse } from 'vs/base/common/json'; -import { join } from 'path'; -import { TPromise } from 'vs/base/common/winjs.base'; -import { readFile } from 'vs/base/node/pfs'; -import { ExtensionMessageCollector, ExtensionsRegistry } from 'vs/platform/extensions/common/extensionsRegistry'; -import { ISnippetsService, ISnippet } from 'vs/workbench/parts/snippets/electron-browser/snippetsService'; -import { IModeService } from 'vs/editor/common/services/modeService'; -import { languagesExtPoint } from 'vs/workbench/services/mode/common/workbenchModeService'; -import { LanguageIdentifier } from 'vs/editor/common/modes'; -import { SnippetParser, Placeholder, Variable, Text } from 'vs/editor/contrib/snippet/browser/snippetParser'; -import { EditorSnippetVariableResolver } from 'vs/editor/contrib/snippet/browser/snippetVariables'; - -interface ISnippetsExtensionPoint { - language: string; - path: string; -} - -let snippetsExtensionPoint = ExtensionsRegistry.registerExtensionPoint('snippets', [languagesExtPoint], { - description: nls.localize('vscode.extension.contributes.snippets', 'Contributes snippets.'), - type: 'array', - defaultSnippets: [{ body: [{ language: '', path: '' }] }], - items: { - type: 'object', - defaultSnippets: [{ body: { language: '${1:id}', path: './snippets/${2:id}.json.' } }], - properties: { - language: { - description: nls.localize('vscode.extension.contributes.snippets-language', 'Language identifier for which this snippet is contributed to.'), - type: 'string' - }, - path: { - description: nls.localize('vscode.extension.contributes.snippets-path', 'Path of the snippets file. The path is relative to the extension folder and typically starts with \'./snippets/\'.'), - type: 'string' - } - } - } -}); - -export class MainProcessTextMateSnippet implements IWorkbenchContribution { - - constructor( - @IModeService private _modeService: IModeService, - @ISnippetsService private _snippetService: ISnippetsService - ) { - snippetsExtensionPoint.setHandler((extensions) => { - for (let i = 0; i < extensions.length; i++) { - let tmSnippets = extensions[i].value; - for (let j = 0; j < tmSnippets.length; j++) { - this._withSnippetContribution(extensions[i].description.name, extensions[i].description.extensionFolderPath, tmSnippets[j], extensions[i].collector); - } - } - }); - } - - getId() { - return 'tmSnippetExtension'; - } - - private _withSnippetContribution(extensionName: string, extensionFolderPath: string, snippet: ISnippetsExtensionPoint, collector: ExtensionMessageCollector): void { - if (!snippet.language || (typeof snippet.language !== 'string') || !this._modeService.isRegisteredMode(snippet.language)) { - collector.error(nls.localize('invalid.language', "Unknown language in `contributes.{0}.language`. Provided value: {1}", snippetsExtensionPoint.name, String(snippet.language))); - return; - } - if (!snippet.path || (typeof snippet.path !== 'string')) { - collector.error(nls.localize('invalid.path.0', "Expected string in `contributes.{0}.path`. Provided value: {1}", snippetsExtensionPoint.name, String(snippet.path))); - return; - } - let normalizedAbsolutePath = join(extensionFolderPath, snippet.path); - - if (normalizedAbsolutePath.indexOf(extensionFolderPath) !== 0) { - collector.warn(nls.localize('invalid.path.1', "Expected `contributes.{0}.path` ({1}) to be included inside extension's folder ({2}). This might make the extension non-portable.", snippetsExtensionPoint.name, normalizedAbsolutePath, extensionFolderPath)); - } - - let modeId = snippet.language; - let languageIdentifier = this._modeService.getLanguageIdentifier(modeId); - if (languageIdentifier) { - readAndRegisterSnippets(this._snippetService, languageIdentifier, normalizedAbsolutePath, extensionName, collector); - } - } -} - -export function readAndRegisterSnippets( - snippetService: ISnippetsService, languageIdentifier: LanguageIdentifier, filePath: string, - extensionName?: string, collector?: ExtensionMessageCollector -): TPromise { - - return readFile(filePath).then(fileContents => { - let snippets = parseSnippetFile(fileContents.toString(), extensionName, collector); - snippetService.registerSnippets(languageIdentifier.id, snippets, filePath); - }, err => { - if (err && err.code === 'ENOENT') { - snippetService.registerSnippets(languageIdentifier.id, [], filePath); - } else { - throw err; - } - }); -} - -function parseSnippetFile(snippetFileContent: string, extensionName?: string, collector?: ExtensionMessageCollector): ISnippet[] { - let snippetsObj = parse(snippetFileContent); - if (!snippetsObj || typeof snippetsObj !== 'object') { - return []; - } - - let topLevelProperties = Object.keys(snippetsObj); - let result: ISnippet[] = []; - - let processSnippet = (snippet: any, name: string) => { - let prefix = snippet['prefix']; - let body = snippet['body']; - - if (Array.isArray(body)) { - body = body.join('\n'); - } - - if (typeof prefix !== 'string' || typeof body !== 'string') { - return; - } - - snippet = { - name, - extensionName, - prefix, - description: snippet['description'] || name, - codeSnippet: body - }; - - const didRewrite = _rewriteBogousVariables(snippet); - if (didRewrite && collector) { - collector.warn(nls.localize( - 'badVariableUse', - "The \"{0}\"-snippet very likely confuses snippet-variables and snippet-placeholders. See https://code.visualstudio.com/docs/editor/userdefinedsnippets#_snippet-syntax for more details.", - name - )); - } - - result.push(snippet); - }; - - topLevelProperties.forEach(topLevelProperty => { - let scopeOrTemplate = snippetsObj[topLevelProperty]; - if (scopeOrTemplate['body'] && scopeOrTemplate['prefix']) { - processSnippet(scopeOrTemplate, topLevelProperty); - } else { - let snippetNames = Object.keys(scopeOrTemplate); - snippetNames.forEach(name => { - processSnippet(scopeOrTemplate[name], name); - }); - } - }); - return result; -} - -export function _rewriteBogousVariables(snippet: ISnippet): boolean { - const textmateSnippet = new SnippetParser().parse(snippet.codeSnippet, false); - - let placeholders = new Map(); - let placeholderMax = 0; - for (const placeholder of textmateSnippet.placeholders) { - placeholderMax = Math.max(placeholderMax, placeholder.index); - } - - let didChange = false; - let stack = [...textmateSnippet.children]; - - while (stack.length > 0) { - let marker = stack.shift(); - - if ( - marker instanceof Variable - && marker.children.length === 0 - && !EditorSnippetVariableResolver.VariableNames[marker.name] - ) { - // a 'variable' without a default value and not being one of our supported - // variables is automatically turing into a placeholder. This is to restore - // a bug we had before. So `${foo}` becomes `${N:foo}` - const index = placeholders.has(marker.name) ? placeholders.get(marker.name) : ++placeholderMax; - placeholders.set(marker.name, index); - - const synthetic = new Placeholder(index).appendChild(new Text(marker.name)); - textmateSnippet.replace(marker, [synthetic]); - didChange = true; - - } else { - // recurse - stack.push(...marker.children); - } - } - - snippet.codeSnippet = textmateSnippet.toTextmateString(); - return didChange; -} diff --git a/src/vs/workbench/parts/snippets/electron-browser/insertSnippet.ts b/src/vs/workbench/parts/snippets/electron-browser/insertSnippet.ts index 7443d483fb6..019265ef09e 100644 --- a/src/vs/workbench/parts/snippets/electron-browser/insertSnippet.ts +++ b/src/vs/workbench/parts/snippets/electron-browser/insertSnippet.ts @@ -12,7 +12,7 @@ import { IQuickOpenService, IPickOpenEntry } from 'vs/platform/quickOpen/common/ import { IModeService } from 'vs/editor/common/services/modeService'; import { LanguageId } from 'vs/editor/common/modes'; import { ICommandService, CommandsRegistry } from 'vs/platform/commands/common/commands'; -import { ISnippetsService, ISnippet } from 'vs/workbench/parts/snippets/electron-browser/snippetsService'; +import { ISnippetsService, ISnippet } from 'vs/workbench/parts/snippets/electron-browser/snippets.contribution'; import { SnippetController2 } from 'vs/editor/contrib/snippet/browser/snippetController2'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; @@ -75,14 +75,14 @@ class InsertSnippetAction extends EditorAction { const { lineNumber, column } = editor.getPosition(); let { snippet, name, langId } = Args.fromUser(arg); - return new TPromise((resolve, reject) => { + return new TPromise(async (resolve, reject) => { if (snippet) { return resolve({ codeSnippet: snippet, description: undefined, name: undefined, - extensionName: undefined, + source: undefined, prefix: undefined }); } @@ -105,7 +105,7 @@ class InsertSnippetAction extends EditorAction { if (name) { // take selected snippet - snippetService.visitSnippets(languageId, snippet => { + (await snippetService.getSnippets(languageId)).every(snippet => { if (snippet.name !== name) { return true; } @@ -114,14 +114,12 @@ class InsertSnippetAction extends EditorAction { }); } else { // let user pick a snippet - const picks: ISnippetPick[] = []; - snippetService.visitSnippets(languageId, snippet => { - picks.push({ + const picks: ISnippetPick[] = (await snippetService.getSnippets(languageId)).map(snippet => { + return { label: snippet.prefix, detail: snippet.description, snippet - }); - return true; + }; }); return quickOpenService.pick(picks, { matchOnDetail: true }).then(pick => resolve(pick && pick.snippet), reject); } diff --git a/src/vs/workbench/parts/snippets/electron-browser/snippets.contribution.ts b/src/vs/workbench/parts/snippets/electron-browser/snippets.contribution.ts index e2e6cb6a263..9074a2e7b1d 100644 --- a/src/vs/workbench/parts/snippets/electron-browser/snippets.contribution.ts +++ b/src/vs/workbench/parts/snippets/electron-browser/snippets.contribution.ts @@ -3,11 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ 'use strict'; - -import 'vs/workbench/parts/snippets/electron-browser/snippetsService'; -import 'vs/workbench/parts/snippets/electron-browser/insertSnippet'; -import 'vs/workbench/parts/snippets/electron-browser/tabCompletion'; - import { CommandsRegistry } from 'vs/platform/commands/common/commands'; import { fileExists, writeFile } from 'vs/base/node/pfs'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; @@ -21,10 +16,31 @@ import { Registry } from 'vs/platform/registry/common/platform'; import * as errors from 'vs/base/common/errors'; import * as JSONContributionRegistry from 'vs/platform/jsonschemas/common/jsonContributionRegistry'; import * as nls from 'vs/nls'; -import * as snippetsTracker from './snippetsTracker'; -import * as tmSnippets from './TMSnippets'; -import * as winjs from 'vs/base/common/winjs.base'; -import * as workbenchContributions from 'vs/workbench/common/contributions'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { LanguageId } from 'vs/editor/common/modes'; +import { TPromise } from 'vs/base/common/winjs.base'; + +export const ISnippetsService = createDecorator('snippetService'); + +export interface ISnippetsService { + + _serviceBrand: any; + + getSnippets(languageId: LanguageId): TPromise; + + getSnippetsSync(languageId: LanguageId): ISnippet[]; +} + + +export interface ISnippet { + readonly name: string; + readonly prefix: string; + readonly description: string; + readonly codeSnippet: string; + readonly source: string; + readonly isBogous?: boolean; + readonly isFromExtension?: boolean; +} namespace OpenSnippetsAction { @@ -37,7 +53,7 @@ namespace OpenSnippetsAction { const environmentService = accessor.get(IEnvironmentService); const windowsService = accessor.get(IWindowsService); - function openFile(filePath: string): winjs.TPromise { + function openFile(filePath: string): TPromise { return windowsService.openWindow([filePath], { forceReuseWindow: true }); } @@ -86,7 +102,7 @@ namespace OpenSnippetsAction { }); }); } - return winjs.TPromise.as(null); + return TPromise.as(null); }); }); @@ -136,11 +152,3 @@ const schema: IJSONSchema = { Registry .as(JSONContributionRegistry.Extensions.JSONContribution) .registerSchema(schemaId, schema); - -Registry - .as(workbenchContributions.Extensions.Workbench) - .registerWorkbenchContribution(snippetsTracker.SnippetsTracker); - -Registry - .as(workbenchContributions.Extensions.Workbench) - .registerWorkbenchContribution(tmSnippets.MainProcessTextMateSnippet); diff --git a/src/vs/workbench/parts/snippets/electron-browser/snippetsFile.ts b/src/vs/workbench/parts/snippets/electron-browser/snippetsFile.ts new file mode 100644 index 00000000000..44fba39fd04 --- /dev/null +++ b/src/vs/workbench/parts/snippets/electron-browser/snippetsFile.ts @@ -0,0 +1,132 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { readFile } from 'vs/base/node/pfs'; +import { parse as jsonParse } from 'vs/base/common/json'; +import { TPromise } from 'vs/base/common/winjs.base'; +import { SnippetParser, Variable, Placeholder, Text } from 'vs/editor/contrib/snippet/browser/snippetParser'; +import { EditorSnippetVariableResolver } from 'vs/editor/contrib/snippet/browser/snippetVariables'; +import { forEach } from 'vs/base/common/collections'; +import { ISnippet } from 'vs/workbench/parts/snippets/electron-browser/snippets.contribution'; + +interface JsonSerializedSnippet { + body: string; + prefix: string | string[]; + description: string; +} + +function isJsonSerilziedSnippet(thing: any): thing is JsonSerializedSnippet { + return Boolean((thing).body) && Boolean((thing).prefix); +} + +interface JsonSerializedSnippets { + [name: string]: JsonSerializedSnippet | { [name: string]: JsonSerializedSnippet }; +} + +export class SnippetFile { + + private constructor( + readonly filepath: string, + readonly data: ISnippet[] + ) { + // + } + + static fromFile(filepath: string, source: string, isFromExtension?: boolean): TPromise { + return readFile(filepath).then(value => { + const data = jsonParse(value.toString()); + const snippets: ISnippet[] = []; + if (typeof data === 'object') { + forEach(data, entry => { + const { key: name, value: scopeOrTemplate } = entry; + if (isJsonSerilziedSnippet(scopeOrTemplate)) { + SnippetFile._parseSnippet(name, scopeOrTemplate, source, isFromExtension, snippets); + } else { + forEach(scopeOrTemplate, entry => { + const { key: name, value: template } = entry; + SnippetFile._parseSnippet(name, template, source, isFromExtension, snippets); + }); + } + }); + } + return new SnippetFile(filepath, snippets); + }); + } + + private static _parseSnippet(name: string, snippet: JsonSerializedSnippet, source: string, isFromExtension: boolean, bucket: ISnippet[]): void { + + let { prefix, body, description } = snippet; + + if (Array.isArray(body)) { + body = body.join('\n'); + } + + if (typeof prefix !== 'string' || typeof body !== 'string') { + return; + } + + let rewrite = SnippetFile._rewriteBogousVariables(body); + let isBogous = false; + if (typeof rewrite === 'string') { + body = rewrite; + isBogous = true; + } + + bucket.push({ + codeSnippet: body, + name, + prefix, + description, + source, + isFromExtension, + isBogous + }); + } + + static _rewriteBogousVariables(template: string): false | string { + const textmateSnippet = new SnippetParser().parse(template, false); + + let placeholders = new Map(); + let placeholderMax = 0; + for (const placeholder of textmateSnippet.placeholders) { + placeholderMax = Math.max(placeholderMax, placeholder.index); + } + + let didChange = false; + let stack = [...textmateSnippet.children]; + + while (stack.length > 0) { + let marker = stack.shift(); + + if ( + marker instanceof Variable + && marker.children.length === 0 + && !EditorSnippetVariableResolver.VariableNames[marker.name] + ) { + // a 'variable' without a default value and not being one of our supported + // variables is automatically turned into a placeholder. This is to restore + // a bug we had before. So `${foo}` becomes `${N:foo}` + const index = placeholders.has(marker.name) ? placeholders.get(marker.name) : ++placeholderMax; + placeholders.set(marker.name, index); + + const synthetic = new Placeholder(index).appendChild(new Text(marker.name)); + textmateSnippet.replace(marker, [synthetic]); + didChange = true; + + } else { + // recurse + stack.push(...marker.children); + } + } + + if (!didChange) { + return false; + } else { + return textmateSnippet.toTextmateString(); + } + } +} diff --git a/src/vs/workbench/parts/snippets/electron-browser/snippetsService.ts b/src/vs/workbench/parts/snippets/electron-browser/snippetsService.ts index 0af3c024910..3e949059fa8 100644 --- a/src/vs/workbench/parts/snippets/electron-browser/snippetsService.ts +++ b/src/vs/workbench/parts/snippets/electron-browser/snippetsService.ts @@ -7,75 +7,226 @@ import { localize } from 'vs/nls'; import { IModel } from 'vs/editor/common/editorCommon'; import { ISuggestSupport, ISuggestResult, ISuggestion, LanguageId, SuggestionType, SnippetType } from 'vs/editor/common/modes'; -import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { setSnippetSuggestSupport } from 'vs/editor/contrib/suggest/browser/suggest'; import { IModeService } from 'vs/editor/common/services/modeService'; import { Position } from 'vs/editor/common/core/position'; import { overlap, compare, startsWith } from 'vs/base/common/strings'; import { SnippetParser } from 'vs/editor/contrib/snippet/browser/snippetParser'; +import { IEnvironmentService } from 'vs/platform/environment/common/environment'; +import { IExtensionService } from 'vs/platform/extensions/common/extensions'; +import { IDisposable, dispose } from 'vs/base/common/lifecycle'; +import { join } from 'path'; +import { mkdirp } from 'vs/base/node/pfs'; +import { watch } from 'fs'; +import { SnippetFile } from 'vs/workbench/parts/snippets/electron-browser/snippetsFile'; +import { TPromise } from 'vs/base/common/winjs.base'; +import { ISnippet, ISnippetsService } from 'vs/workbench/parts/snippets/electron-browser/snippets.contribution'; +import { IJSONSchema } from 'vs/base/common/jsonSchema'; +import { ExtensionsRegistry, IExtensionPointUser } from 'vs/platform/extensions/common/extensionsRegistry'; +import { languagesExtPoint } from 'vs/workbench/services/mode/common/workbenchModeService'; -export const ISnippetsService = createDecorator('snippetService'); +namespace schema { -export interface ISnippetsService { + export interface ISnippetsExtensionPoint { + language: string; + path: string; + } - _serviceBrand: any; + export function isValidSnippet(extension: IExtensionPointUser, snippet: ISnippetsExtensionPoint, modeService: IModeService): boolean { + if (!snippet.language || (typeof snippet.language !== 'string') || !modeService.isRegisteredMode(snippet.language)) { + extension.collector.error(localize('invalid.language', "Unknown language in `contributes.{0}.language`. Provided value: {0}", String(snippet.language))); + return false; - registerSnippets(languageId: LanguageId, snippets: ISnippet[], owner: string): void; + } else if (!snippet.path || (typeof snippet.path !== 'string')) { + extension.collector.error(localize('invalid.path.0', "Expected string in `contributes.{0}.path`. Provided value: {0}", String(snippet.path))); + return false; - visitSnippets(languageId: LanguageId, accept: (snippet: ISnippet) => boolean): void; + } else { + const normalizedAbsolutePath = join(extension.description.extensionFolderPath, snippet.path); + if (normalizedAbsolutePath.indexOf(extension.description.extensionFolderPath) !== 0) { + extension.collector.error(localize( + 'invalid.path.1', + "Expected `contributes.{0}.path` ({1}) to be included inside extension's folder ({2}). This might make the extension non-portable.", + extension.description.name, normalizedAbsolutePath, extension.description.extensionFolderPath + )); + return false; + } - getSnippets(languageId: LanguageId): ISnippet[]; + snippet.path = normalizedAbsolutePath; + return true; + } + } + + export const snippetsContribution: IJSONSchema = { + description: localize('vscode.extension.contributes.snippets', 'Contributes snippets.'), + type: 'array', + defaultSnippets: [{ body: [{ language: '', path: '' }] }], + items: { + type: 'object', + defaultSnippets: [{ body: { language: '${1:id}', path: './snippets/${2:id}.json.' } }], + properties: { + language: { + description: localize('vscode.extension.contributes.snippets-language', 'Language identifier for which this snippet is contributed to.'), + type: 'string' + }, + path: { + description: localize('vscode.extension.contributes.snippets-path', 'Path of the snippets file. The path is relative to the extension folder and typically starts with \'./snippets/\'.'), + type: 'string' + } + } + } + }; } -export interface ISnippet { - name: string; - prefix: string; - description: string; - codeSnippet: string; - extensionName?: string; -} +class SnippetsService implements ISnippetsService { -export class SnippetsService implements ISnippetsService { + readonly _serviceBrand: any; - _serviceBrand: any; - - private readonly _snippets = new Map>(); + private readonly _pendingExtensionSnippets = new Map, string][]>(); + private readonly _extensionSnippets = new Map(); + private readonly _userSnippets = new Map(); + private readonly _userSnippetsFolder: string; + private readonly _disposables: IDisposable[] = []; constructor( - @IModeService modeService: IModeService + @IModeService readonly _modeService: IModeService, + @IExtensionService readonly _extensionService: IExtensionService, + @IEnvironmentService environmentService: IEnvironmentService, ) { - setSnippetSuggestSupport(new SnippetSuggestProvider(modeService, this)); + this._userSnippetsFolder = join(environmentService.appSettingsHome, 'snippets'); + this._prepUserSnippetsWatching(); + this._prepExtensionSnippets(); + + setSnippetSuggestSupport(new SnippetSuggestProvider(this._modeService, this)); } - registerSnippets(languageId: LanguageId, snippets: ISnippet[], fileName: string): void { - if (!this._snippets.has(languageId)) { - this._snippets.set(languageId, new Map()); + dispose(): void { + dispose(this._disposables); + } + + async getSnippets(languageId: LanguageId): TPromise { + let result: ISnippet[] = []; + await TPromise.join([ + this._extensionService.onReady(), + this._getOrLoadUserSnippets(languageId, result), + this._getOrLoadExtensionSnippets(languageId, result) + ]); + return result; + } + + getSnippetsSync(languageId: LanguageId): ISnippet[] { + // just kick off snippet loading for this language such + // that subseqent calls to this method return more + // correct results + this.getSnippets(languageId).done(undefined, undefined); + + // collect and return what we already have + let userSnippets = this._userSnippets.get(languageId); + let extensionSnippets = this._extensionSnippets.get(languageId); + return (userSnippets || []).concat(extensionSnippets || []); + } + + // --- extension snippet logic --- + + private async _prepExtensionSnippets(): TPromise { + ExtensionsRegistry.registerExtensionPoint('snippets', [languagesExtPoint], schema.snippetsContribution).setHandler(async extensions => { + for (const extension of extensions) { + for (const contribution of extension.value) { + if (schema.isValidSnippet(extension, contribution, this._modeService)) { + const { id } = this._modeService.getLanguageIdentifier(contribution.language); + const array = this._pendingExtensionSnippets.get(id); + if (!array) { + this._pendingExtensionSnippets.set(id, [[extension, contribution.path]]); + } else { + array.push([extension, contribution.path]); + } + } + } + } + }); + } + + private async _getOrLoadExtensionSnippets(languageId: LanguageId, bucket: ISnippet[]): TPromise { + + if (this._extensionSnippets.has(languageId)) { + bucket.push(...this._extensionSnippets.get(languageId)); + + } else if (this._pendingExtensionSnippets.has(languageId)) { + const pending = this._pendingExtensionSnippets.get(languageId); + this._pendingExtensionSnippets.delete(languageId); + + const snippets = []; + this._extensionSnippets.set(languageId, snippets); + + for (const [extension, filepath] of pending) { + let file: SnippetFile; + try { + file = await SnippetFile.fromFile(filepath, extension.description.displayName || extension.description.name, true); + } catch (e) { + extension.collector.warn(localize( + 'badFile', + "The snippet file \"{0}\" could not be read.", + filepath + )); + } + if (file) { + for (const snippet of file.data) { + snippets.push(snippet); + bucket.push(snippet); + if (snippet.isBogous) { + extension.collector.warn(localize( + 'badVariableUse', + "The \"{0}\"-snippet very likely confuses snippet-variables and snippet-placeholders. See https://code.visualstudio.com/docs/editor/userdefinedsnippets#_snippet-syntax for more details.", + snippet.name + )); + } + } + } + } } - this._snippets.get(languageId).set(fileName, snippets); } - visitSnippets(languageId: LanguageId, accept: (snippet: ISnippet) => boolean): void { - const modeSnippets = this._snippets.get(languageId); - if (modeSnippets) { - modeSnippets.forEach(snippets => { - let result = snippets.every(accept); - if (!result) { - return; + // --- user snippet logic --- + + private async _getOrLoadUserSnippets(languageId: LanguageId, bucket: ISnippet[]): TPromise { + let snippets = this._userSnippets.get(languageId); + if (snippets === undefined) { + try { + snippets = (await SnippetFile.fromFile(this._getUserSnippetFilepath(languageId), localize('source.snippet', "User Snippet"))).data; + } catch (e) { + snippets = null; + } + this._userSnippets.set(languageId, snippets); + } + + if (snippets) { + bucket.push(...snippets); + } + } + + private _getUserSnippetFilepath(languageId: LanguageId): string { + const { language } = this._modeService.getLanguageIdentifier(languageId); + const filepath = join(this._userSnippetsFolder, `${language}.json`); + return filepath; + } + + private _prepUserSnippetsWatching(): void { + // Install a FS watcher on the snippet directory and when an + // event occurs delete any cached snippet information + mkdirp(this._userSnippetsFolder).then(() => { + const watcher = watch(this._userSnippetsFolder); + this._disposables.push({ dispose: () => watcher.close() }); + watcher.on('change', (type, filename) => { + if (typeof filename === 'string') { + const language = filename.replace(/\.json$/, '').toLowerCase(); + const languageId = this._modeService.getLanguageIdentifier(language); + if (languageId) { + this._userSnippets.delete(languageId.id); + } } }); - } - } - - getSnippets(languageId: LanguageId): ISnippet[] { - const modeSnippets = this._snippets.get(languageId); - const ret: ISnippet[] = []; - if (modeSnippets) { - modeSnippets.forEach(snippets => { - ret.push(...snippets); - }); - } - return ret; + }); } } @@ -87,8 +238,6 @@ export interface ISimpleModel { export class SnippetSuggestion implements ISuggestion { - private static _userSnippet = localize('source.snippet', "User Snippet"); - label: string; detail: string; insertText: string; @@ -99,16 +248,15 @@ export class SnippetSuggestion implements ISuggestion { type: SuggestionType; snippetType: SnippetType; - constructor( readonly snippet: ISnippet, overwriteBefore: number ) { this.label = snippet.prefix; - this.detail = localize('detail.snippet', "{0} ({1})", snippet.description, snippet.extensionName || SnippetSuggestion._userSnippet); + this.detail = localize('detail.snippet', "{0} ({1})", snippet.description, snippet.source); this.insertText = snippet.codeSnippet; this.overwriteBefore = overwriteBefore; - this.sortText = `${snippet.prefix}-${snippet.extensionName || ''}`; + this.sortText = `${snippet.isFromExtension ? 'z' : 'a'}-${snippet.prefix}`; this.noAutoAccept = true; this.type = 'snippet'; this.snippetType = 'textmate'; @@ -134,10 +282,10 @@ export class SnippetSuggestProvider implements ISuggestSupport { // } - provideCompletionItems(model: IModel, position: Position): ISuggestResult { + async provideCompletionItems(model: IModel, position: Position): TPromise { const languageId = this._getLanguageIdAtPosition(model, position); - const snippets = this._snippets.getSnippets(languageId); + const snippets = await this._snippets.getSnippets(languageId); const suggestions: SnippetSuggestion[] = []; const lowWordUntil = model.getWordUntilPosition(position).word.toLowerCase(); diff --git a/src/vs/workbench/parts/snippets/electron-browser/snippetsTracker.ts b/src/vs/workbench/parts/snippets/electron-browser/snippetsTracker.ts deleted file mode 100644 index 2ecbc3150fb..00000000000 --- a/src/vs/workbench/parts/snippets/electron-browser/snippetsTracker.ts +++ /dev/null @@ -1,68 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -'use strict'; - -import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; -import { join } from 'path'; -import { mkdirp, fileExists } from 'vs/base/node/pfs'; -import { onUnexpectedError } from 'vs/base/common/errors'; -import { IDisposable, dispose } from 'vs/base/common/lifecycle'; -import { readAndRegisterSnippets } from './TMSnippets'; -import { ISnippetsService } from 'vs/workbench/parts/snippets/electron-browser/snippetsService'; -import { IEnvironmentService } from 'vs/platform/environment/common/environment'; -import { IExtensionService } from 'vs/platform/extensions/common/extensions'; -import { watch } from 'fs'; -import { IModeService } from 'vs/editor/common/services/modeService'; - -export class SnippetsTracker implements IWorkbenchContribution { - - private readonly _snippetFolder: string; - private readonly _toDispose: IDisposable[]; - - constructor( - @IModeService modeService: IModeService, - @ISnippetsService snippetService: ISnippetsService, - @IEnvironmentService environmentService: IEnvironmentService, - @IExtensionService extensionService: IExtensionService - ) { - this._snippetFolder = join(environmentService.appSettingsHome, 'snippets'); - this._toDispose = []; - - // Whenever a mode is being created check if a snippet file exists - // and iff so read all snippets from it. - this._toDispose.push(modeService.onDidCreateMode(mode => { - const snippetPath = join(this._snippetFolder, `${mode.getId()}.json`); - fileExists(snippetPath) - .then(exists => exists && readAndRegisterSnippets(snippetService, mode.getLanguageIdentifier(), snippetPath)) - .done(undefined, onUnexpectedError); - })); - - // Install a FS watcher on the snippet directory and when an - // event occurs update the snippets for that one snippet. - mkdirp(this._snippetFolder).then(() => { - const watcher = watch(this._snippetFolder); - this._toDispose.push({ dispose: () => watcher.close() }); - watcher.on('change', (type, filename) => { - if (typeof filename !== 'string') { - return; - } - extensionService.onReady().then(() => { - const langName = filename.replace(/\.json$/, '').toLowerCase(); - const langId = modeService.getLanguageIdentifier(langName); - return langId && readAndRegisterSnippets(snippetService, langId, join(this._snippetFolder, filename)); - }, onUnexpectedError); - }); - }); - } - - getId(): string { - return 'vs.snippets.snippetsTracker'; - } - - dispose(): void { - dispose(this._toDispose); - } -} diff --git a/src/vs/workbench/parts/snippets/electron-browser/tabCompletion.ts b/src/vs/workbench/parts/snippets/electron-browser/tabCompletion.ts index 71a2e2fda5a..de866d03c5a 100644 --- a/src/vs/workbench/parts/snippets/electron-browser/tabCompletion.ts +++ b/src/vs/workbench/parts/snippets/electron-browser/tabCompletion.ts @@ -9,7 +9,8 @@ import { localize } from 'vs/nls'; import { KeyCode } from 'vs/base/common/keyCodes'; import { RawContextKey, IContextKeyService, ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { KeybindingsRegistry } from 'vs/platform/keybinding/common/keybindingsRegistry'; -import { ISnippetsService, getNonWhitespacePrefix, ISnippet, SnippetSuggestion } from 'vs/workbench/parts/snippets/electron-browser/snippetsService'; +import { ISnippetsService, ISnippet } from 'vs/workbench/parts/snippets/electron-browser/snippets.contribution'; +import { getNonWhitespacePrefix, SnippetSuggestion } from 'vs/workbench/parts/snippets/electron-browser/snippetsService'; import { Registry } from 'vs/platform/registry/common/platform'; import { endsWith } from 'vs/base/common/strings'; import { IDisposable, dispose } from 'vs/base/common/lifecycle'; @@ -33,7 +34,7 @@ export class TabCompletionController implements editorCommon.IEditorContribution private readonly _editor: editorCommon.ICommonCodeEditor; private readonly _snippetController: SnippetController2; private readonly _dispoables: IDisposable[] = []; - private readonly _snippets: ISnippet[] = []; + private _snippets: ISnippet[] = []; constructor( editor: editorCommon.ICommonCodeEditor, @@ -61,12 +62,10 @@ export class TabCompletionController implements editorCommon.IEditorContribution } if (selectFn) { - snippetService.visitSnippets(editor.getModel().getLanguageIdentifier().id, s => { - if (selectFn(s)) { - this._snippets.push(s); - } - return true; - }); + const model = editor.getModel(); + model.tokenizeIfCheap(e.selection.positionLineNumber); + const id = model.getLanguageIdAtPosition(e.selection.positionLineNumber, e.selection.positionColumn); + this._snippets = snippetService.getSnippetsSync(id).filter(selectFn); } hasSnippets.set(this._snippets.length > 0); })); diff --git a/src/vs/workbench/parts/snippets/test/electron-browser/snippetsRewrite.test.ts b/src/vs/workbench/parts/snippets/test/electron-browser/snippetsRewrite.test.ts index c25a99ad46a..033528c8ca3 100644 --- a/src/vs/workbench/parts/snippets/test/electron-browser/snippetsRewrite.test.ts +++ b/src/vs/workbench/parts/snippets/test/electron-browser/snippetsRewrite.test.ts @@ -6,20 +6,19 @@ 'use strict'; import * as assert from 'assert'; -import { _rewriteBogousVariables } from 'vs/workbench/parts/snippets/electron-browser/TMSnippets'; +import { SnippetFile } from 'vs/workbench/parts/snippets/electron-browser/snippetsFile'; suite('TMSnippets', function () { - function assertRewrite(input: string, expected: string): void { - let snippet = { codeSnippet: input, description: undefined, extensionName: undefined, name: undefined, prefix: undefined }; - _rewriteBogousVariables(snippet); - assert.equal(snippet.codeSnippet, expected); + function assertRewrite(input: string, expected: string | boolean): void { + const actual = SnippetFile._rewriteBogousVariables(input); + assert.equal(actual, expected); } test('bogous variable rewrite', function () { - assertRewrite('foo', 'foo'); - assertRewrite('hello $1 world$0', 'hello $1 world$0'); + assertRewrite('foo', false); + assertRewrite('hello $1 world$0', false); assertRewrite('$foo and $foo', '${1:foo} and ${1:foo}'); assertRewrite('$1 and $SELECTION and $foo', '$1 and ${SELECTION} and ${2:foo}'); @@ -42,6 +41,6 @@ suite('TMSnippets', function () { }); test('Snippet choices: unable to escape comma and pipe, #31521', function () { - assertRewrite('console.log(${1|not\\, not, five, 5, 1 23|});', 'console.log(${1|not\\, not, five, 5, 1 23|});'); + assertRewrite('console.log(${1|not\\, not, five, 5, 1 23|});', false); }); }); diff --git a/src/vs/workbench/parts/snippets/test/electron-browser/snippetsService.test.ts b/src/vs/workbench/parts/snippets/test/electron-browser/snippetsService.test.ts index e179467e64e..21acef02217 100644 --- a/src/vs/workbench/parts/snippets/test/electron-browser/snippetsService.test.ts +++ b/src/vs/workbench/parts/snippets/test/electron-browser/snippetsService.test.ts @@ -6,11 +6,25 @@ 'use strict'; import * as assert from 'assert'; -import { SnippetsService, ISnippet, SnippetSuggestProvider } from 'vs/workbench/parts/snippets/electron-browser/snippetsService'; +import { SnippetSuggestProvider } from 'vs/workbench/parts/snippets/electron-browser/snippetsService'; import { Position } from 'vs/editor/common/core/position'; import { ModesRegistry } from 'vs/editor/common/modes/modesRegistry'; import { ModeServiceImpl } from 'vs/editor/common/services/modeServiceImpl'; import { Model } from 'vs/editor/common/model/model'; +import { ISnippetsService, ISnippet } from 'vs/workbench/parts/snippets/electron-browser/snippets.contribution'; +import { TPromise } from 'vs/base/common/winjs.base'; + +class SimpleSnippetService implements ISnippetsService { + _serviceBrand: any; + constructor(readonly snippets: ISnippet[]) { + } + getSnippets() { + return TPromise.as(this.getSnippetsSync()); + } + getSnippetsSync(): ISnippet[] { + return this.snippets; + } +} suite('SnippetsService', function () { @@ -22,42 +36,43 @@ suite('SnippetsService', function () { }); let modeService: ModeServiceImpl; - let snippetService: SnippetsService; + let snippetService: ISnippetsService; setup(function () { modeService = new ModeServiceImpl(); - snippetService = new SnippetsService(modeService); - - snippetService.registerSnippets(modeService.getLanguageIdentifier('fooLang').id, [{ + snippetService = new SimpleSnippetService([{ prefix: 'bar', codeSnippet: 'barCodeSnippet', name: 'barTest', - description: '' + description: '', + source: '' }, { prefix: 'bazz', codeSnippet: 'bazzCodeSnippet', name: 'bazzTest', - description: '' - }], 'fooFile.json'); + description: '', + source: '' + }]); }); - test('snippet completions - simple', function () { + + test('snippet completions - simple', async function () { const provider = new SnippetSuggestProvider(modeService, snippetService); const model = Model.createFromString('', undefined, modeService.getLanguageIdentifier('fooLang')); - const result = provider.provideCompletionItems(model, new Position(1, 1)); + const result = await provider.provideCompletionItems(model, new Position(1, 1)); assert.equal(result.incomplete, undefined); assert.equal(result.suggestions.length, 2); }); - test('snippet completions - with prefix', function () { + test('snippet completions - with prefix', async function () { const provider = new SnippetSuggestProvider(modeService, snippetService); const model = Model.createFromString('bar', undefined, modeService.getLanguageIdentifier('fooLang')); - const result = provider.provideCompletionItems(model, new Position(1, 4)); + const result = await provider.provideCompletionItems(model, new Position(1, 4)); assert.equal(result.incomplete, undefined); assert.equal(result.suggestions.length, 1); @@ -65,48 +80,50 @@ suite('SnippetsService', function () { assert.equal(result.suggestions[0].insertText, 'barCodeSnippet'); }); - test('Cannot use "[{ + test('Cannot use "[{ + snippetService = new SimpleSnippetService([{ prefix: 'foo', codeSnippet: '$0', name: '', - description: '' - }], 'fooFile.json'); + description: '', + source: '' + }]); const provider = new SnippetSuggestProvider(modeService, snippetService); let model = Model.createFromString('\n\t\n>/head>', undefined, modeService.getLanguageIdentifier('fooLang')); - let result = provider.provideCompletionItems(model, new Position(1, 1)); + let result = await provider.provideCompletionItems(model, new Position(1, 1)); assert.equal(result.suggestions.length, 1); - result = provider.provideCompletionItems(model, new Position(2, 2)); + result = await provider.provideCompletionItems(model, new Position(2, 2)); assert.equal(result.suggestions.length, 1); }); }); diff --git a/src/vs/workbench/workbench.main.ts b/src/vs/workbench/workbench.main.ts index d9c253d4709..decfd691f9c 100644 --- a/src/vs/workbench/workbench.main.ts +++ b/src/vs/workbench/workbench.main.ts @@ -89,6 +89,9 @@ import 'vs/workbench/parts/codeEditor/codeEditor.contribution'; import 'vs/workbench/parts/execution/electron-browser/execution.contribution'; import 'vs/workbench/parts/snippets/electron-browser/snippets.contribution'; +import 'vs/workbench/parts/snippets/electron-browser/snippetsService'; +import 'vs/workbench/parts/snippets/electron-browser/insertSnippet'; +import 'vs/workbench/parts/snippets/electron-browser/tabCompletion'; import 'vs/workbench/parts/themes/electron-browser/themes.contribution';