snippetsService.ts 14.2 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/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';
15
import { SnippetParser } from 'vs/editor/contrib/snippet/snippetParser';
16 17 18 19 20 21 22
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';
J
Johannes Rieken 已提交
23
import { Snippet, ISnippetsService } from 'vs/workbench/parts/snippets/electron-browser/snippets.contribution';
24 25 26
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';
27
import { MarkdownString } from 'vs/base/common/htmlContent';
28 29 30 31 32 33 34

namespace schema {

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

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

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

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

90
class SnippetsService implements ISnippetsService {
91

92
	readonly _serviceBrand: any;
93

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

101
	constructor(
102 103
		@IModeService private readonly _modeService: IModeService,
		@IEnvironmentService private readonly _environmentService: IEnvironmentService,
104
		@IExtensionService extensionService: IExtensionService,
105
	) {
106
		this._wait = Promise.resolve(extensionService.whenInstalledExtensionsRegistered());
107
		this._userSnippetsFolder = join(_environmentService.appSettingsHome, 'snippets');
108 109
		this._prepUserSnippetsWatching();
		this._prepExtensionSnippets();
110

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

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

118
	getSnippets(languageId: LanguageId): Promise<Snippet[]> {
J
Johannes Rieken 已提交
119
		let result: Snippet[] = [];
120 121 122 123 124
		return this._wait.then(() => {
			return this._getOrLoadUserSnippets(languageId, result);
		}).then(() => {
			return this._getOrLoadExtensionSnippets(languageId, result);
		}).then(() => {
125 126
			return result;
		});
127
	}
128

J
Johannes Rieken 已提交
129
	getSnippetsSync(languageId: LanguageId): Snippet[] {
130
		// just kick off snippet loading for this language such
131
		// that subsequent calls to this method return more
132
		// correct results
133
		this.getSnippets(languageId).catch(undefined);
134

135
		// collect and return what we already have
136 137 138 139 140 141 142 143 144 145 146 147
		const userSnippets = this._userSnippets.get(languageId);
		const extensionSnippets = this._extensionSnippets.get(languageId);

		if (userSnippets && extensionSnippets) {
			return userSnippets.concat(extensionSnippets);
		} else if (!userSnippets) {
			return extensionSnippets;
		} else if (!extensionSnippets) {
			return userSnippets;
		} else {
			return undefined;
		}
148 149
	}

150 151
	// --- extension snippet logic ---

152 153
	private _prepExtensionSnippets(): void {
		ExtensionsRegistry.registerExtensionPoint<schema.ISnippetsExtensionPoint[]>('snippets', [languagesExtPoint], schema.snippetsContribution).setHandler(extensions => {
154 155 156 157 158 159 160 161 162 163 164 165 166 167
			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]);
						}
					}
				}
			}
		});
168 169
	}

170
	private _getOrLoadExtensionSnippets(languageId: LanguageId, bucket: Snippet[]): Promise<any> {
171 172 173

		if (this._extensionSnippets.has(languageId)) {
			bucket.push(...this._extensionSnippets.get(languageId));
174
			return undefined;
175 176 177 178 179 180 181 182

		} else if (this._pendingExtensionSnippets.has(languageId)) {
			const pending = this._pendingExtensionSnippets.get(languageId);
			this._pendingExtensionSnippets.delete(languageId);

			const snippets = [];
			this._extensionSnippets.set(languageId, snippets);

183 184 185
			return Promise.all(pending.map(([extension, filepath]) => {
				return SnippetFile.fromFile(filepath, extension.description.displayName || extension.description.name, true).then(file => {

186 187 188
					// collect
					snippets.push(...file.data);
					bucket.push(...file.data);
189 190

					// warn about bad tabstop/variable usage
191
					if (this._environmentService.isExtensionDevelopment && file.data.some(snippet => snippet.isBogous)) {
192 193 194 195 196
						extension.collector.warn(localize(
							'badVariableUse',
							"One or more snippets from the extension '{0}' very likely confuse snippet-variables and snippet-placeholders (see https://code.visualstudio.com/docs/editor/userdefinedsnippets#_snippet-syntax for more details)",
							extension.description.name
						));
197
					}
198 199 200 201 202 203 204 205 206

				}, err => {
					// generic error
					extension.collector.warn(localize(
						'badFile',
						"The snippet file \"{0}\" could not be read.",
						filepath
					));
				});
207 208 209
			})).then(() => {

			});
210 211 212

		} else {
			return undefined;
213 214
		}
	}
J
Johannes Rieken 已提交
215

216 217
	// --- user snippet logic ---

218
	private _getOrLoadUserSnippets(languageId: LanguageId, bucket: Snippet[]): Promise<any> {
219 220
		let snippets = this._userSnippets.get(languageId);
		if (snippets) {
221
			// has data
222
			bucket.push(...snippets);
223 224 225 226 227 228
			return undefined;

		} else if (snippets === undefined) {
			// not yet loaded
			return SnippetFile.fromFile(this._getUserSnippetFilepath(languageId), localize('source.snippet', "User Snippet")).then(file => {
				this._userSnippets.set(languageId, file.data);
J
Johannes Rieken 已提交
229
				bucket.push(...file.data);
230 231 232 233 234 235 236
			}, err => {
				this._userSnippets.set(languageId, null);
			});

		} else {
			// previous failure
			return undefined;
J
Johannes Rieken 已提交
237
		}
238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261
	}

	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 已提交
