snippetsService.ts 13.6 KB
Newer Older
1 2 3 4 5 6
/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/
'use strict';

J
Johannes Rieken 已提交
7
import { localize } from 'vs/nls';
A
Alex Dima 已提交
8
import { IModel } from 'vs/editor/common/editorCommon';
J
Johannes Rieken 已提交
9
import { ISuggestSupport, ISuggestResult, ISuggestion, LanguageId, SuggestionType, SnippetType } from 'vs/editor/common/modes';
10
import { registerSingleton } from 'vs/platform/instantiation/common/extensions';
11
import { setSnippetSuggestSupport } from 'vs/editor/contrib/suggest/browser/suggest';
12
import { IModeService } from 'vs/editor/common/services/modeService';
13
import { Position } from 'vs/editor/common/core/position';
J
Johannes Rieken 已提交
14
import { overlap, compare, startsWith } from 'vs/base/common/strings';
J
Johannes Rieken 已提交
15
import { SnippetParser } from 'vs/editor/contrib/snippet/browser/snippetParser';
16 17 18 19 20 21 22 23
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';
J
Johannes Rieken 已提交
24
import { Snippet, ISnippetsService } from 'vs/workbench/parts/snippets/electron-browser/snippets.contribution';
25 26 27
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';
28
import { MarkdownString } from 'vs/base/common/htmlContent';
29 30 31 32 33 34 35

namespace schema {

	export interface ISnippetsExtensionPoint {
		language: string;
		path: string;
	}
36

37 38
	export function isValidSnippet(extension: IExtensionPointUser<ISnippetsExtensionPoint[]>, snippet: ISnippetsExtensionPoint, modeService: IModeService): boolean {
		if (!snippet.language || (typeof snippet.language !== 'string') || !modeService.isRegisteredMode(snippet.language)) {
39 40 41 42 43
			extension.collector.error(localize(
				'invalid.language',
				"Unknown language in `contributes.{0}.language`. Provided value: {1}",
				extension.description.name, String(snippet.language)
			));
44 45 46
			return false;

		} else if (!snippet.path || (typeof snippet.path !== 'string')) {
47 48 49 50 51
			extension.collector.error(localize(
				'invalid.path.0',
				"Expected string in `contributes.{0}.path`. Provided value: {1}",
				extension.description.name, String(snippet.path)
			));
52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
			return false;

		} 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;
			}

			snippet.path = normalizedAbsolutePath;
			return true;
		}
	}
69

70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89
	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'
				}
			}
		}
	};
}
90

91
class SnippetsService implements ISnippetsService {
92

93
	readonly _serviceBrand: any;
94

95
	private readonly _pendingExtensionSnippets = new Map<LanguageId, [IExtensionPointUser<any>, string][]>();
J
Johannes Rieken 已提交
96 97
	private readonly _extensionSnippets = new Map<LanguageId, Snippet[]>();
	private readonly _userSnippets = new Map<LanguageId, Snippet[]>();
98 99
	private readonly _userSnippetsFolder: string;
	private readonly _disposables: IDisposable[] = [];
J
Johannes Rieken 已提交
100

101 102 103 104 105 106 107 108
	constructor(
		@IModeService readonly _modeService: IModeService,
		@IExtensionService readonly _extensionService: IExtensionService,
		@IEnvironmentService environmentService: IEnvironmentService,
	) {
		this._userSnippetsFolder = join(environmentService.appSettingsHome, 'snippets');
		this._prepUserSnippetsWatching();
		this._prepExtensionSnippets();
109

110 111
		setSnippetSuggestSupport(new SnippetSuggestProvider(this._modeService, this));
	}
112

113 114 115
	dispose(): void {
		dispose(this._disposables);
	}
116

J
Johannes Rieken 已提交
117 118
	async getSnippets(languageId: LanguageId): TPromise<Snippet[]> {
		let result: Snippet[] = [];
119 120 121 122 123 124 125
		await TPromise.join([
			this._extensionService.onReady(),
			this._getOrLoadUserSnippets(languageId, result),
			this._getOrLoadExtensionSnippets(languageId, result)
		]);
		return result;
	}
126

J
Johannes Rieken 已提交
127
	getSnippetsSync(languageId: LanguageId): Snippet[] {
128 129 130 131
		// 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);
132

133 134 135 136
		// collect and return what we already have
		let userSnippets = this._userSnippets.get(languageId);
		let extensionSnippets = this._extensionSnippets.get(languageId);
		return (userSnippets || []).concat(extensionSnippets || []);
137 138
	}

139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156
	// --- extension snippet logic ---

