571 lines
		
	
	
	
		
			26 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			571 lines
		
	
	
	
		
			26 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /*---------------------------------------------------------------------------------------------
 | |
|  *  Copyright (c) Microsoft Corporation. All rights reserved.
 | |
|  *  Licensed under the MIT License. See License.txt in the project root for license information.
 | |
|  *--------------------------------------------------------------------------------------------*/
 | |
| import { createScanner } from '../parser/htmlScanner';
 | |
| import { ScannerState, TokenType, Position, CompletionItemKind, Range, TextEdit, InsertTextFormat, MarkupKind } from '../htmlLanguageTypes';
 | |
| import { entities } from '../parser/htmlEntities';
 | |
| import * as l10n from '@vscode/l10n';
 | |
| import { isLetterOrDigit, endsWith, startsWith } from '../utils/strings';
 | |
| import { isDefined } from '../utils/object';
 | |
| import { generateDocumentation } from '../languageFacts/dataProvider';
 | |
| import { PathCompletionParticipant } from './pathCompletion';
 | |
| export 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 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 = 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: CompletionItemKind.Property,
 | |
|                         documentation: generateDocumentation(tag, undefined, doesSupportMarkdown),
 | |
|                         textEdit: TextEdit.replace(range, tag.name),
 | |
|                         insertTextFormat: 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, ScannerState.WithinEndTag, 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: CompletionItemKind.Property,
 | |
|                         filterText: '/' + tag,
 | |
|                         textEdit: TextEdit.replace(range, '/' + tag + closeTag),
 | |
|                         insertTextFormat: InsertTextFormat.PlainText
 | |
|                     };
 | |
|                     const startIndent = getLineIndent(curr.start);
 | |
|                     const endIndent = getLineIndent(afterOpenBracket - 1);
 | |
|                     if (startIndent !== null && endIndent !== null && startIndent !== endIndent) {
 | |
|                         const insertText = startIndent + '</' + tag + closeTag;
 | |
|                         item.textEdit = TextEdit.replace(getReplaceRange(afterOpenBracket - 1 - endIndent.length), insertText);
 | |
|                         item.filterText = endIndent + '</' + tag;
 | |
|                     }
 | |
|                     result.items.push(item);
 | |
|                     return result;
 | |
|                 }
 | |
|                 curr = curr.parent;
 | |
|             }
 | |
|             if (inOpenTag) {
 | |
|                 return result;
 | |
|             }
 | |