262
	}
263
}
264

265 266 267 268 269 270
registerSingleton(ISnippetsService, SnippetsService);

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

271
export class SnippetSuggestion implements ISuggestion {
J
Johannes Rieken 已提交
272 273 274 275

	label: string;
	detail: string;
	insertText: string;
276
	documentation: MarkdownString;
J
Johannes Rieken 已提交
277 278 279 280 281 282 283
	overwriteBefore: number;
	sortText: string;
	noAutoAccept: boolean;
	type: SuggestionType;
	snippetType: SnippetType;

	constructor(
J
Johannes Rieken 已提交
284
		readonly snippet: Snippet,
J
Johannes Rieken 已提交
285 286 287
		overwriteBefore: number
	) {
		this.label = snippet.prefix;
288
		this.detail = localize('detail.snippet', "{0} ({1})", snippet.description, snippet.source);
289
		this.insertText = snippet.body;
J
Johannes Rieken 已提交
290
		this.overwriteBefore = overwriteBefore;
291
		this.sortText = `${snippet.isFromExtension ? 'z' : 'a'}-${snippet.prefix}`;
J
Johannes Rieken 已提交
292 293 294 295 296 297
		this.noAutoAccept = true;
		this.type = 'snippet';
		this.snippetType = 'textmate';
	}

	resolve(): this {
298
		this.documentation = new MarkdownString().appendCodeblock('', new SnippetParser().text(this.snippet.codeSnippet));
299
		this.insertText = this.snippet.codeSnippet;
J
Johannes Rieken 已提交
300 301 302 303 304 305
		return this;
	}

	static compareByLabel(a: SnippetSuggestion, b: SnippetSuggestion): number {
		return compare(a.label, b.label);
	}
J
Johannes Rieken 已提交
306 307 308 309
}


export class SnippetSuggestProvider implements ISuggestSupport {
310 311 312 313 314 315

	constructor(
		@IModeService private _modeService: IModeService,
		@ISnippetsService private _snippets: ISnippetsService
	) {
		//
316 317
	}

318
	provideCompletionItems(model: IModel, position: Position): Promise<ISuggestResult> {
319

320
		const languageId = this._getLanguageIdAtPosition(model, position);
321
		return this._snippets.getSnippets(languageId).then(snippets => {
322

323
			const suggestions: SnippetSuggestion[] = [];
324

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

328
			for (const snippet of snippets) {
329

330 331 332
				const lowPrefix = snippet.prefix.toLowerCase();
				let overwriteBefore = 0;
				let accetSnippet = true;
J
Johannes Rieken 已提交
333

334 335 336 337 338 339 340 341 342 343
				if (lowWordUntil.length > 0 && startsWith(lowPrefix, lowWordUntil)) {
					// cheap match on the (none-empty) current word
					overwriteBefore = lowWordUntil.length;
					accetSnippet = true;

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

345 346 347
				if (accetSnippet) {
					suggestions.push(new SnippetSuggestion(snippet, overwriteBefore));
				}
J
Johannes Rieken 已提交
348
			}
349

350 351 352 353 354 355 356 357 358
			// dismbiguate suggestions with same labels
			let lastItem: SnippetSuggestion;
			for (const item of suggestions.sort(SnippetSuggestion.compareByLabel)) {
				if (lastItem && lastItem.label === item.label) {
					// use the disambiguateLabel instead of the actual label
					lastItem.label = localize('snippetSuggest.longLabel', "{0}, {1}", lastItem.label, lastItem.snippet.name);
					item.label = localize('snippetSuggest.longLabel', "{0}, {1}", item.label, item.snippet.name);
				}
				lastItem = item;
359 360
			}

361 362
			return { suggestions };
		});
363 364
	}

J
Johannes Rieken 已提交
365 366 367 368
	resolveCompletionItem?(model: IModel, position: Position, item: ISuggestion): ISuggestion {
		return (item instanceof SnippetSuggestion) ? item.resolve() : item;
	}

369 370 371 372
	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
373
		model.tokenizeIfCheap(position.lineNumber);
374 375 376 377 378 379
		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;
380 381
	}

J
Johannes Rieken 已提交
382

383 384
}

A
Alex Dima 已提交
385
export function getNonWhitespacePrefix(model: ISimpleModel, position: Position): string {
386 387 388 389 390
	/**
	 * Do not analyze more characters
	 */
	const MAX_PREFIX_LENGTH = 100;

391
	let line = model.getLineContent(position.lineNumber).substr(0, position.column - 1);
392 393 394 395 396 397 398 399

	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);
		}
400
	}
401 402 403 404 405

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

406 407 408
	return '';
}