abbreviationActions.ts 16.3 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.
 *--------------------------------------------------------------------------------------------*/

import * as vscode from 'vscode';
7
import { Node, HtmlNode, Rule, Property } from 'EmmetNode';
8
import { getEmmetHelper, getNode, getInnerRange, getMappingForIncludedLanguages, parseDocument, validate, getEmmetConfiguration, isStyleSheet, getEmmetMode } from './util';
9

10 11
const trimRegex = /[\u00a0]*[\d|#|\-|\*|\u2022]+\.?/;

R
Ramya Achutha Rao 已提交
12
interface ExpandAbbreviationInput {
13
	syntax: string;
R
Ramya Achutha Rao 已提交
14 15
	abbreviation: string;
	rangeToReplace: vscode.Range;
16
	textToWrap?: string[];
17
	filter?: string;
R
Ramya Achutha Rao 已提交
18 19
}

20 21
export function wrapWithAbbreviation(args: any) {
	if (!validate(false) || !vscode.window.activeTextEditor) {
22 23
		return;
	}
24 25

	const editor = vscode.window.activeTextEditor;
26

27
	const syntax = getSyntaxFromArgs({ language: editor.document.languageId });
28 29 30 31 32
	if (!syntax) {
		return;
	}

	const abbreviationPromise = (args && args['abbreviation']) ? Promise.resolve(args['abbreviation']) : vscode.window.showInputBox({ prompt: 'Enter Abbreviation' });
33
	const helper = getEmmetHelper();
34

35 36 37 38 39 40 41 42
	return abbreviationPromise.then(inputAbbreviation => {
		if (!inputAbbreviation || !inputAbbreviation.trim() || !helper.isAbbreviationValid(syntax, inputAbbreviation)) { return false; }

		let extractedResults = helper.extractAbbreviationFromText(inputAbbreviation);
		if (!extractedResults) {
			return false;
		}
		let { abbreviation, filter } = extractedResults;
43

R
Ramya Achutha Rao 已提交
44
		let expandAbbrList: ExpandAbbreviationInput[] = [];
45

46
		editor.selections.forEach(selection => {
47
			let rangeToReplace: vscode.Range = selection.isReversed ? new vscode.Range(selection.active, selection.anchor) : selection;
48 49 50
			if (rangeToReplace.isEmpty) {
				rangeToReplace = new vscode.Range(rangeToReplace.start.line, 0, rangeToReplace.start.line, editor.document.lineAt(rangeToReplace.start.line).text.length);
			}
51

52 53 54
			const firstLineOfSelection = editor.document.lineAt(rangeToReplace.start).text.substr(rangeToReplace.start.character);
			const matches = firstLineOfSelection.match(/^(\s*)/);
			const preceedingWhiteSpace = matches ? matches[1].length : 0;
55

56
			rangeToReplace = new vscode.Range(rangeToReplace.start.line, rangeToReplace.start.character + preceedingWhiteSpace, rangeToReplace.end.line, rangeToReplace.end.character);
57
			expandAbbrList.push({ syntax, abbreviation, rangeToReplace, textToWrap: ['\n\t$TM_SELECTED_TEXT\n'], filter });
58
		});
59

60
		return expandAbbreviationInRange(editor, expandAbbrList, true);
61 62 63
	});
}

64 65
export function wrapIndividualLinesWithAbbreviation(args: any) {
	if (!validate(false) || !vscode.window.activeTextEditor) {
66 67 68 69 70 71 72 73 74
		return;
	}

	const editor = vscode.window.activeTextEditor;
	if (editor.selection.isEmpty) {
		vscode.window.showInformationMessage('Select more than 1 line and try again.');
		return;
	}

75
	const syntax = getSyntaxFromArgs({ language: editor.document.languageId });
76 77 78 79 80
	if (!syntax) {
		return;
	}

	const abbreviationPromise = (args && args['abbreviation']) ? Promise.resolve(args['abbreviation']) : vscode.window.showInputBox({ prompt: 'Enter Abbreviation' });
81
	const lines = editor.document.getText(editor.selection).split('\n').map(x => x.trim());
82
	const helper = getEmmetHelper();
83

84
	return abbreviationPromise.then(inputAbbreviation => {
85
		if (!inputAbbreviation || !inputAbbreviation.trim() || !helper.isAbbreviationValid(syntax, inputAbbreviation)) { return false; }
86

87
		let extractedResults = helper.extractAbbreviationFromText(inputAbbreviation);
88
		if (!extractedResults) {
89
			return false;
90 91
		}

92
		let { abbreviation, filter } = extractedResults;
93 94 95 96
		let input: ExpandAbbreviationInput = {
			syntax,
			abbreviation,
			rangeToReplace: editor.selection,
97
			textToWrap: lines,
98
			filter
99 100 101 102 103 104 105
		};

		return expandAbbreviationInRange(editor, [input], true);
	});

}

106 107 108 109 110 111 112 113
export function expandEmmetAbbreviation(args: any): Thenable<boolean | undefined> {
	if (!validate() || !vscode.window.activeTextEditor) {
		return fallbackTab();
	}

	args = args || {};
	if (!args['language']) {
		args['language'] = vscode.window.activeTextEditor.document.languageId;
114 115 116 117 118
	} else {
		const excludedLanguages = vscode.workspace.getConfiguration('emmet')['excludeLanguages'] ? vscode.workspace.getConfiguration('emmet')['excludeLanguages'] : [];
		if (excludedLanguages.indexOf(vscode.window.activeTextEditor.document.languageId) > -1) {
			return fallbackTab();
		}
119
	}
120
	const syntax = getSyntaxFromArgs(args);
121
	if (!syntax) {
122
		return fallbackTab();
123
	}
124 125 126

	const editor = vscode.window.activeTextEditor;

127
	let rootNode = parseDocument(editor.document, false);
128

129 130 131 132 133
	// When tabbed on a non empty selection, do not treat it as an emmet abbreviation, and fallback to tab instead
	if (vscode.workspace.getConfiguration('emmet')['triggerExpansionOnTab'] === true && editor.selections.find(x => !x.isEmpty)) {
		return fallbackTab();
	}

R
Ramya Achutha Rao 已提交
134
	let abbreviationList: ExpandAbbreviationInput[] = [];
135 136
	let firstAbbreviation: string;
	let allAbbreviationsSame: boolean = true;
137
	const helper = getEmmetHelper();
138

139
	let getAbbreviation = (document: vscode.TextDocument, selection: vscode.Selection, position: vscode.Position, syntax: string): [vscode.Range | null, string, string] => {
R
Ramya Achutha Rao 已提交
140
		let rangeToReplace: vscode.Range = selection;
141
		let abbr = document.getText(rangeToReplace);
142
		if (!rangeToReplace.isEmpty) {
143
			let extractedResults = helper.extractAbbreviationFromText(abbr);
144
			if (extractedResults) {
145
				return [rangeToReplace, extractedResults.abbreviation, extractedResults.filter];
146
			}
147
			return [null, '', ''];
148 149
		}

150 151 152
		const currentLine = editor.document.lineAt(position.line).text;
		const textTillPosition = currentLine.substr(0, position.character);

153 154
		// Expand cases like <div to <div></div> explicitly
		// else we will end up with <<div></div>
155
		if (syntax === 'html') {
156 157
			let matches = textTillPosition.match(/<(\w+)$/);
			if (matches) {
158 159
				abbr = matches[1];
				rangeToReplace = new vscode.Range(position.translate(0, -(abbr.length + 1)), position);
160
				return [rangeToReplace, abbr, ''];
161
			}
162
		}
163
		let extractedResults = helper.extractAbbreviation(editor.document, position, false);
164
		if (!extractedResults) {
165
			return [null, '', ''];
166 167
		}

168 169
		let { abbreviationRange, abbreviation, filter } = extractedResults;
		return [new vscode.Range(abbreviationRange.start.line, abbreviationRange.start.character, abbreviationRange.end.line, abbreviationRange.end.character), abbreviation, filter];
170 171
	};

J
jmdowns2 已提交
172 173 174 175 176 177 178 179
	let selectionsInReverseOrder = editor.selections.slice(0);
	selectionsInReverseOrder.sort((a, b) => {
		var posA = a.isReversed ? a.anchor : a.active;
		var posB = b.isReversed ? b.anchor : b.active;
		return posA.compareTo(posB) * -1;
	});

	selectionsInReverseOrder.forEach(selection => {
180
		let position = selection.isReversed ? selection.anchor : selection.active;
181
		let [rangeToReplace, abbreviation, filter] = getAbbreviation(editor.document, selection, position, syntax);
182 183 184
		if (!rangeToReplace) {
			return;
		}
185
		if (!helper.isAbbreviationValid(syntax, abbreviation)) {
186 187
			return;
		}
188

189
		let currentNode = getNode(rootNode, position, true);
190
		if (!isValidLocationForEmmetAbbreviation(editor.document, currentNode, syntax, position, rangeToReplace)) {
191 192 193
			return;
		}

194 195 196 197
		if (!firstAbbreviation) {
			firstAbbreviation = abbreviation;
		} else if (allAbbreviationsSame && firstAbbreviation !== abbreviation) {
			allAbbreviationsSame = false;
198
		}
199

200
		abbreviationList.push({ syntax, abbreviation, rangeToReplace, filter });
201 202
	});

203 204 205 206 207
	return expandAbbreviationInRange(editor, abbreviationList, allAbbreviationsSame).then(success => {
		if (!success) {
			return fallbackTab();
		}
	});
208 209
}

210
function fallbackTab(): Thenable<boolean | undefined> {
211 212 213
	if (vscode.workspace.getConfiguration('emmet')['triggerExpansionOnTab'] === true) {
		return vscode.commands.executeCommand('tab');
	}
214
	return Promise.resolve(true);
215
}
216
/**
217 218
 * Checks if given position is a valid location to expand emmet abbreviation.
 * Works only on html and css/less/scss syntax
219
 * @param document current Text Document
220 221 222
 * @param currentNode parsed node at given position
 * @param syntax syntax of the abbreviation
 * @param position position to validate
223
 * @param abbreviationRange The range of the abbreviation for which given position is being validated
224
 */
225
export function isValidLocationForEmmetAbbreviation(document: vscode.TextDocument, currentNode: Node | null, syntax: string, position: vscode.Position, abbreviationRange: vscode.Range): boolean {
226
	if (isStyleSheet(syntax)) {
227 228 229 230 231
		// Continue validation only if the file was parse-able and the currentNode has been found
		if (!currentNode) {
			return true;
		}

232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248
		// Fix for https://github.com/Microsoft/vscode/issues/34162
		// Other than sass, stylus, we can make use of the terminator tokens to validate position
		if (syntax !== 'sass' && syntax !== 'stylus' && currentNode.type === 'property') {
			const propertyNode = <Property>currentNode;
			if (propertyNode.terminatorToken
				&& propertyNode.separator
				&& position.isAfterOrEqual(propertyNode.separatorToken.end)
				&& position.isBeforeOrEqual(propertyNode.terminatorToken.start)) {
				return false;
			}
			if (!propertyNode.terminatorToken
				&& propertyNode.separator
				&& position.isAfterOrEqual(propertyNode.separatorToken.end)) {
				return false;
			}
		}

249 250 251
		// If current node is a rule or at-rule, then perform additional checks to ensure
		// emmet suggestions are not provided in the rule selector
		if (currentNode.type !== 'rule' && currentNode.type !== 'at-rule') {
R
Ramya Achutha Rao 已提交
252 253
			return true;
		}
254

R
Ramya Achutha Rao 已提交
255
		const currentCssNode = <Rule>currentNode;
256

257 258 259 260 261
		// Position is valid if it occurs after the `{` that marks beginning of rule contents
		if (position.isAfter(currentCssNode.contentStartToken.end)) {
			return true;
		}

262
		// Workaround for https://github.com/Microsoft/vscode/30188
263 264
		// The line above the rule selector is considered as part of the selector by the css-parser
		// But we should assume it is a valid location for css properties under the parent rule
265
		if (currentCssNode.parent
266
			&& (currentCssNode.parent.type === 'rule' || currentCssNode.parent.type === 'at-rule')
267
			&& currentCssNode.selectorToken
268 269 270 271
			&& position.line !== currentCssNode.selectorToken.end.line
			&& currentCssNode.selectorToken.start.character === abbreviationRange.start.character
			&& currentCssNode.selectorToken.start.line === abbreviationRange.start.line
		) {
272 273 274
			return true;
		}

275
		return false;
276 277
	}

278 279 280
	const startAngle = '<';
	const endAngle = '>';
	const escape = '\\';
R
Ramya Achutha Rao 已提交
281
	const currentHtmlNode = <HtmlNode>currentNode;
282
	let start = new vscode.Position(0, 0);
283

284 285
	if (currentHtmlNode) {
		const innerRange = getInnerRange(currentHtmlNode);
286

287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302
		// Fix for https://github.com/Microsoft/vscode/issues/28829
		if (!innerRange || !innerRange.contains(position)) {
			return false;
		}

		// Fix for https://github.com/Microsoft/vscode/issues/35128
		// Find the position up till where we will backtrack looking for unescaped < or >
		// to decide if current position is valid for emmet expansion
		start = innerRange.start;
		let lastChildBeforePosition = currentHtmlNode.firstChild;
		while (lastChildBeforePosition) {
			if (lastChildBeforePosition.end.isAfter(position)) {
				break;
			}
			start = lastChildBeforePosition.end;
			lastChildBeforePosition = lastChildBeforePosition.nextSibling;
303 304
		}
	}
305
	let textToBackTrack = document.getText(new vscode.Range(start.line, start.character, abbreviationRange.start.line, abbreviationRange.start.character));
306 307 308 309 310 311 312

	// Worse case scenario is when cursor is inside a big chunk of text which needs to backtracked
	// Backtrack only 500 offsets to ensure we dont waste time doing this
	if (textToBackTrack.length > 500) {
		textToBackTrack = textToBackTrack.substr(textToBackTrack.length - 500);
	}

313 314 315 316
	if (!textToBackTrack.trim()) {
		return true;
	}

317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340
	let valid = true;
	let foundSpace = false; // If < is found before finding whitespace, then its valid abbreviation. Eg: <div|
	let i = textToBackTrack.length - 1;
	while (i >= 0) {
		const char = textToBackTrack[i];
		i--;
		if (!foundSpace && /\s/.test(char)) {
			foundSpace = true;
			continue;
		}
		if (char !== startAngle && char !== endAngle) {
			continue;
		}
		if (i >= 0 && textToBackTrack[i] === escape) {
			i--;
			continue;
		}
		if (char === endAngle) {
			break;
		}
		if (char === startAngle) {
			valid = !foundSpace;
			break;
		}
341 342
	}

343
	return valid;
R
Ramya Achutha Rao 已提交
344 345
}

346 347
/**
 * Expands abbreviations as detailed in expandAbbrList in the editor
348 349 350
 * @param editor
 * @param expandAbbrList
 * @param insertSameSnippet
351
 * @returns false if no snippet can be inserted.
352
 */
353
function expandAbbreviationInRange(editor: vscode.TextEditor, expandAbbrList: ExpandAbbreviationInput[], insertSameSnippet: boolean): Thenable<boolean> {
R
Ramya Achutha Rao 已提交
354
	if (!expandAbbrList || expandAbbrList.length === 0) {
355
		return Promise.resolve(false);
R
Ramya Achutha Rao 已提交
356 357 358 359 360
	}

	// Snippet to replace at multiple cursors are not the same
	// `editor.insertSnippet` will have to be called for each instance separately
	// We will not be able to maintain multiple cursors after snippet insertion
361
	let insertPromises: Thenable<boolean>[] = [];
R
Ramya Achutha Rao 已提交
362 363
	if (!insertSameSnippet) {
		expandAbbrList.forEach((expandAbbrInput: ExpandAbbreviationInput) => {
364
			let expandedText = expandAbbr(expandAbbrInput);
R
Ramya Achutha Rao 已提交
365
			if (expandedText) {
366
				insertPromises.push(editor.insertSnippet(new vscode.SnippetString(expandedText), expandAbbrInput.rangeToReplace));
R
Ramya Achutha Rao 已提交
367 368
			}
		});
369 370 371
		if (insertPromises.length === 0) {
			return Promise.resolve(false);
		}
372
		return Promise.all(insertPromises).then(() => Promise.resolve(true));
R
Ramya Achutha Rao 已提交
373 374 375
	}

	// Snippet to replace at all cursors are the same
376
	// We can pass all ranges to `editor.insertSnippet` in a single call so that
R
Ramya Achutha Rao 已提交
377 378
	// all cursors are maintained after snippet insertion
	const anyExpandAbbrInput = expandAbbrList[0];
379
	let expandedText = expandAbbr(anyExpandAbbrInput);
R
Ramya Achutha Rao 已提交
380
	let allRanges = expandAbbrList.map(value => {
381
		return new vscode.Range(value.rangeToReplace.start.line, value.rangeToReplace.start.character, value.rangeToReplace.end.line, value.rangeToReplace.end.character);
R
Ramya Achutha Rao 已提交
382 383
	});
	if (expandedText) {
384
		return editor.insertSnippet(new vscode.SnippetString(expandedText), allRanges);
R
Ramya Achutha Rao 已提交
385
	}
386
	return Promise.resolve(false);
387 388
}

389
/**
390
 * Expands abbreviation as detailed in given input.
391
 */
392
function expandAbbr(input: ExpandAbbreviationInput): string | undefined {
393 394
	const helper = getEmmetHelper();
	const expandOptions = helper.getExpandOptions(input.syntax, getEmmetConfiguration(input.syntax), input.filter);
395

396
	if (input.textToWrap) {
397
		if (input.filter && input.filter.indexOf('t') > -1) {
398 399 400 401
			input.textToWrap = input.textToWrap.map(line => {
				return line.replace(trimRegex, '').trim();
			});
		}
402 403 404 405 406 407 408 409
		expandOptions['text'] = input.textToWrap;

		// Below fixes https://github.com/Microsoft/vscode/issues/29898
		// With this, Emmet formats inline elements as block elements
		// ensuring the wrapped multi line text does not get merged to a single line
		if (!input.rangeToReplace.isSingleLine) {
			expandOptions.profile['inlineBreak'] = 1;
		}
410 411
	}

412
	try {
413
		// Expand the abbreviation
414
		let expandedText = helper.expandAbbreviation(input.abbreviation, expandOptions);
415

416 417 418 419 420 421 422 423 424
		if (input.textToWrap) {
			// All $anyword would have been escaped by the emmet helper.
			// Remove the escaping backslash from $TM_SELECTED_TEXT so that VS Code Snippet controller can treat it as a variable
			expandedText = expandedText.replace('\\$TM_SELECTED_TEXT', '$TM_SELECTED_TEXT');

			// If the expanded text is single line then we dont need the \t and \n we added to $TM_SELECTED_TEXT earlier
			if (input.textToWrap.length === 1 && expandedText.indexOf('\n') === -1) {
				expandedText = expandedText.replace(/\s*\$TM_SELECTED_TEXT\s*/, '$TM_SELECTED_TEXT');
			}
425
		}
426

427 428
		return expandedText;

429 430
	} catch (e) {
		vscode.window.showErrorMessage('Failed to expand abbreviation');
431 432 433
	}


434 435
}

436
function getSyntaxFromArgs(args: Object): string | undefined {
437
	const mappedModes = getMappingForIncludedLanguages();
438 439 440 441 442 443 444
	const language: string = args['language'];
	const parentMode: string = args['parentMode'];
	const excludedLanguages = vscode.workspace.getConfiguration('emmet')['excludeLanguages'] ? vscode.workspace.getConfiguration('emmet')['excludeLanguages'] : [];
	if (excludedLanguages.indexOf(language) > -1) {
		return;
	}

445
	let syntax = getEmmetMode((mappedModes[language] ? mappedModes[language] : language), excludedLanguages);
446 447
	if (!syntax) {
		syntax = getEmmetMode((mappedModes[parentMode] ? mappedModes[parentMode] : parentMode), excludedLanguages);
448 449
	}

450
	return syntax;
451
}