diff --git a/extensions/emmet/src/balance.ts b/extensions/emmet/src/balance.ts index 21ac3027d5e55..0862b20a60f68 100644 --- a/extensions/emmet/src/balance.ts +++ b/extensions/emmet/src/balance.ts @@ -4,11 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { HtmlNode } from 'EmmetNode'; -import { getHtmlNode, parseDocument, validate } from './util'; +import { getHtmlNodeLS, offsetRangeToSelection, toLSTextDocument, validate } from './util'; +import { parseMarkupDocument } from './parseMarkupDocument'; +import { TextDocument as LSTextDocument } from 'vscode-html-languageservice'; let balanceOutStack: Array = []; -let lastOut = false; let lastBalancedSelections: vscode.Selection[] = []; export function balanceOut() { @@ -24,53 +24,50 @@ function balance(out: boolean) { return; } const editor = vscode.window.activeTextEditor; - let rootNode = parseDocument(editor.document); - if (!rootNode) { + const document = toLSTextDocument(editor.document); + const htmlDocument = parseMarkupDocument(document); + if (!htmlDocument) { return; } - let getRangeFunction = out ? getRangeToBalanceOut : getRangeToBalanceIn; + const rangeFn = out ? getRangeToBalanceOut : getRangeToBalanceIn; let newSelections: vscode.Selection[] = []; editor.selections.forEach(selection => { - let range = getRangeFunction(editor.document, selection, rootNode); + const range = rangeFn(document, selection); newSelections.push(range); }); - if (areSameSelections(newSelections, editor.selections)) { - return; - } - + // check whether we are starting a balance elsewhere if (areSameSelections(lastBalancedSelections, editor.selections)) { + // we are not starting elsewhere, so use the stack as-is if (out) { - if (!balanceOutStack.length) { + // make sure we are able to expand outwards + if (!areSameSelections(editor.selections, newSelections)) { balanceOutStack.push(editor.selections); } - balanceOutStack.push(newSelections); - } else { - if (lastOut) { - balanceOutStack.pop(); - } - newSelections = balanceOutStack.pop() || newSelections; + } else if (balanceOutStack.length) { + newSelections = balanceOutStack.pop()!; } } else { - balanceOutStack = out ? [editor.selections, newSelections] : []; + // we are starting elsewhere, so reset the stack + balanceOutStack = out ? [editor.selections] : []; } - lastOut = out; - lastBalancedSelections = editor.selections = newSelections; + editor.selections = newSelections; + lastBalancedSelections = editor.selections; } -function getRangeToBalanceOut(document: vscode.TextDocument, selection: vscode.Selection, rootNode: HtmlNode): vscode.Selection { - let nodeToBalance = getHtmlNode(document, rootNode, selection.start, false); +function getRangeToBalanceOut(document: LSTextDocument, selection: vscode.Selection): vscode.Selection { + const nodeToBalance = getHtmlNodeLS(document, selection.start, false); if (!nodeToBalance) { return selection; } - if (!nodeToBalance.close) { - return new vscode.Selection(nodeToBalance.start, nodeToBalance.end); + if (!nodeToBalance.endTagStart || !nodeToBalance.startTagEnd) { + return offsetRangeToSelection(document, nodeToBalance.start, nodeToBalance.end); } - let innerSelection = new vscode.Selection(nodeToBalance.open.end, nodeToBalance.close.start); - let outerSelection = new vscode.Selection(nodeToBalance.start, nodeToBalance.end); + const innerSelection = offsetRangeToSelection(document, nodeToBalance.startTagEnd, nodeToBalance.endTagStart); + const outerSelection = offsetRangeToSelection(document, nodeToBalance.start, nodeToBalance.end); if (innerSelection.contains(selection) && !innerSelection.isEqual(selection)) { return innerSelection; @@ -81,34 +78,37 @@ function getRangeToBalanceOut(document: vscode.TextDocument, selection: vscode.S return selection; } -function getRangeToBalanceIn(document: vscode.TextDocument, selection: vscode.Selection, rootNode: HtmlNode): vscode.Selection { - let nodeToBalance = getHtmlNode(document, rootNode, selection.start, true); +function getRangeToBalanceIn(document: LSTextDocument, selection: vscode.Selection): vscode.Selection { + const nodeToBalance = getHtmlNodeLS(document, selection.start, true); if (!nodeToBalance) { return selection; } - if (nodeToBalance.close) { - const entireNodeSelected = selection.start.isEqual(nodeToBalance.start) && selection.end.isEqual(nodeToBalance.end); - const startInOpenTag = selection.start.isAfter(nodeToBalance.open.start) && selection.start.isBefore(nodeToBalance.open.end); - const startInCloseTag = selection.start.isAfter(nodeToBalance.close.start) && selection.start.isBefore(nodeToBalance.close.end); + const selectionStart = document.offsetAt(selection.start); + const selectionEnd = document.offsetAt(selection.end); + if (nodeToBalance.endTagStart !== undefined && nodeToBalance.startTagEnd !== undefined) { + const entireNodeSelected = selectionStart === nodeToBalance.start && selectionEnd === nodeToBalance.end; + const startInOpenTag = selectionStart > nodeToBalance.start && selectionStart < nodeToBalance.startTagEnd; + const startInCloseTag = selectionStart > nodeToBalance.endTagStart && selectionStart < nodeToBalance.end; if (entireNodeSelected || startInOpenTag || startInCloseTag) { - return new vscode.Selection(nodeToBalance.open.end, nodeToBalance.close.start); + return offsetRangeToSelection(document, nodeToBalance.startTagEnd, nodeToBalance.endTagStart); } } - if (!nodeToBalance.firstChild) { + if (!nodeToBalance.children.length) { return selection; } - if (selection.start.isEqual(nodeToBalance.firstChild.start) - && selection.end.isEqual(nodeToBalance.firstChild.end) - && nodeToBalance.firstChild.close) { - return new vscode.Selection(nodeToBalance.firstChild.open.end, nodeToBalance.firstChild.close.start); + const firstChild = nodeToBalance.children[0]; + if (selectionStart === firstChild.start + && selectionEnd === firstChild.end + && firstChild.endTagStart !== undefined + && firstChild.startTagEnd !== undefined) { + return offsetRangeToSelection(document, firstChild.startTagEnd, firstChild.endTagStart); } - return new vscode.Selection(nodeToBalance.firstChild.start, nodeToBalance.firstChild.end); - + return offsetRangeToSelection(document, firstChild.start, firstChild.end); } function areSameSelections(a: vscode.Selection[], b: vscode.Selection[]): boolean { @@ -121,4 +121,4 @@ function areSameSelections(a: vscode.Selection[], b: vscode.Selection[]): boolea } } return true; -} \ No newline at end of file +} diff --git a/extensions/emmet/src/emmetCommon.ts b/extensions/emmet/src/emmetCommon.ts index e768b03afd6f0..56e02a3b1aa38 100644 --- a/extensions/emmet/src/emmetCommon.ts +++ b/extensions/emmet/src/emmetCommon.ts @@ -17,8 +17,9 @@ import { fetchEditPoint } from './editPoint'; import { fetchSelectItem } from './selectItem'; import { evaluateMathExpression } from './evaluateMathExpression'; import { incrementDecrement } from './incrementDecrement'; -import { LANGUAGE_MODES, getMappingForIncludedLanguages, updateEmmetExtensionsPath, getPathBaseName } from './util'; +import { LANGUAGE_MODES, getMappingForIncludedLanguages, updateEmmetExtensionsPath, getPathBaseName, toLSTextDocument, getSyntaxes, getEmmetMode } from './util'; import { reflectCssValue } from './reflectCssValue'; +import { addFileToMarkupParseCache, removeFileFromMarkupParseCache } from './parseMarkupDocument'; export function activateEmmetExtension(context: vscode.ExtensionContext) { registerCompletionProviders(context); @@ -145,6 +146,20 @@ export function activateEmmetExtension(context: vscode.ExtensionContext) { updateEmmetExtensionsPath(true); } })); + + context.subscriptions.push(vscode.workspace.onDidOpenTextDocument((e) => { + const emmetMode = getEmmetMode(e.languageId, []) ?? ''; + if (getSyntaxes().markup.includes(emmetMode)) { + addFileToMarkupParseCache(toLSTextDocument(e)); + } + })); + + context.subscriptions.push(vscode.workspace.onDidCloseTextDocument((e) => { + const emmetMode = getEmmetMode(e.languageId, []) ?? ''; + if (getSyntaxes().markup.includes(emmetMode)) { + removeFileFromMarkupParseCache(toLSTextDocument(e)); + } + })); } /** diff --git a/extensions/emmet/src/matchTag.ts b/extensions/emmet/src/matchTag.ts index 1e5ead3fb87e9..f2b707d445762 100644 --- a/extensions/emmet/src/matchTag.ts +++ b/extensions/emmet/src/matchTag.ts @@ -4,9 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { HtmlNode } from 'EmmetNode'; -import { getHtmlNode, parseDocument, validate } from './util'; - +import { toLSTextDocument, validate, getHtmlNodeLS, offsetRangeToSelection } from './util'; +import { TextDocument as LSTextDocument } from 'vscode-html-languageservice'; export function matchTag() { if (!validate(false) || !vscode.window.activeTextEditor) { @@ -14,32 +13,37 @@ export function matchTag() { } const editor = vscode.window.activeTextEditor; - let rootNode: HtmlNode = parseDocument(editor.document); - if (!rootNode) { return; } + const document = toLSTextDocument(editor.document); let updatedSelections: vscode.Selection[] = []; editor.selections.forEach(selection => { - let updatedSelection = getUpdatedSelections(editor, selection.start, rootNode); + const updatedSelection = getUpdatedSelections(document, selection.start); if (updatedSelection) { updatedSelections.push(updatedSelection); } }); - if (updatedSelections.length > 0) { + if (updatedSelections.length) { editor.selections = updatedSelections; editor.revealRange(editor.selections[updatedSelections.length - 1]); } } -function getUpdatedSelections(editor: vscode.TextEditor, position: vscode.Position, rootNode: HtmlNode): vscode.Selection | undefined { - let currentNode = getHtmlNode(editor.document, rootNode, position, true); - if (!currentNode) { return; } +function getUpdatedSelections(document: LSTextDocument, position: vscode.Position): vscode.Selection | undefined { + const currentNode = getHtmlNodeLS(document, position, true); + if (!currentNode) { + return; + } + + const offset = document.offsetAt(position); // If no closing tag or cursor is between open and close tag, then no-op - if (!currentNode.close || (position.isAfter(currentNode.open.end) && position.isBefore(currentNode.close.start))) { + if (currentNode.endTagStart === undefined + || currentNode.startTagEnd === undefined + || (offset > currentNode.startTagEnd && offset < currentNode.endTagStart)) { return; } // Place cursor inside the close tag if cursor is inside the open tag, else place it inside the open tag - let finalPosition = position.isBeforeOrEqual(currentNode.open.end) ? currentNode.close.start.translate(0, 2) : currentNode.open.start.translate(0, 1); - return new vscode.Selection(finalPosition, finalPosition); -} \ No newline at end of file + const finalOffset = (offset <= currentNode.startTagEnd) ? currentNode.endTagStart + 2 : currentNode.start + 1; + return offsetRangeToSelection(document, finalOffset, finalOffset); +} diff --git a/extensions/emmet/src/parseMarkupDocument.ts b/extensions/emmet/src/parseMarkupDocument.ts new file mode 100644 index 0000000000000..a1e526056c87a --- /dev/null +++ b/extensions/emmet/src/parseMarkupDocument.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { HTMLDocument, TextDocument as LSTextDocument } from 'vscode-html-languageservice'; +import { getLanguageService } from './util'; + +type Pair = { + key: K; + value: V; +}; + +// Map(filename, Pair(fileVersion, parsedContent)) +const _parseCache = new Map | undefined>(); + +export function parseMarkupDocument(document: LSTextDocument, useCache: boolean = true): HTMLDocument { + const languageService = getLanguageService(); + const key = document.uri; + const result = _parseCache.get(key); + const documentVersion = document.version; + if (useCache && result) { + if (documentVersion === result.key) { + return result.value; + } + } + + const parsedDocument = languageService.parseHTMLDocument(document); + if (useCache) { + _parseCache.set(key, { key: documentVersion, value: parsedDocument }); + } + return parsedDocument; +} + +export function addFileToMarkupParseCache(document: LSTextDocument) { + const filename = document.uri; + _parseCache.set(filename, undefined); +} + +export function removeFileFromMarkupParseCache(document: LSTextDocument) { + const filename = document.uri; + _parseCache.delete(filename); +} diff --git a/extensions/emmet/src/splitJoinTag.ts b/extensions/emmet/src/splitJoinTag.ts index a5f1d255c8550..d2df5d6507821 100644 --- a/extensions/emmet/src/splitJoinTag.ts +++ b/extensions/emmet/src/splitJoinTag.ts @@ -4,8 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { HtmlNode } from 'EmmetNode'; -import { getHtmlNode, parseDocument, validate, getEmmetMode, getEmmetConfiguration } from './util'; +import { validate, getEmmetMode, getEmmetConfiguration, toLSTextDocument, getHtmlNodeLS, offsetRangeToVsRange } from './util'; +import { Node as LSNode, TextDocument as LSTextDocument } from 'vscode-html-languageservice'; export function splitJoinTag() { if (!validate(false) || !vscode.window.activeTextEditor) { @@ -13,40 +13,42 @@ export function splitJoinTag() { } const editor = vscode.window.activeTextEditor; - let rootNode = parseDocument(editor.document); - if (!rootNode) { - return; - } - + const document = toLSTextDocument(editor.document); return editor.edit(editBuilder => { editor.selections.reverse().forEach(selection => { - let nodeToUpdate = getHtmlNode(editor.document, rootNode, selection.start, true); + const nodeToUpdate = getHtmlNodeLS(document, selection.start, true); if (nodeToUpdate) { - let textEdit = getRangesToReplace(editor.document, nodeToUpdate); - editBuilder.replace(textEdit.range, textEdit.newText); + const textEdit = getRangesToReplace(document, nodeToUpdate); + if (textEdit) { + editBuilder.replace(textEdit.range, textEdit.newText); + } } }); }); } -function getRangesToReplace(document: vscode.TextDocument, nodeToUpdate: HtmlNode): vscode.TextEdit { +function getRangesToReplace(document: LSTextDocument, nodeToUpdate: LSNode): vscode.TextEdit | undefined { let rangeToReplace: vscode.Range; let textToReplaceWith: string; - if (!nodeToUpdate.close) { + if (!nodeToUpdate?.tag) { + return; + } + + if (nodeToUpdate.endTagStart === undefined || nodeToUpdate.startTagEnd === undefined) { // Split Tag - let nodeText = document.getText(new vscode.Range(nodeToUpdate.start, nodeToUpdate.end)); - let m = nodeText.match(/(\s*\/)?>$/); - let end = nodeToUpdate.end; - let start = m ? end.translate(0, -m[0].length) : end; + const nodeText = document.getText().substring(nodeToUpdate.start, nodeToUpdate.end); + const m = nodeText.match(/(\s*\/)?>$/); + const end = nodeToUpdate.end; + const start = m ? end - m[0].length : end; - rangeToReplace = new vscode.Range(start, end); - textToReplaceWith = `>`; + rangeToReplace = offsetRangeToVsRange(document, start, end); + textToReplaceWith = `>`; } else { // Join Tag - let start = (nodeToUpdate.open.end).translate(0, -1); - let end = nodeToUpdate.end; - rangeToReplace = new vscode.Range(start, end); + const start = nodeToUpdate.startTagEnd - 1; + const end = nodeToUpdate.end; + rangeToReplace = offsetRangeToVsRange(document, start, end); textToReplaceWith = '/>'; const emmetMode = getEmmetMode(document.languageId, []) || ''; @@ -55,8 +57,7 @@ function getRangesToReplace(document: vscode.TextDocument, nodeToUpdate: HtmlNod (emmetConfig.syntaxProfiles[emmetMode]['selfClosingStyle'] === 'xhtml' || emmetConfig.syntaxProfiles[emmetMode]['self_closing_tag'] === 'xhtml')) { textToReplaceWith = ' ' + textToReplaceWith; } - } return new vscode.TextEdit(rangeToReplace, textToReplaceWith); -} \ No newline at end of file +} diff --git a/extensions/emmet/src/updateTag.ts b/extensions/emmet/src/updateTag.ts index b8f568e9e524b..a2f9b5a66e0e7 100644 --- a/extensions/emmet/src/updateTag.ts +++ b/extensions/emmet/src/updateTag.ts @@ -4,23 +4,18 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { HtmlNode } from 'EmmetNode'; -import { getHtmlNode, parseDocument, validate } from './util'; +import { getHtmlNodeLS, toLSTextDocument, validate } from './util'; +import { TextDocument as LSTextDocument, Node as LSNode } from 'vscode-html-languageservice'; export function updateTag(tagName: string): Thenable | undefined { if (!validate(false) || !vscode.window.activeTextEditor) { return; } - let editor = vscode.window.activeTextEditor; - let rootNode = parseDocument(editor.document); - if (!rootNode) { - return; - } - let rangesToUpdate: vscode.Range[] = []; - editor.selections.reverse().forEach(selection => { - rangesToUpdate = rangesToUpdate.concat(getRangesToUpdate(editor, selection, rootNode)); - }); + const editor = vscode.window.activeTextEditor; + const rangesToUpdate = editor.selections.reverse() + .reduce((prev, selection) => + prev.concat(getRangesToUpdate(editor, selection)), []); return editor.edit(editBuilder => { rangesToUpdate.forEach(range => { @@ -29,22 +24,36 @@ export function updateTag(tagName: string): Thenable | undefined { }); } -function getRangesToUpdate(editor: vscode.TextEditor, selection: vscode.Selection, rootNode: HtmlNode): vscode.Range[] { - let nodeToUpdate = getHtmlNode(editor.document, rootNode, selection.start, true); - if (!nodeToUpdate) { - return []; +function getPositionFromOffset(offset: number | undefined, document: LSTextDocument): vscode.Position | undefined { + if (offset === undefined) { + return undefined; } + const pos = document.positionAt(offset); + return new vscode.Position(pos.line, pos.character); +} - let openStart = nodeToUpdate.open.start.translate(0, 1); - let openEnd = openStart.translate(0, nodeToUpdate.name.length); +function getRangesFromNode(node: LSNode, document: LSTextDocument): vscode.Range[] { + const start = getPositionFromOffset(node.start, document)!; + const startTagEnd = getPositionFromOffset(node.startTagEnd, document); + const end = getPositionFromOffset(node.end, document)!; + const endTagStart = getPositionFromOffset(node.endTagStart, document); - let ranges = [new vscode.Range(openStart, openEnd)]; - if (nodeToUpdate.close) { - let closeStart = nodeToUpdate.close.start.translate(0, 2); - let closeEnd = nodeToUpdate.close.end.translate(0, -1); - ranges.push(new vscode.Range(closeStart, closeEnd)); + let ranges: vscode.Range[] = []; + if (startTagEnd) { + ranges.push(new vscode.Range(start.translate(0, 1), + start.translate(0, 1).translate(0, node.tag!.length ?? 0))); + } + if (endTagStart) { + ranges.push(new vscode.Range(endTagStart.translate(0, 2), end.translate(0, -1))); } return ranges; } - +function getRangesToUpdate(editor: vscode.TextEditor, selection: vscode.Selection): vscode.Range[] { + const document = toLSTextDocument(editor.document); + const nodeToUpdate = getHtmlNodeLS(document, selection.start, true); + if (!nodeToUpdate) { + return []; + } + return getRangesFromNode(nodeToUpdate, document); +} diff --git a/extensions/emmet/src/util.ts b/extensions/emmet/src/util.ts index f2b72548a5f49..259a30292d8c2 100644 --- a/extensions/emmet/src/util.ts +++ b/extensions/emmet/src/util.ts @@ -9,9 +9,11 @@ import parseStylesheet from '@emmetio/css-parser'; import { Node, HtmlNode, CssToken, Property, Rule, Stylesheet } from 'EmmetNode'; import { DocumentStreamReader } from './bufferStream'; import * as EmmetHelper from 'vscode-emmet-helper'; -import { TextDocument as LSTextDocument } from 'vscode-html-languageservice'; +import { Position as LSPosition, getLanguageService as getLanguageServiceInternal, LanguageService, LanguageServiceOptions, TextDocument as LSTextDocument, Node as LSNode } from 'vscode-html-languageservice'; +import { parseMarkupDocument } from './parseMarkupDocument'; let _emmetHelper: typeof EmmetHelper; +let _languageService: LanguageService; let _currentExtensionsPath: string | undefined = undefined; let _homeDir: vscode.Uri | undefined; @@ -21,7 +23,6 @@ export function setHomeDir(homeDir: vscode.Uri) { _homeDir = homeDir; } - export function getEmmetHelper() { // Lazy load vscode-emmet-helper instead of importing it // directly to reduce the start-up time of the extension @@ -32,6 +33,16 @@ export function getEmmetHelper() { return _emmetHelper; } +export function getLanguageService(options?: LanguageServiceOptions): LanguageService { + if (!options) { + if (!_languageService) { + _languageService = getLanguageServiceInternal(); + } + return _languageService; + } + return getLanguageServiceInternal(options); +} + /** * Update Emmet Helper to use user snippets from the extensionsPath setting */ @@ -130,8 +141,8 @@ export function getEmmetMode(language: string, excludedLanguages: string[]): str if (language === 'jade') { return 'pug'; } - const emmetModes = ['html', 'pug', 'slim', 'haml', 'xml', 'xsl', 'jsx', 'css', 'scss', 'sass', 'less', 'stylus']; - if (emmetModes.indexOf(language) > -1) { + const syntaxes = getSyntaxes(); + if (syntaxes.markup.includes(language) || syntaxes.stylesheet.includes(language)) { return language; } return; @@ -366,6 +377,78 @@ export function getHtmlNode(document: vscode.TextDocument, root: Node | undefine return currentNode; } +/** + * Finds the HTML node within an HTML document at a given position + */ +export function getHtmlNodeLS(document: LSTextDocument, position: vscode.Position, includeNodeBoundary: boolean): LSNode | undefined { + const documentText = document.getText(); + const offset = document.offsetAt(position); + let selectionStartOffset = offset; + if (includeNodeBoundary && documentText.charAt(offset) === '<') { + selectionStartOffset++; + } + else if (includeNodeBoundary && documentText.charAt(offset) === '>') { + selectionStartOffset--; + } + return getHtmlNodeLSInternal(document, selectionStartOffset); +} + +function getHtmlNodeLSInternal(document: LSTextDocument, offset: number, isInTemplateNode: boolean = false): LSNode | undefined { + const useCache = !isInTemplateNode; + const parsedDocument = parseMarkupDocument(document, useCache); + + const currentNode: LSNode = parsedDocument.findNodeAt(offset); + if (!currentNode.tag) { return; } + + const isTemplateScript = isNodeTemplateScriptLS(currentNode); + if (isTemplateScript + && currentNode.startTagEnd + && offset > currentNode.startTagEnd + && (!currentNode.endTagStart || offset < currentNode.endTagStart)) { + // blank out the rest of the document and search for the node within + const documentText = document.getText(); + const beforePadding = ' '.repeat(currentNode.startTagEnd); + const scriptBodyText = beforePadding + documentText.substring(currentNode.startTagEnd, currentNode.endTagStart ?? currentNode.end); + const scriptBodyDocument = LSTextDocument.create(document.uri, document.languageId, document.version, scriptBodyText); + const scriptBodyNode = getHtmlNodeLSInternal(scriptBodyDocument, offset, true); + if (scriptBodyNode) { + scriptBodyNode.parent = currentNode; + currentNode.children.push(scriptBodyNode); + return scriptBodyNode; + } + } + return currentNode; +} + +/** + * Returns whether the node is a