	private async _prepExtensionSnippets(): TPromise<void> {
		ExtensionsRegistry.registerExtensionPoint<schema.ISnippetsExtensionPoint[]>('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]);
						}
					}
				}
			}
		});
157 158
	}

J
Johannes Rieken 已提交
159
	private async _getOrLoadExtensionSnippets(languageId: LanguageId, bucket: Snippet[]): TPromise<void> {
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180

		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
					));
181
				}
182 183 184 185 186 187 188 189 190 191 192 193 194 195
				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
							));
						}
					}
				}
			}
196 197
		}
	}
J
Johannes Rieken 已提交
198

199 200
	// --- user snippet logic ---

J
Johannes Rieken 已提交
201
	private async _getOrLoadUserSnippets(languageId: LanguageId, bucket: Snippet[]): TPromise<void> {
202 203 204 205 206 207 208 209 210 211 212 213
		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);
J
Johannes Rieken 已提交
214
		}
215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238
	}

	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);
					}
				}
			});
		});
J
Johannes Rieken 已提交
239
	}
240
}
241

242 243 244 245 246 247
registerSingleton(ISnippetsService, SnippetsService);

export interface ISimpleModel {
	getLineContent(lineNumber: number): string;
}

248
export class SnippetSuggestion implements ISuggestion {
J
Johannes Rieken 已提交
249 250 251 252

	label: string;
	detail: string;
	insertText: string;
253
	documentation: MarkdownString;
J
Johannes Rieken 已提交
254 255 256 257 258 259 260
	overwriteBefore: number;
	sortText: string;
	noAutoAccept: boolean;
	type: SuggestionType;
	snippetType: SnippetType;

	constructor(
J
Johannes Rieken 已提交
261
		readonly snippet: Snippet,
J
Johannes Rieken 已提交
262 263 264
		overwriteBefore: number
	) {
		this.label = snippet.prefix;
265
		this.detail = localize('detail.snippet', "{0} ({1})", snippet.description, snippet.source);
J
Johannes Rieken 已提交
266 267
		this.insertText = snippet.codeSnippet;
		this.overwriteBefore = overwriteBefore;
268
		this.sortText = `${snippet.isFromExtension ? 'z' : 'a'}-${snippet.prefix}`;
J
Johannes Rieken 已提交
269 270 271 272 273 274
		this.noAutoAccept = true;
		this.type = 'snippet';
		this.snippetType = 'textmate';
	}

	resolve(): this {
275
		this.documentation = new MarkdownString().appendCodeblock('', new SnippetParser().text(this.snippet.codeSnippet));
J
Johannes Rieken 已提交
276 277 278 279 280 281
		return this;
	}

	static compareByLabel(a: SnippetSuggestion, b: SnippetSuggestion): number {
		return compare(a.label, b.label);
	}
J
Johannes Rieken 已提交
282 283 284 285
}


export class SnippetSuggestProvider implements ISuggestSupport {
286 287 288 289 290 291