|             dataProviders.forEach(provider => {
 | |
|                 provider.provideTags().forEach(tag => {
 | |
|                     result.items.push({
 | |
|                         label: '/' + tag.name,
 | |
|                         kind: CompletionItemKind.Property,
 | |
|                         documentation: generateDocumentation(tag, undefined, doesSupportMarkdown),
 | |
|                         filterText: '/' + tag.name + closeTag,
 | |
|                         textEdit: TextEdit.replace(range, '/' + tag.name + closeTag),
 | |
|                         insertTextFormat: 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: '</' + tag + '>',
 | |
|                     kind: CompletionItemKind.Property,
 | |
|                     filterText: '</' + tag + '>',
 | |
|                     textEdit: TextEdit.insert(pos, '$0</' + tag + '>'),
 | |
|                     insertTextFormat: 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, ScannerState.AfterAttributeName, 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' ? CompletionItemKind.Function : CompletionItemKind.Value,
 | |
|                         documentation: generateDocumentation(attr, undefined, doesSupportMarkdown),
 | |
|                         textEdit: TextEdit.replace(range, codeSnippet),
 | |
|                         insertTextFormat: 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 (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: CompletionItemKind.Value,
 | |
|                 textEdit: TextEdit.replace(range, dataAttributes[attr]),
 | |
|                 insertTextFormat: 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: CompletionItemKind.Unit,
 | |
|                         documentation: generateDocumentation(value, undefined, doesSupportMarkdown),
 | |
|                         textEdit: TextEdit.replace(range, insertText),
 | |
|                         insertTextFormat: 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 && isLetterOrDigit(text, k)) {
 | |
|                 k--;
 | |
|                 characterStart--;
 | |
|             }
 | |
|             if (k >= 0 && text[k] === '&') {
 | |
|                 const range = Range.create(Position.create(position.line, characterStart - 1), position);
 | |
|                 for (const entity in entities) {
 | |
|                     if (endsWith(entity, ';')) {
 | |
|                         const label = '&' + entity;
 | |
|                         result.items.push({
 | |
|                             label,
 | |
|                             kind: CompletionItemKind.Keyword,
 | |
|                             documentation: l10n.t('Character entity representing \'{0}\'', entities[entity]),
 | |
|                             textEdit: TextEdit.replace(range, label),
 | |
|                             insertTextFormat: InsertTextFormat.PlainText
 | |
|                         });
 | |
|                     }
 | |
|                 }
 | |
|             }
 | |
|             return result;
 | |
|         }
 | |
|         function suggestDoctype(replaceStart, replaceEnd) {
 | |
|             const range = getReplaceRange(replaceStart, replaceEnd);
 | |
|             result.items.push({
 | |
|                 label: '!DOCTYPE',
 | |
|                 kind: CompletionItemKind.Property,
 | |
|                 documentation: 'A preamble for an HTML document.',
 | |
|                 textEdit: TextEdit.replace(range, '!DOCTYPE html>'),
 | |
|                 insertTextFormat: InsertTextFormat.PlainText
 | |
|             });
 | |
|         }
 | |
|         let token = scanner.scan();
 | |
|         while (token !== TokenType.EOS && scanner.getTokenOffset() <= offset) {
 | |
|             switch (token) {
 | |
|                 case TokenType.StartTagOpen:
 | |
|                     if (scanner.getTokenEnd() === offset) {
 | |
|                         const endPos = scanNextForEndPos(TokenType.StartTag);
 | |
|                         if (position.line === 0) {
 | |
|                             suggestDoctype(offset, endPos);
 | |
|                         }
 | |
|                         return collectTagSuggestions(offset, endPos);
 | |
|                     }
 | |
|                     break;
 | |
|                 case TokenType.StartTag:
 | |
|                     if (scanner.getTokenOffset() <= offset && offset <= scanner.getTokenEnd()) {
 | |
|                         return collectOpenTagSuggestions(scanner.getTokenOffset(), scanner.getTokenEnd());
 | |
|                     }
 | |
|                     currentTag = scanner.getTokenText();
 | |
|                     break;
 | |
|                 case TokenType.AttributeName:
 | |
|                     if (scanner.getTokenOffset() <= offset && offset <= scanner.getTokenEnd()) {
 | |
|                         return collectAttributeNameSuggestions(scanner.getTokenOffset(), scanner.getTokenEnd());
 | |
|                     }
 | |
|                     currentAttributeName = scanner.getTokenText();
 | |
|                     break;
 | |
|                 case TokenType.DelimiterAssign:
 | |
|                     if (scanner.getTokenEnd() === offset) {
 | |
|                         const endPos = scanNextForEndPos(TokenType.AttributeValue);
 | |
|                         return collectAttributeValueSuggestions(offset, endPos);
 | |
|                     }
 | |
|                     break;
 | |
|                 case TokenType.AttributeValue:
 | |
|                     if (scanner.getTokenOffset() <= offset && offset <= scanner.getTokenEnd()) {
 | |
|                         return collectAttributeValueSuggestions(scanner.getTokenOffset(), scanner.getTokenEnd());
 | |
|                     }
 | |
|                     break;
 | |
|                 case TokenType.Whitespace:
 | |
|                     if (offset <= scanner.getTokenEnd()) {
 | |
|                         switch (scanner.getScannerState()) {
 | |
|                             case ScannerState.AfterOpeningStartTag:
 | |
|                                 const startPos = scanner.getTokenOffset();
 | |
|                                 const endTagPos = scanNextForEndPos(TokenType.StartTag);
 | |
|                                 return collectTagSuggestions(startPos, endTagPos);
 | |
|                             case ScannerState.WithinTag:
 | |
|                             case ScannerState.AfterAttributeName:
 | |
|                                 return collectAttributeNameSuggestions(scanner.getTokenEnd());
 | |
|                             case ScannerState.BeforeAttributeValue:
 | |
|                                 return collectAttributeValueSuggestions(scanner.getTokenEnd());
 | |
|                             case ScannerState.AfterOpeningEndTag:
 | |
|                                 return collectCloseTagSuggestions(scanner.getTokenOffset() - 1, false);
 | |
|                             case ScannerState.WithinContent:
 | |
|                                 return collectInsideContent();
 | |
|                         }
 | |
|                     }
 | |
|                     break;
 | |
|                 case TokenType.EndTagOpen:
 | |
|                     if (offset <= scanner.getTokenEnd()) {
 | |
|                         const afterOpenBracket = scanner.getTokenOffset() + 1;
 | |
|                         const endOffset = scanNextForEndPos(TokenType.EndTag);
 | |
|                         return collectCloseTagSuggestions(afterOpenBracket, false, endOffset);
 | |
|                     }
 | |
|                     break;
 | |
|                 case 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 TokenType.StartTagClose:
 | |
|                     if (offset <= scanner.getTokenEnd()) {
 | |
|                         if (currentTag) {
 | |
|                             return collectAutoCloseTagSuggestion(scanner.getTokenEnd(), currentTag);
 | |
|                         }
 | |
|                     }
 | |
|                     break;
 | |
|                 case 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 = createScanner(document.getText(), node.start);
 | |
|             let token = scanner.scan();
 | |
|             while (token !== TokenType.EOS && scanner.getTokenEnd() <= offset) {
 | |
|                 if (token === 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 !== TokenType.DelimiterAssign) {
 | |
|                         return null;
 | |
|                     }
 | |
|                     token = scanner.scan();
 | |
|                     // Any non-attribute valid tag
 | |
|                     if (token === TokenType.Unknown || token === 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 = createScanner(document.getText(), node.start);
 | |
|                 let token = scanner.scan();
 | |
|                 while (token !== TokenType.EOS && scanner.getTokenEnd() <= offset) {
 | |
|                     if (token === TokenType.StartTagClose && scanner.getTokenEnd() === offset) {
 | |
|                         return `$0</${node.tag}>`;
 | |
|                     }
 | |
|                     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 = createScanner(document.getText(), node.start);
 | |
|                 let token = scanner.scan();
 | |
|                 while (token !== TokenType.EOS && scanner.getTokenEnd() <= offset) {
 | |
|                     if (token === 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 (!isDefined(this.supportsMarkdown)) {
 | |
|             if (!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(MarkupKind.Markdown) !== -1;
 | |
|         }
 | |
|         return this.supportsMarkdown;
 | |
|     }
 | |
| }
 | |
| function isQuote(s) {
 | |
|     return /^["']*$/.test(s);
 | |
| }
 | |
| function isWhiteSpace(s) {
 | |
|     return /^\s*$/.test(s);
 | |
| }
 | |
| function isFollowedBy(s, offset, intialState, expectedToken) {
 | |
|     const scanner = createScanner(s, offset, intialState);
 | |
|     let token = scanner.scan();
 | |
|     while (token === 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;
 | |
| }
 | 
