/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ (function (factory) { if (typeof module === "object" && typeof module.exports === "object") { var v = factory(require, exports); if (v !== undefined) module.exports = v; } else if (typeof define === "function" && define.amd) { define(["require", "exports", "../parser/htmlScanner", "../htmlLanguageTypes", "../parser/htmlEntities", "@vscode/l10n", "../utils/strings", "../utils/object", "../languageFacts/dataProvider", "./pathCompletion"], factory); } })(function (require, exports) { "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.HTMLCompletion = void 0; const htmlScanner_1 = require("../parser/htmlScanner"); const htmlLanguageTypes_1 = require("../htmlLanguageTypes"); const htmlEntities_1 = require("../parser/htmlEntities"); const l10n = require("@vscode/l10n"); const strings_1 = require("../utils/strings"); const object_1 = require("../utils/object"); const dataProvider_1 = require("../languageFacts/dataProvider"); const pathCompletion_1 = require("./pathCompletion"); class HTMLCompletion { constructor(lsOptions, dataManager) { this.lsOptions = lsOptions; this.dataManager = dataManager; this.completionParticipants = []; } setCompletionParticipants(registeredCompletionParticipants) { this.completionParticipants = registeredCompletionParticipants || []; } async doComplete2(document, position, htmlDocument, documentContext, settings) { if (!this.lsOptions.fileSystemProvider || !this.lsOptions.fileSystemProvider.readDirectory) { return this.doComplete(document, position, htmlDocument, settings); } const participant = new pathCompletion_1.PathCompletionParticipant(this.dataManager, this.lsOptions.fileSystemProvider.readDirectory); const contributedParticipants = this.completionParticipants; this.completionParticipants = [participant].concat(contributedParticipants); const result = this.doComplete(document, position, htmlDocument, settings); try { const pathCompletionResult = await participant.computeCompletions(document, documentContext); return { isIncomplete: result.isIncomplete || pathCompletionResult.isIncomplete, items: pathCompletionResult.items.concat(result.items) }; } finally { this.completionParticipants = contributedParticipants; } } doComplete(document, position, htmlDocument, settings) { const result = this._doComplete(document, position, htmlDocument, settings); return this.convertCompletionList(result); } _doComplete(document, position, htmlDocument, settings) { const result = { isIncomplete: false, items: [] }; const completionParticipants = this.completionParticipants; const dataProviders = this.dataManager.getDataProviders().filter(p => p.isApplicable(document.languageId) && (!settings || settings[p.getId()] !== false)); const voidElements = this.dataManager.getVoidElements(dataProviders); const doesSupportMarkdown = this.doesSupportMarkdown(); const text = document.getText(); const offset = document.offsetAt(position); const node = htmlDocument.findNodeBefore(offset); if (!node) { return result; } const scanner = (0, htmlScanner_1.createScanner)(text, node.start); let currentTag = ''; let currentAttributeName; function getReplaceRange(replaceStart, replaceEnd = offset) { if (replaceStart > offset) { replaceStart = offset; } return { start: document.positionAt(replaceStart), end: document.positionAt(replaceEnd) }; } function collectOpenTagSuggestions(afterOpenBracket, tagNameEnd) { const range = getReplaceRange(afterOpenBracket, tagNameEnd); dataProviders.forEach((provider) => { provider.provideTags().forEach(tag => { result.items.push({ label: tag.name, kind: htmlLanguageTypes_1.CompletionItemKind.Property, documentation: (0, dataProvider_1.generateDocumentation)(tag, undefined, doesSupportMarkdown), textEdit: htmlLanguageTypes_1.TextEdit.replace(range, tag.name), insertTextFormat: htmlLanguageTypes_1.InsertTextFormat.PlainText }); }); }); return result; } function getLineIndent(offset) { let start = offset; while (start > 0) { const ch = text.charAt(start - 1); if ("\n\r".indexOf(ch) >= 0) { return text.substring(start, offset); } if (!isWhiteSpace(ch)) { return null; } start--; } return text.substring(0, offset); } function collectCloseTagSuggestions(afterOpenBracket, inOpenTag, tagNameEnd = offset) { const range = getReplaceRange(afterOpenBracket, tagNameEnd); const closeTag = isFollowedBy(text, tagNameEnd, htmlLanguageTypes_1.ScannerState.WithinEndTag, htmlLanguageTypes_1.TokenType.EndTagClose) ? '' : '>'; let curr = node; if (inOpenTag) { curr = curr.parent; // don't suggest the own tag, it's not yet open } while (curr) { const tag = curr.tag; if (tag && (!curr.closed || curr.endTagStart && (curr.endTagStart > offset))) { const item = { label: '/' + tag, kind: htmlLanguageTypes_1.CompletionItemKind.Property, filterText: '/' + tag, textEdit: htmlLanguageTypes_1.TextEdit.replace(range, '/' + tag + closeTag), insertTextFormat: htmlLanguageTypes_1.InsertTextFormat.PlainText }; const startIndent = getLineIndent(curr.start); const endIndent = getLineIndent(afterOpenBracket - 1); if (startIndent !== null && endIndent !== null && startIndent !== endIndent) { const insertText = startIndent + ' { provider.provideTags().forEach(tag => { result.items.push({ label: '/' + tag.name, kind: htmlLanguageTypes_1.CompletionItemKind.Property, documentation: (0, dataProvider_1.generateDocumentation)(tag, undefined, doesSupportMarkdown), filterText: '/' + tag.name + closeTag, textEdit: htmlLanguageTypes_1.TextEdit.replace(range, '/' + tag.name + closeTag), insertTextFormat: htmlLanguageTypes_1.InsertTextFormat.PlainText }); }); }); return result; } const collectAutoCloseTagSuggestion = (tagCloseEnd, tag) => { if (settings && settings.hideAutoCompleteProposals) { return result; } if (!this.dataManager.isVoidElement(tag, voidElements)) { const pos = document.positionAt(tagCloseEnd); result.items.push({ label: '', kind: htmlLanguageTypes_1.CompletionItemKind.Property, filterText: '', textEdit: htmlLanguageTypes_1.TextEdit.insert(pos, '$0'), insertTextFormat: htmlLanguageTypes_1.InsertTextFormat.Snippet }); } return result; }; function collectTagSuggestions(tagStart, tagEnd) { collectOpenTagSuggestions(tagStart, tagEnd); collectCloseTagSuggestions(tagStart, true, tagEnd); return result; } function getExistingAttributes() { const existingAttributes = Object.create(null); node.attributeNames.forEach(attribute => { existingAttributes[attribute] = true; }); return existingAttributes; } function collectAttributeNameSuggestions(nameStart, nameEnd = offset) { let replaceEnd = offset; while (replaceEnd < nameEnd && text[replaceEnd] !== '<') { // < is a valid attribute name character, but we rather assume the attribute name ends. See #23236. replaceEnd++; } const currentAttribute = text.substring(nameStart, nameEnd); const range = getReplaceRange(nameStart, replaceEnd); let value = ''; if (!isFollowedBy(text, nameEnd, htmlLanguageTypes_1.ScannerState.AfterAttributeName, htmlLanguageTypes_1.TokenType.DelimiterAssign)) { const defaultValue = settings?.attributeDefaultValue ?? 'doublequotes'; if (defaultValue === 'empty') { value = '=$1'; } else if (defaultValue === 'singlequotes') { value = '=\'$1\''; } else { value = '="$1"'; } } const seenAttributes = getExistingAttributes(); // include current typing attribute seenAttributes[currentAttribute] = false; dataProviders.forEach(provider => { provider.provideAttributes(currentTag).forEach(attr => { if (seenAttributes[attr.name]) { return; } seenAttributes[attr.name] = true; let codeSnippet = attr.name; let command; if (attr.valueSet !== 'v' && value.length) { codeSnippet = codeSnippet + value; if (attr.valueSet || attr.name === 'style') { command = { title: 'Suggest', command: 'editor.action.triggerSuggest' }; } } result.items.push({ label: attr.name, kind: attr.valueSet === 'handler' ? htmlLanguageTypes_1.CompletionItemKind.Function : htmlLanguageTypes_1.CompletionItemKind.Value, documentation: (0, dataProvider_1.generateDocumentation)(attr, undefined, doesSupportMarkdown), textEdit: htmlLanguageTypes_1.TextEdit.replace(range, codeSnippet), insertTextFormat: htmlLanguageTypes_1.InsertTextFormat.Snippet, command }); }); }); collectDataAttributesSuggestions(range, seenAttributes); return result; } function collectDataAttributesSuggestions(range, seenAttributes) { const dataAttr = 'data-'; const dataAttributes = {}; dataAttributes[dataAttr] = `${dataAttr}$1="$2"`; function addNodeDataAttributes(node) { node.attributeNames.forEach(attr => { if ((0, strings_1.startsWith)(attr, dataAttr) && !dataAttributes[attr] && !seenAttributes[attr]) { dataAttributes[attr] = attr + '="$1"'; } }); node.children.forEach(child => addNodeDataAttributes(child)); } if (htmlDocument) { htmlDocument.roots.forEach(root => addNodeDataAttributes(root)); } Object.keys(dataAttributes).forEach(attr => result.items.push({ label: attr, kind: htmlLanguageTypes_1.CompletionItemKind.Value, textEdit: htmlLanguageTypes_1.TextEdit.replace(range, dataAttributes[attr]), insertTextFormat: htmlLanguageTypes_1.InsertTextFormat.Snippet })); } function collectAttributeValueSuggestions(valueStart, valueEnd = offset) { let range; let addQuotes; let valuePrefix; if (offset > valueStart && offset <= valueEnd && isQuote(text[valueStart])) { // inside quoted attribute const valueContentStart = valueStart + 1; let valueContentEnd = valueEnd; // valueEnd points to the char after quote, which encloses the replace range if (valueEnd > valueStart && text[valueEnd - 1] === text[valueStart]) { valueContentEnd--; } const wsBefore = getWordStart(text, offset, valueContentStart); const wsAfter = getWordEnd(text, offset, valueContentEnd); range = getReplaceRange(wsBefore, wsAfter); valuePrefix = offset >= valueContentStart && offset <= valueContentEnd ? text.substring(valueContentStart, offset) : ''; addQuotes = false; } else { range = getReplaceRange(valueStart, valueEnd); valuePrefix = text.substring(valueStart, offset); addQuotes = true; } if (completionParticipants.length > 0) { const tag = currentTag.toLowerCase(); const attribute = currentAttributeName.toLowerCase(); const fullRange = getReplaceRange(valueStart, valueEnd); for (const participant of completionParticipants) { if (participant.onHtmlAttributeValue) { participant.onHtmlAttributeValue({ document, position, tag, attribute, value: valuePrefix, range: fullRange }); } } } dataProviders.forEach(provider => { provider.provideValues(currentTag, currentAttributeName).forEach(value => { const insertText = addQuotes ? '"' + value.name + '"' : value.name; result.items.push({ label: value.name, filterText: insertText, kind: htmlLanguageTypes_1.CompletionItemKind.Unit, documentation: (0, dataProvider_1.generateDocumentation)(value, undefined, doesSupportMarkdown), textEdit: htmlLanguageTypes_1.TextEdit.replace(range, insertText), insertTextFormat: htmlLanguageTypes_1.InsertTextFormat.PlainText }); }); }); collectCharacterEntityProposals(); return result; } function scanNextForEndPos(nextToken) { if (offset === scanner.getTokenEnd()) { token = scanner.scan(); if (token === nextToken && scanner.getTokenOffset() === offset) { return scanner.getTokenEnd(); } } return offset; } function collectInsideContent() { for (const participant of completionParticipants) { if (participant.onHtmlContent) { participant.onHtmlContent({ document, position }); } } return collectCharacterEntityProposals(); } function collectCharacterEntityProposals() { // character entities let k = offset - 1; let characterStart = position.character; while (k >= 0 && (0, strings_1.isLetterOrDigit)(text, k)) { k--; characterStart--; } if (k >= 0 && text[k] === '&') { const range = htmlLanguageTypes_1.Range.create(htmlLanguageTypes_1.Position.create(position.line, characterStart - 1), position); for (const entity in htmlEntities_1.entities) { if ((0, strings_1.endsWith)(entity, ';')) { const label = '&' + entity; result.items.push({ label, kind: htmlLanguageTypes_1.CompletionItemKind.Keyword, documentation: l10n.t('Character entity representing \'{0}\'', htmlEntities_1.entities[entity]), textEdit: htmlLanguageTypes_1.TextEdit.replace(range, label), insertTextFormat: htmlLanguageTypes_1.InsertTextFormat.PlainText }); } } } return result; } function suggestDoctype(replaceStart, replaceEnd) { const range = getReplaceRange(replaceStart, replaceEnd); result.items.push({ label: '!DOCTYPE', kind: htmlLanguageTypes_1.CompletionItemKind.Property, documentation: 'A preamble for an HTML document.', textEdit: htmlLanguageTypes_1.TextEdit.replace(range, '!DOCTYPE html>'), insertTextFormat: htmlLanguageTypes_1.InsertTextFormat.PlainText }); } let token = scanner.scan(); while (token !== htmlLanguageTypes_1.TokenType.EOS && scanner.getTokenOffset() <= offset) { switch (token) { case htmlLanguageTypes_1.TokenType.StartTagOpen: if (scanner.getTokenEnd() === offset) { const endPos = scanNextForEndPos(htmlLanguageTypes_1.TokenType.StartTag); if (position.line === 0) { suggestDoctype(offset, endPos); } return collectTagSuggestions(offset, endPos); } break; case htmlLanguageTypes_1.TokenType.StartTag: if (scanner.getTokenOffset() <= offset && offset <= scanner.getTokenEnd()) { return collectOpenTagSuggestions(scanner.getTokenOffset(), scanner.getTokenEnd()); } currentTag = scanner.getTokenText(); break; case htmlLanguageTypes_1.TokenType.AttributeName: if (scanner.getTokenOffset() <= offset && offset <= scanner.getTokenEnd()) { return collectAttributeNameSuggestions(scanner.getTokenOffset(), scanner.getTokenEnd()); } currentAttributeName = scanner.getTokenText(); break; case htmlLanguageTypes_1.TokenType.DelimiterAssign: if (scanner.getTokenEnd() === offset) { const endPos = scanNextForEndPos(htmlLanguageTypes_1.TokenType.AttributeValue); return collectAttributeValueSuggestions(offset, endPos); } break; case htmlLanguageTypes_1.TokenType.AttributeValue: if (scanner.getTokenOffset() <= offset && offset <= scanner.getTokenEnd()) { return collectAttributeValueSuggestions(scanner.getTokenOffset(), scanner.getTokenEnd()); } break; case htmlLanguageTypes_1.TokenType.Whitespace: if (offset <= scanner.getTokenEnd()) { switch (scanner.getScannerState()) { case htmlLanguageTypes_1.ScannerState.AfterOpeningStartTag: const startPos = scanner.getTokenOffset(); const endTagPos = scanNextForEndPos(htmlLanguageTypes_1.TokenType.StartTag); return collectTagSuggestions(startPos, endTagPos); case htmlLanguageTypes_1.ScannerState.WithinTag: case htmlLanguageTypes_1.ScannerState.AfterAttributeName: return collectAttributeNameSuggestions(scanner.getTokenEnd()); case htmlLanguageTypes_1.ScannerState.BeforeAttributeValue: return collectAttributeValueSuggestions(scanner.getTokenEnd()); case htmlLanguageTypes_1.ScannerState.AfterOpeningEndTag: return collectCloseTagSuggestions(scanner.getTokenOffset() - 1, false); case htmlLanguageTypes_1.ScannerState.WithinContent: return collectInsideContent(); } } break; case htmlLanguageTypes_1.TokenType.EndTagOpen: if (offset <= scanner.getTokenEnd()) { const afterOpenBracket = scanner.getTokenOffset() + 1; const endOffset = scanNextForEndPos(htmlLanguageTypes_1.TokenType.EndTag); return collectCloseTagSuggestions(afterOpenBracket, false, endOffset); } break; case htmlLanguageTypes_1.TokenType.EndTag: if (offset <= scanner.getTokenEnd()) { let start = scanner.getTokenOffset() - 1; while (start >= 0) { const ch = text.charAt(start); if (ch === '/') { return collectCloseTagSuggestions(start, false, scanner.getTokenEnd()); } else if (!isWhiteSpace(ch)) { break; } start--; } } break; case htmlLanguageTypes_1.TokenType.StartTagClose: if (offset <= scanner.getTokenEnd()) { if (currentTag) { return collectAutoCloseTagSuggestion(scanner.getTokenEnd(), currentTag); } } break; case htmlLanguageTypes_1.TokenType.Content: if (offset <= scanner.getTokenEnd()) { return collectInsideContent(); } break; default: if (offset <= scanner.getTokenEnd()) { return result; } break; } token = scanner.scan(); } return result; } doQuoteComplete(document, position, htmlDocument, settings) { const offset = document.offsetAt(position); if (offset <= 0) { return null; } const defaultValue = settings?.attributeDefaultValue ?? 'doublequotes'; if (defaultValue === 'empty') { return null; } const char = document.getText().charAt(offset - 1); if (char !== '=') { return null; } const value = defaultValue === 'doublequotes' ? '"$1"' : '\'$1\''; const node = htmlDocument.findNodeBefore(offset); if (node && node.attributes && node.start < offset && (!node.endTagStart || node.endTagStart > offset)) { const scanner = (0, htmlScanner_1.createScanner)(document.getText(), node.start); let token = scanner.scan(); while (token !== htmlLanguageTypes_1.TokenType.EOS && scanner.getTokenEnd() <= offset) { if (token === htmlLanguageTypes_1.TokenType.AttributeName && scanner.getTokenEnd() === offset - 1) { // Ensure the token is a valid standalone attribute name token = scanner.scan(); // this should be the = just written if (token !== htmlLanguageTypes_1.TokenType.DelimiterAssign) { return null; } token = scanner.scan(); // Any non-attribute valid tag if (token === htmlLanguageTypes_1.TokenType.Unknown || token === htmlLanguageTypes_1.TokenType.AttributeValue) { return null; } return value; } token = scanner.scan(); } } return null; } doTagComplete(document, position, htmlDocument) { const offset = document.offsetAt(position); if (offset <= 0) { return null; } const char = document.getText().charAt(offset - 1); if (char === '>') { const voidElements = this.dataManager.getVoidElements(document.languageId); const node = htmlDocument.findNodeBefore(offset); if (node && node.tag && !this.dataManager.isVoidElement(node.tag, voidElements) && node.start < offset && (!node.endTagStart || node.endTagStart > offset)) { const scanner = (0, htmlScanner_1.createScanner)(document.getText(), node.start); let token = scanner.scan(); while (token !== htmlLanguageTypes_1.TokenType.EOS && scanner.getTokenEnd() <= offset) { if (token === htmlLanguageTypes_1.TokenType.StartTagClose && scanner.getTokenEnd() === offset) { return `$0`; } token = scanner.scan(); } } } else if (char === '/') { let node = htmlDocument.findNodeBefore(offset); while (node && node.closed && !(node.endTagStart && (node.endTagStart > offset))) { node = node.parent; } if (node && node.tag) { const scanner = (0, htmlScanner_1.createScanner)(document.getText(), node.start); let token = scanner.scan(); while (token !== htmlLanguageTypes_1.TokenType.EOS && scanner.getTokenEnd() <= offset) { if (token === htmlLanguageTypes_1.TokenType.EndTagOpen && scanner.getTokenEnd() === offset) { return `${node.tag}>`; } token = scanner.scan(); } } } return null; } convertCompletionList(list) { if (!this.doesSupportMarkdown()) { list.items.forEach(item => { if (item.documentation && typeof item.documentation !== 'string') { item.documentation = { kind: 'plaintext', value: item.documentation.value }; } }); } return list; } doesSupportMarkdown() { if (!(0, object_1.isDefined)(this.supportsMarkdown)) { if (!(0, object_1.isDefined)(this.lsOptions.clientCapabilities)) { this.supportsMarkdown = true; return this.supportsMarkdown; } const documentationFormat = this.lsOptions.clientCapabilities.textDocument?.completion?.completionItem?.documentationFormat; this.supportsMarkdown = Array.isArray(documentationFormat) && documentationFormat.indexOf(htmlLanguageTypes_1.MarkupKind.Markdown) !== -1; } return this.supportsMarkdown; } } exports.HTMLCompletion = HTMLCompletion; function isQuote(s) { return /^["']*$/.test(s); } function isWhiteSpace(s) { return /^\s*$/.test(s); } function isFollowedBy(s, offset, intialState, expectedToken) { const scanner = (0, htmlScanner_1.createScanner)(s, offset, intialState); let token = scanner.scan(); while (token === htmlLanguageTypes_1.TokenType.Whitespace) { token = scanner.scan(); } return token === expectedToken; } function getWordStart(s, offset, limit) { while (offset > limit && !isWhiteSpace(s[offset - 1])) { offset--; } return offset; } function getWordEnd(s, offset, limit) { while (offset < limit && !isWhiteSpace(s[offset])) { offset++; } return offset; } });