	constructor(
		@IModeService private _modeService: IModeService,
		@ISnippetsService private _snippets: ISnippetsService
	) {
		//
292 293
	}

294
	async provideCompletionItems(model: IModel, position: Position): TPromise<ISuggestResult> {
295

296
		const languageId = this._getLanguageIdAtPosition(model, position);
297
		const snippets = await this._snippets.getSnippets(languageId);
J
Johannes Rieken 已提交
298
		const suggestions: SnippetSuggestion[] = [];
299

J
Johannes Rieken 已提交
300 301
		const lowWordUntil = model.getWordUntilPosition(position).word.toLowerCase();
		const lowLineUntil = model.getLineContent(position.lineNumber).substr(Math.max(0, position.column - 100), position.column - 1).toLowerCase();
302

J
Johannes Rieken 已提交
303 304 305
		for (const snippet of snippets) {

			const lowPrefix = snippet.prefix.toLowerCase();
306 307
			let overwriteBefore = 0;
			let accetSnippet = true;
308

J
Johannes Rieken 已提交
309 310 311
			if (lowWordUntil.length > 0 && startsWith(lowPrefix, lowWordUntil)) {
				// cheap match on the (none-empty) current word
				overwriteBefore = lowWordUntil.length;
312
				accetSnippet = true;
J
Johannes Rieken 已提交
313

J
Johannes Rieken 已提交
314 315
			} else if (lowLineUntil.length > 0 && lowLineUntil.match(/[^\s]$/)) {
				// compute overlap between snippet and (none-empty) line on text
J
Johannes Rieken 已提交
316
				overwriteBefore = overlap(lowLineUntil, snippet.prefix.toLowerCase());
317
				accetSnippet = overwriteBefore > 0 && !model.getWordAtPosition(new Position(position.lineNumber, position.column - overwriteBefore));
J
Johannes Rieken 已提交
318 319
			}

320
			if (accetSnippet) {
J
Johannes Rieken 已提交
321
				suggestions.push(new SnippetSuggestion(snippet, overwriteBefore));
J
Johannes Rieken 已提交
322 323
			}
		}
324 325

		// dismbiguate suggestions with same labels
J
Johannes Rieken 已提交
326 327 328
		let lastItem: SnippetSuggestion;
		for (const item of suggestions.sort(SnippetSuggestion.compareByLabel)) {
			if (lastItem && lastItem.label === item.label) {
329
				// use the disambiguateLabel instead of the actual label
J
Johannes Rieken 已提交
330 331
				lastItem.label = localize('snippetSuggest.longLabel', "{0}, {1}", lastItem.label, lastItem.snippet.name);
				item.label = localize('snippetSuggest.longLabel', "{0}, {1}", item.label, item.snippet.name);
332
			}
J
Johannes Rieken 已提交
333
			lastItem = item;
334 335
		}

336 337 338
		return { suggestions };
	}

J
Johannes Rieken 已提交
339 340 341 342
	resolveCompletionItem?(model: IModel, position: Position, item: ISuggestion): ISuggestion {
		return (item instanceof SnippetSuggestion) ? item.resolve() : item;
	}

343 344 345 346
	private _getLanguageIdAtPosition(model: IModel, position: Position): LanguageId {
		// validate the `languageId` to ensure this is a user
		// facing language with a name and the chance to have
		// snippets, else fall back to the outer language
347
		model.tokenizeIfCheap(position.lineNumber);
348 349 350 351 352 353
		let languageId = model.getLanguageIdAtPosition(position.lineNumber, position.column);
		let { language } = this._modeService.getLanguageIdentifier(languageId);
		if (!this._modeService.getLanguageName(language)) {
			languageId = model.getLanguageIdentifier().id;
		}
		return languageId;
354 355
	}

J
Johannes Rieken 已提交
356

357 358
}

A
Alex Dima 已提交
359
export function getNonWhitespacePrefix(model: ISimpleModel, position: Position): string {
360 361 362 363 364
	/**
	 * Do not analyze more characters
	 */
	const MAX_PREFIX_LENGTH = 100;

365
	let line = model.getLineContent(position.lineNumber).substr(0, position.column - 1);
366 367 368 369 370 371 372 373

	let minChIndex = Math.max(0, line.length - MAX_PREFIX_LENGTH);
	for (let chIndex = line.length - 1; chIndex >= minChIndex; chIndex--) {
		let ch = line.charAt(chIndex);

		if (/\s/.test(ch)) {
			return line.substr(chIndex + 1);
		}
374
	}
375 376 377 378 379

	if (minChIndex === 0) {
		return line;
	}

380 381 382
	return '';
}