diff --git a/.gitignore b/.gitignore index ddf23ee..5960167 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules *.js *.map *.tgz +!packages/emmet/lib/typings/**/* diff --git a/packages/emmet/index.ts b/packages/emmet/index.ts index 2dd8490..f01bd06 100644 --- a/packages/emmet/index.ts +++ b/packages/emmet/index.ts @@ -1,106 +1,210 @@ -import type { LanguageServicePluginInstance, LanguageServicePlugin, TextDocument } from '@volar/language-service'; -import * as emmet from '@vscode/emmet-helper'; -import * as html from 'vscode-html-languageservice'; - -const htmlDocuments = new WeakMap(); - -let htmlLs: html.LanguageService; - -function getHtmlDocument(document: TextDocument) { - - const cache = htmlDocuments.get(document); - if (cache) { - const [cacheVersion, cacheDoc] = cache; - if (cacheVersion === document.version) { - return cacheDoc; - } - } - - htmlLs ??= html.getLanguageService(); - - const doc = htmlLs.parseHTMLDocument(document); - htmlDocuments.set(document, [document.version, doc]); - - return doc; -} +import type * as vscode from '@volar/language-service'; +import type * as helper from '@vscode/emmet-helper'; +import type { Node, Stylesheet } from 'EmmetFlatNode'; +import { getRootNode } from './lib/parseDocument'; +import { allowedMimeTypesInScriptTag, getEmbeddedCssNodeIfAny, getEmmetConfiguration, getEmmetHelper, getEmmetMode, getFlatNode, getHtmlFlatNode, isStyleSheet, parsePartialStylesheet } from './lib/util'; +import { getSyntaxFromArgs, isValidLocationForEmmetAbbreviation } from './lib/abbreviationActions'; export function create({ - mappedModes = {}, + mappedLanguages = {}, }: { - mappedModes?: Record; -} = {}): LanguageServicePlugin { + mappedLanguages?: Record; +} = {}): vscode.LanguageServicePlugin { return { name: 'emmet', // https://docs.emmet.io/abbreviations/syntax/ triggerCharacters: '>+^*()#.[]$@-{}'.split(''), - create(context): LanguageServicePluginInstance { + create(context): vscode.LanguageServicePluginInstance { + + let lastCompletionType: string | undefined; return { isAdditionalCompletion: true, - async provideCompletionItems(textDocument, position) { - - const syntax = emmet.getEmmetMode(mappedModes[textDocument.languageId] ?? textDocument.languageId); - if (!syntax) { + async provideCompletionItems(document, position, completionContext) { + const completionResult = provideCompletionItemsInternal(document, position, completionContext); + if (!completionResult) { + lastCompletionType = undefined; return; } - // fix https://github.com/vuejs/language-tools/issues/1329 - if (syntax === 'html') { - const htmlDocument = getHtmlDocument(textDocument); - const node = htmlDocument.findNodeAt(textDocument.offsetAt(position)); - if (node.tag) { - let insideBlock = false; - if (node.startTagEnd !== undefined && node.endTagStart !== undefined) { - insideBlock = textDocument.offsetAt(position) >= node.startTagEnd && textDocument.offsetAt(position) <= node.endTagStart; + return completionResult.then(completionList => { + if (!completionList || !completionList.items.length) { + lastCompletionType = undefined; + return completionList; + } + const item = completionList.items[0]; + const expandedText = item.documentation ? item.documentation.toString() : ''; + + if (expandedText.startsWith('<')) { + lastCompletionType = 'html'; + } else if (expandedText.indexOf(':') > 0 && expandedText.endsWith(';')) { + lastCompletionType = 'css'; + } else { + lastCompletionType = undefined; + } + return completionList; + }); + }, + }; + + async function provideCompletionItemsInternal(document: vscode.TextDocument, position: vscode.Position, completionContext: vscode.CompletionContext) { + + const emmetConfig: any = await context.env.getConfiguration?.('emmet') ?? {}; + const excludedLanguages = emmetConfig['excludeLanguages'] ?? []; + if (excludedLanguages.includes(document.languageId)) { + return; + } + + const isSyntaxMapped = mappedLanguages[document.languageId] ? true : false; + const emmetMode = getEmmetMode(mappedLanguages[document.languageId] ?? document.languageId, mappedLanguages, excludedLanguages); + if (!emmetMode + || emmetConfig['showExpandedAbbreviation'] === 'never' + || ((isSyntaxMapped || emmetMode === 'jsx') && emmetConfig['showExpandedAbbreviation'] !== 'always')) { + return; + } + + let syntax = emmetMode; + + let validateLocation = syntax === 'html' || syntax === 'jsx' || syntax === 'xml'; + let rootNode: Node | undefined; + let currentNode: Node | undefined; + + // Don't show completions if there's a comment at the beginning of the line + const lineRange: vscode.Range = { + start: { line: position.line, character: 0 }, + end: position, + }; + if (document.getText(lineRange).trimStart().startsWith('//')) { + return; + } + + const helper = getEmmetHelper(); + if (syntax === 'html') { + if (completionContext.triggerKind === 3 satisfies typeof vscode.CompletionTriggerKind.TriggerForIncompleteCompletions) { + switch (lastCompletionType) { + case 'html': + validateLocation = false; + break; + case 'css': + validateLocation = false; + syntax = 'css'; + break; + default: + break; + } + } + if (validateLocation) { + const positionOffset = document.offsetAt(position); + const emmetRootNode = getRootNode(document, true); + const foundNode = getHtmlFlatNode(document.getText(), emmetRootNode, positionOffset, false); + if (foundNode) { + if (foundNode.name === 'script') { + const typeNode = foundNode.attributes.find(attr => attr.name.toString() === 'type'); + if (typeNode) { + const typeAttrValue = typeNode.value.toString(); + if (typeAttrValue === 'application/javascript' || typeAttrValue === 'text/javascript') { + if (!await getSyntaxFromArgs(context, { language: 'javascript' })) { + return; + } else { + validateLocation = false; + } + } + else if (allowedMimeTypesInScriptTag.includes(typeAttrValue)) { + validateLocation = false; + } + } else { + return; + } } - if (!insideBlock) { - return; + else if (foundNode.name === 'style') { + syntax = 'css'; + validateLocation = false; + } else { + const styleNode = foundNode.attributes.find(attr => attr.name.toString() === 'style'); + if (styleNode && styleNode.value.start <= positionOffset && positionOffset <= styleNode.value.end) { + syntax = 'css'; + validateLocation = false; + } } } } + } - // monkey fix https://github.com/johnsoncodehk/volar/issues/1105 - if (syntax === 'jsx') { + const expandOptions = isStyleSheet(syntax) ? + { lookAhead: false, syntax: 'stylesheet' } : + { lookAhead: true, syntax: 'markup' }; + const extractAbbreviationResults = helper.extractAbbreviation(document, position, expandOptions); + if (!extractAbbreviationResults || !helper.isAbbreviationValid(syntax, extractAbbreviationResults.abbreviation)) { + return; + } + + const offset = document.offsetAt(position); + if (isStyleSheet(document.languageId) && completionContext.triggerKind !== 3 satisfies typeof vscode.CompletionTriggerKind.TriggerForIncompleteCompletions) { + validateLocation = true; + const usePartialParsing = await context.env.getConfiguration?.('emmet.optimizeStylesheetParsing') === true; + rootNode = usePartialParsing && document.lineCount > 1000 ? parsePartialStylesheet(document, position) : getRootNode(document, true); + if (!rootNode) { return; } + currentNode = getFlatNode(rootNode, offset, true); + } - const emmetConfig = await getEmmetConfig(syntax); + // Fix for https://github.com/microsoft/vscode/issues/107578 + // Validate location if syntax is of styleSheet type to ensure that location is valid for emmet abbreviation. + // For an html document containing a