diff --git a/packages/language-core/lib/plugins/vue-tsx.ts b/packages/language-core/lib/plugins/vue-tsx.ts index ad74060f3e..2bc157fd28 100644 --- a/packages/language-core/lib/plugins/vue-tsx.ts +++ b/packages/language-core/lib/plugins/vue-tsx.ts @@ -276,5 +276,7 @@ function useCodegen( getGeneratedScript, getGeneratedTemplate, getImportComponentNames, + getSetupBindingNames, + getDirectAccessNames, }; } diff --git a/packages/language-service/lib/htmlFormatter.ts b/packages/language-service/lib/htmlFormatter.ts new file mode 100644 index 0000000000..b85c23ce45 --- /dev/null +++ b/packages/language-service/lib/htmlFormatter.ts @@ -0,0 +1,194 @@ +// @ts-expect-error +import beautify = require('vscode-html-languageservice/lib/umd/beautify/beautify-html.js'); +// @ts-expect-error +import strings = require('vscode-html-languageservice/lib/umd/utils/strings.js'); + +/* + * original file: https://github.com/microsoft/vscode-html-languageservice/blob/main/src/services/htmlFormatter.ts + * commit: a134f3050c22fe80954241467cd429811792a81d (2024-03-22) + * purpose: override to add void_elements option + */ + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + type HTMLFormatConfiguration, + Position, + Range, + type TextDocument, + type TextEdit, +} from 'vscode-html-languageservice'; + +export function format( + document: TextDocument, + range: Range | undefined, + options: HTMLFormatConfiguration, + voidElements?: string[], +): TextEdit[] { + let value = document.getText(); + let includesEnd = true; + let initialIndentLevel = 0; + const tabSize = options.tabSize || 4; + if (range) { + let startOffset = document.offsetAt(range.start); + + // include all leading whitespace iff at the beginning of the line + let extendedStart = startOffset; + while (extendedStart > 0 && isWhitespace(value, extendedStart - 1)) { + extendedStart--; + } + if (extendedStart === 0 || isEOL(value, extendedStart - 1)) { + startOffset = extendedStart; + } + else { + // else keep at least one whitespace + if (extendedStart < startOffset) { + startOffset = extendedStart + 1; + } + } + + // include all following whitespace until the end of the line + let endOffset = document.offsetAt(range.end); + let extendedEnd = endOffset; + while (extendedEnd < value.length && isWhitespace(value, extendedEnd)) { + extendedEnd++; + } + if (extendedEnd === value.length || isEOL(value, extendedEnd)) { + endOffset = extendedEnd; + } + range = Range.create(document.positionAt(startOffset), document.positionAt(endOffset)); + + // Do not modify if substring starts in inside an element + // Ending inside an element is fine as it doesn't cause formatting errors + const firstHalf = value.substring(0, startOffset); + if (new RegExp(/.*[<][^>]*$/).test(firstHalf)) { + // return without modification + value = value.substring(startOffset, endOffset); + return [{ + range: range, + newText: value, + }]; + } + + includesEnd = endOffset === value.length; + value = value.substring(startOffset, endOffset); + + if (startOffset !== 0) { + const startOfLineOffset = document.offsetAt(Position.create(range.start.line, 0)); + initialIndentLevel = computeIndentLevel(document.getText(), startOfLineOffset, options); + } + } + else { + range = Range.create(Position.create(0, 0), document.positionAt(value.length)); + } + const htmlOptions = { + indent_size: tabSize, + indent_char: options.insertSpaces ? ' ' : '\t', + indent_empty_lines: getFormatOption(options, 'indentEmptyLines', false), + wrap_line_length: getFormatOption(options, 'wrapLineLength', 120), + unformatted: getTagsFormatOption(options, 'unformatted', void 0), + content_unformatted: getTagsFormatOption(options, 'contentUnformatted', void 0), + indent_inner_html: getFormatOption(options, 'indentInnerHtml', false), + preserve_newlines: getFormatOption(options, 'preserveNewLines', true), + max_preserve_newlines: getFormatOption(options, 'maxPreserveNewLines', 32786), + indent_handlebars: getFormatOption(options, 'indentHandlebars', false), + end_with_newline: includesEnd && getFormatOption(options, 'endWithNewline', false), + extra_liners: getTagsFormatOption(options, 'extraLiners', void 0), + wrap_attributes: getFormatOption(options, 'wrapAttributes', 'auto'), + wrap_attributes_indent_size: getFormatOption(options, 'wrapAttributesIndentSize', void 0), + eol: '\n', + indent_scripts: getFormatOption(options, 'indentScripts', 'normal'), + templating: getTemplatingFormatOption(options, 'all'), + unformatted_content_delimiter: getFormatOption(options, 'unformattedContentDelimiter', ''), + ...voidElements !== undefined && { void_elements: voidElements }, + }; + + let result = beautify.html_beautify(trimLeft(value), htmlOptions); + if (initialIndentLevel > 0) { + const indent = options.insertSpaces + ? strings.repeat(' ', tabSize * initialIndentLevel) + : strings.repeat('\t', initialIndentLevel); + result = result.split('\n').join('\n' + indent); + if (range.start.character === 0) { + result = indent + result; // keep the indent + } + } + return [{ + range: range, + newText: result, + }]; +} + +function trimLeft(str: string) { + return str.replace(/^\s+/, ''); +} + +function getFormatOption(options: HTMLFormatConfiguration, key: keyof HTMLFormatConfiguration, dflt: any): any { + if (options && options.hasOwnProperty(key)) { + const value = options[key]; + if (value !== null) { + return value; + } + } + return dflt; +} + +function getTagsFormatOption( + options: HTMLFormatConfiguration, + key: keyof HTMLFormatConfiguration, + dflt: string[] | undefined, +): string[] | undefined { + const list = getFormatOption(options, key, null); + if (typeof list === 'string') { + if (list.length > 0) { + return list.split(',').map(t => t.trim().toLowerCase()); + } + return []; + } + return dflt; +} + +function getTemplatingFormatOption( + options: HTMLFormatConfiguration, + dflt: string, +): ('auto' | 'none' | 'angular' | 'django' | 'erb' | 'handlebars' | 'php' | 'smarty')[] | undefined { + const value = getFormatOption(options, 'templating', dflt); + if (value === true) { + return ['auto']; + } + if (value === false || value === dflt || Array.isArray(value) === false) { + return ['none']; + } + return value; +} + +function computeIndentLevel(content: string, offset: number, options: HTMLFormatConfiguration): number { + let i = offset; + let nChars = 0; + const tabSize = options.tabSize || 4; + while (i < content.length) { + const ch = content.charAt(i); + if (ch === ' ') { + nChars++; + } + else if (ch === '\t') { + nChars += tabSize; + } + else { + break; + } + i++; + } + return Math.floor(nChars / tabSize); +} + +function isEOL(text: string, offset: number) { + return '\r\n'.indexOf(text.charAt(offset)) !== -1; +} + +function isWhitespace(text: string, offset: number) { + return ' \t'.indexOf(text.charAt(offset)) !== -1; +} diff --git a/packages/language-service/lib/plugins/vue-template.ts b/packages/language-service/lib/plugins/vue-template.ts index 2ae76958fa..79f3c21120 100644 --- a/packages/language-service/lib/plugins/vue-template.ts +++ b/packages/language-service/lib/plugins/vue-template.ts @@ -20,6 +20,7 @@ import { create as createPugService } from 'volar-service-pug'; import * as html from 'vscode-html-languageservice'; import { URI, Utils } from 'vscode-uri'; import { loadModelModifiersData, loadTemplateData } from '../data'; +import { format } from '../htmlFormatter'; import { AttrNameCasing, getAttrNameCasing, getTagNameCasing, TagNameCasing } from '../nameCasing'; import { createReferenceResolver, resolveEmbeddedCode } from '../utils'; @@ -140,6 +141,45 @@ export function create( } return parseHTMLDocument(document); }; + htmlService.format = (document, range, options) => { + let voidElements: string[] | undefined; + const info = resolveEmbeddedCode(context, document.uri); + const codegen = info && tsCodegen.get(info.root.sfc); + if (codegen) { + const componentNames = new Set([ + ...codegen.getImportComponentNames(), + ...codegen.getSetupBindingNames(), + ]); + // copied from https://github.com/microsoft/vscode-html-languageservice/blob/10daf45dc16b4f4228987cf7cddf3a7dbbdc7570/src/beautify/beautify-html.js#L2746-L2761 + voidElements = [ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'keygen', + 'link', + 'menuitem', + 'meta', + 'param', + 'source', + 'track', + 'wbr', + '!doctype', + '?xml', + 'basefont', + 'isindex', + ].filter(tag => + tag + && !componentNames.has(tag) + && !componentNames.has(capitalize(camelize(tag))) + ); + } + return format(document, range, options, voidElements); + }; } builtInData ??= loadTemplateData(context.env.locale ?? 'en'); diff --git a/packages/language-service/tests/format/5279.spec.ts b/packages/language-service/tests/format/5279.spec.ts new file mode 100644 index 0000000000..b67d70c903 --- /dev/null +++ b/packages/language-service/tests/format/5279.spec.ts @@ -0,0 +1,73 @@ +import { defineFormatTest } from '../utils/format'; + +const title = '#' + __filename.split('.')[0]; + +defineFormatTest({ + title: title + ' (with component)', + languageId: 'vue', + input: ` + + + + `.trim(), + output: ` + + + + `.trim(), +}); + +defineFormatTest({ + title: title + ' (without component)', + languageId: 'vue', + input: ` + + `.trim(), + output: ` + + `.trim(), +});