From e8ac273c937bef77fba47d46c7754deafa8362fa Mon Sep 17 00:00:00 2001 From: SerKo Date: Mon, 1 Dec 2025 04:12:59 +0800 Subject: [PATCH 01/10] wip: format elements with void element name --- .../lib/plugins/vue-template.ts | 68 +++++++++++++++++ .../tests/format/5279.spec.ts | 73 +++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 packages/language-service/tests/format/5279.spec.ts diff --git a/packages/language-service/lib/plugins/vue-template.ts b/packages/language-service/lib/plugins/vue-template.ts index 2ae76958fa..d2ff77a20f 100644 --- a/packages/language-service/lib/plugins/vue-template.ts +++ b/packages/language-service/lib/plugins/vue-template.ts @@ -48,6 +48,21 @@ const builtInComponents = new Set([ let builtInData: html.HTMLDataV1 | undefined; let modelData: html.HTMLDataV1 | undefined; +const beautifyHtmlModule: { html_beautify: (text: string, options: any) => string } = require( + 'vscode-html-languageservice/lib/umd/beautify/beautify-html', +); +const originalHtmlBeautify = beautifyHtmlModule.html_beautify; +let formattingVoidElements: string[] | undefined; + +beautifyHtmlModule.html_beautify = (text, options) => { + if (formattingVoidElements?.length) { + options = { + ...options, + void_elements: formattingVoidElements, + }; + } + return originalHtmlBeautify(text, options); +}; export function create( languageId: 'html' | 'jade', @@ -377,6 +392,33 @@ export function create( return baseServiceInstance.provideHover?.(document, position, token); }, + async provideDocumentFormattingEdits(document, range, options, embeddedCodeContext, token) { + if (document.languageId !== languageId) { + return; + } + const info = resolveEmbeddedCode(context, document.uri); + const isTemplate = info?.code.id === 'template'; + + try { + if (isTemplate) { + formattingVoidElements = await getFormattingVoidElements(info.root, info.script.id); + } + + return await baseServiceInstance.provideDocumentFormattingEdits?.( + document, + range, + options, + embeddedCodeContext, + token, + ); + } + finally { + if (isTemplate) { + formattingVoidElements = undefined; + } + } + }, + async provideDocumentLinks(document, token) { if (document.languageId !== languageId) { return; @@ -410,6 +452,32 @@ export function create( return { result, ...lastSync }; } + async function getFormattingVoidElements(root: VueVirtualCode, sourceDocumentUri: URI) { + const tagNameCasing = await getTagNameCasing(context, sourceDocumentUri); + const componentNames = new Set(); + const scriptSetupRanges = tsCodegen.get(root.sfc)?.getScriptSetupRanges(); + const componentList = (await getComponentNames(root.fileName) ?? []) + .filter(name => !builtInComponents.has(name)); + + const addName = (name: string) => { + const tagName = tagNameCasing === TagNameCasing.Kebab ? hyphenateTag(name) : name; + componentNames.add(tagName.toLowerCase()); + }; + + for (const tag of componentList) { + addName(tag); + } + if (root.sfc.scriptSetup) { + for (const binding of scriptSetupRanges?.bindings ?? []) { + addName(root.sfc.scriptSetup.content.slice(binding.range.start, binding.range.end)); + } + } + + return htmlDataProvider.provideTags() + .filter(tag => tag.void && !componentNames.has(tag.name.toLowerCase())) + .map(tag => tag.name); + } + async function provideHtmlData(sourceDocumentUri: URI, root: VueVirtualCode) { await (initializing ??= initialize()); 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(), +}); From 1ff624dd1291a5f0242c8747853a73be955310a7 Mon Sep 17 00:00:00 2001 From: SerKo Date: Mon, 1 Dec 2025 04:33:54 +0800 Subject: [PATCH 02/10] refactor: patch inside `provideDocumentFormattingEdits` --- .../lib/plugins/vue-template.ts | 35 +++++++------------ 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/packages/language-service/lib/plugins/vue-template.ts b/packages/language-service/lib/plugins/vue-template.ts index d2ff77a20f..2a6804deb3 100644 --- a/packages/language-service/lib/plugins/vue-template.ts +++ b/packages/language-service/lib/plugins/vue-template.ts @@ -23,6 +23,10 @@ import { loadModelModifiersData, loadTemplateData } from '../data'; import { AttrNameCasing, getAttrNameCasing, getTagNameCasing, TagNameCasing } from '../nameCasing'; import { createReferenceResolver, resolveEmbeddedCode } from '../utils'; +const beautifyHtmlModule: { html_beautify: (text: string, options: any) => string } = require( + 'vscode-html-languageservice/lib/umd/beautify/beautify-html', +); + const specialTags = new Set([ 'slot', 'component', @@ -48,21 +52,6 @@ const builtInComponents = new Set([ let builtInData: html.HTMLDataV1 | undefined; let modelData: html.HTMLDataV1 | undefined; -const beautifyHtmlModule: { html_beautify: (text: string, options: any) => string } = require( - 'vscode-html-languageservice/lib/umd/beautify/beautify-html', -); -const originalHtmlBeautify = beautifyHtmlModule.html_beautify; -let formattingVoidElements: string[] | undefined; - -beautifyHtmlModule.html_beautify = (text, options) => { - if (formattingVoidElements?.length) { - options = { - ...options, - void_elements: formattingVoidElements, - }; - } - return originalHtmlBeautify(text, options); -}; export function create( languageId: 'html' | 'jade', @@ -397,12 +386,16 @@ export function create( return; } const info = resolveEmbeddedCode(context, document.uri); - const isTemplate = info?.code.id === 'template'; + if (info?.code.id !== 'template') { + return; + } + const originalHtmlBeautify = beautifyHtmlModule.html_beautify; try { - if (isTemplate) { - formattingVoidElements = await getFormattingVoidElements(info.root, info.script.id); - } + const voidElements = await getFormattingVoidElements(info.root, info.script.id); + + beautifyHtmlModule.html_beautify = (text, options) => + originalHtmlBeautify(text, { ...options, void_elements: voidElements }); return await baseServiceInstance.provideDocumentFormattingEdits?.( document, @@ -413,9 +406,7 @@ export function create( ); } finally { - if (isTemplate) { - formattingVoidElements = undefined; - } + beautifyHtmlModule.html_beautify = originalHtmlBeautify; } }, From d4dc3934bb178a3363b7d3084b183645f9b30a97 Mon Sep 17 00:00:00 2001 From: SerKo Date: Mon, 1 Dec 2025 15:27:58 +0800 Subject: [PATCH 03/10] refactor: abstract html beautify patcher --- .../lib/plugins/vue-template.ts | 65 +++++++++++++------ 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/packages/language-service/lib/plugins/vue-template.ts b/packages/language-service/lib/plugins/vue-template.ts index 2a6804deb3..bcc8e0c953 100644 --- a/packages/language-service/lib/plugins/vue-template.ts +++ b/packages/language-service/lib/plugins/vue-template.ts @@ -23,10 +23,6 @@ import { loadModelModifiersData, loadTemplateData } from '../data'; import { AttrNameCasing, getAttrNameCasing, getTagNameCasing, TagNameCasing } from '../nameCasing'; import { createReferenceResolver, resolveEmbeddedCode } from '../utils'; -const beautifyHtmlModule: { html_beautify: (text: string, options: any) => string } = require( - 'vscode-html-languageservice/lib/umd/beautify/beautify-html', -); - const specialTags = new Set([ 'slot', 'component', @@ -53,6 +49,8 @@ const builtInComponents = new Set([ let builtInData: html.HTMLDataV1 | undefined; let modelData: html.HTMLDataV1 | undefined; +const patchHtmlBeautify = createHtmlBeautifyPatcher(); + export function create( languageId: 'html' | 'jade', { @@ -390,24 +388,19 @@ export function create( return; } - const originalHtmlBeautify = beautifyHtmlModule.html_beautify; - try { - const voidElements = await getFormattingVoidElements(info.root, info.script.id); - - beautifyHtmlModule.html_beautify = (text, options) => - originalHtmlBeautify(text, { ...options, void_elements: voidElements }); + const voidElements = await getFormattingVoidElements(info.root, info.script.id); - return await baseServiceInstance.provideDocumentFormattingEdits?.( - document, - range, - options, - embeddedCodeContext, - token, - ); - } - finally { - beautifyHtmlModule.html_beautify = originalHtmlBeautify; - } + return await patchHtmlBeautify( + voidElements, + () => + baseServiceInstance.provideDocumentFormattingEdits?.( + document, + range, + options, + embeddedCodeContext, + token, + ), + ); }, async provideDocumentLinks(document, token) { @@ -866,3 +859,33 @@ function getPropName( } return { isEvent, propName: name }; } + +function createHtmlBeautifyPatcher() { + let module: { html_beautify: (text: string, options: any) => string } | undefined; + + try { + module = require('vscode-html-languageservice/lib/umd/beautify/beautify-html'); + } + catch { + console.error('Failed to load html beautify module for patcher'); + } + + const originalHtmlBeautify = module?.html_beautify; + + return async function patchVoidElements(voidElements: string[], run: () => T) { + if (!module || !originalHtmlBeautify || !voidElements.length) { + return await run(); + } + try { + module.html_beautify = (text, options) => + originalHtmlBeautify(text, { + ...options, + void_elements: voidElements, + }); + return await run(); + } + finally { + module.html_beautify = originalHtmlBeautify; + } + }; +} From 28894fef8b1f93dd9403c58070e9b26ec359de0b Mon Sep 17 00:00:00 2001 From: SerKo Date: Mon, 1 Dec 2025 15:54:29 +0800 Subject: [PATCH 04/10] refactor: simplify --- .../lib/plugins/vue-template.ts | 67 +++++++++---------- 1 file changed, 33 insertions(+), 34 deletions(-) diff --git a/packages/language-service/lib/plugins/vue-template.ts b/packages/language-service/lib/plugins/vue-template.ts index bcc8e0c953..27f3b81c4b 100644 --- a/packages/language-service/lib/plugins/vue-template.ts +++ b/packages/language-service/lib/plugins/vue-template.ts @@ -388,19 +388,21 @@ export function create( return; } - const voidElements = await getFormattingVoidElements(info.root, info.script.id); + const baseFn = () => + baseServiceInstance.provideDocumentFormattingEdits?.( + document, + range, + options, + embeddedCodeContext, + token, + ); + + if (!patchHtmlBeautify) { + return baseFn(); + } - return await patchHtmlBeautify( - voidElements, - () => - baseServiceInstance.provideDocumentFormattingEdits?.( - document, - range, - options, - embeddedCodeContext, - token, - ), - ); + const voidElements = await getFormattingVoidElements(info.root, info.script.id); + return patchHtmlBeautify(voidElements, baseFn); }, async provideDocumentLinks(document, token) { @@ -861,31 +863,28 @@ function getPropName( } function createHtmlBeautifyPatcher() { - let module: { html_beautify: (text: string, options: any) => string } | undefined; - try { - module = require('vscode-html-languageservice/lib/umd/beautify/beautify-html'); + const module: { html_beautify: (text: string, options: any) => string } = require( + 'vscode-html-languageservice/lib/umd/beautify/beautify-html', + ); + + const originalHtmlBeautify = module.html_beautify; + + return async function patchVoidElements(voidElements: string[], run: () => T) { + try { + module.html_beautify = (text, options) => + originalHtmlBeautify(text, { + ...options, + void_elements: voidElements, + }); + return await run(); + } + finally { + module.html_beautify = originalHtmlBeautify; + } + }; } catch { console.error('Failed to load html beautify module for patcher'); } - - const originalHtmlBeautify = module?.html_beautify; - - return async function patchVoidElements(voidElements: string[], run: () => T) { - if (!module || !originalHtmlBeautify || !voidElements.length) { - return await run(); - } - try { - module.html_beautify = (text, options) => - originalHtmlBeautify(text, { - ...options, - void_elements: voidElements, - }); - return await run(); - } - finally { - module.html_beautify = originalHtmlBeautify; - } - }; } From bd6156b7c1f7d6245ee49cf7d6706fc032e12b2e Mon Sep 17 00:00:00 2001 From: SerKo Date: Mon, 1 Dec 2025 18:13:03 +0800 Subject: [PATCH 05/10] fix: extract `require` out of `try` for checking module is exists when building --- .../language-service/lib/plugins/vue-template.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/language-service/lib/plugins/vue-template.ts b/packages/language-service/lib/plugins/vue-template.ts index 27f3b81c4b..30992edce4 100644 --- a/packages/language-service/lib/plugins/vue-template.ts +++ b/packages/language-service/lib/plugins/vue-template.ts @@ -863,11 +863,15 @@ function getPropName( } function createHtmlBeautifyPatcher() { - try { - const module: { html_beautify: (text: string, options: any) => string } = require( - 'vscode-html-languageservice/lib/umd/beautify/beautify-html', - ); + // for build check is module exists + function requireBeautifyHtml() { + return require('vscode-html-languageservice/lib/umd/beautify/beautify-html') as { + html_beautify: (text: string, options: any) => string; + }; + } + try { + const module = requireBeautifyHtml(); const originalHtmlBeautify = module.html_beautify; return async function patchVoidElements(voidElements: string[], run: () => T) { From c125e0ee5c4f8c84dcd003de52f7a2665ba0fadf Mon Sep 17 00:00:00 2001 From: SerKo Date: Mon, 1 Dec 2025 23:16:37 +0800 Subject: [PATCH 06/10] wip --- .../lib/plugins/vue-template.ts | 48 +++++++++++-------- packages/language-service/lib/utils.ts | 44 +++++++++++++++++ 2 files changed, 71 insertions(+), 21 deletions(-) diff --git a/packages/language-service/lib/plugins/vue-template.ts b/packages/language-service/lib/plugins/vue-template.ts index 30992edce4..316cacebd4 100644 --- a/packages/language-service/lib/plugins/vue-template.ts +++ b/packages/language-service/lib/plugins/vue-template.ts @@ -21,7 +21,7 @@ import * as html from 'vscode-html-languageservice'; import { URI, Utils } from 'vscode-uri'; import { loadModelModifiersData, loadTemplateData } from '../data'; import { AttrNameCasing, getAttrNameCasing, getTagNameCasing, TagNameCasing } from '../nameCasing'; -import { createReferenceResolver, resolveEmbeddedCode } from '../utils'; +import { createModulesLoader, createReferenceResolver, resolveEmbeddedCode } from '../utils'; const specialTags = new Set([ 'slot', @@ -863,32 +863,38 @@ function getPropName( } function createHtmlBeautifyPatcher() { - // for build check is module exists - function requireBeautifyHtml() { - return require('vscode-html-languageservice/lib/umd/beautify/beautify-html') as { - html_beautify: (text: string, options: any) => string; - }; - } + const ensureModules = createModulesLoader<{ + html_beautify: (text: string, options: any) => string; + }>( + () => require('vscode-html-languageservice/lib/umd/beautify/beautify-html.js'), + // @ts-ignore + () => import('vscode-html-languageservice/lib/esm/beautify/beautify-html.js'), + ); + + return async function patchVoidElements(voidElements: string[], run: () => T) { + if (voidElements.length === 0) { + return await run(); + } - try { - const module = requireBeautifyHtml(); - const originalHtmlBeautify = module.html_beautify; + const modules = await ensureModules(); + const originals = modules.map(m => m.html_beautify); - return async function patchVoidElements(voidElements: string[], run: () => T) { - try { + try { + for (let i = 0; i < modules.length; i++) { + const module = modules[i]!; module.html_beautify = (text, options) => - originalHtmlBeautify(text, { + originals[i]!(text, { ...options, void_elements: voidElements, }); - return await run(); } - finally { - module.html_beautify = originalHtmlBeautify; + return await run(); + } + finally { + for (let i = 0; i < modules.length; i++) { + const module = modules[i]!; + module.html_beautify = originals[i]!; } - }; - } - catch { - console.error('Failed to load html beautify module for patcher'); - } + } + }; } diff --git a/packages/language-service/lib/utils.ts b/packages/language-service/lib/utils.ts index 52866a951d..43fef2747f 100644 --- a/packages/language-service/lib/utils.ts +++ b/packages/language-service/lib/utils.ts @@ -39,3 +39,47 @@ export function createReferenceResolver( return moduleName ?? resolveReference(ref, uri, context.env.workspaceFolders); }; } + +export function createModulesLoader(cjsLoader?: () => T, esmLoader?: () => Promise): () => Promise { + let cached: Promise | undefined; + return () => { + if (cached) { + return cached; + } + cached = (async () => { + const loaded: T[] = []; + if (cjsLoader) { + try { + const cjs = cjsLoader(); + if (cjs) { + loaded.push(cjs); + } + } + catch { + // ignore, will still try ESM path below + console.error('Failed to load CJS module.'); + } + } + + if (esmLoader) { + try { + const esm = await esmLoader(); + if (esm && !loaded.includes(esm)) { + loaded.push(esm); + } + } + catch { + // ignore; CJS path may still be available + console.error('Failed to load ESM module.'); + } + } + + if (loaded.length === 0) { + console.error('Failed to load modules.'); + } + + return loaded; + })(); + return cached; + }; +} From 111bd0e527b188f251267a6dd787ac586720e5bf Mon Sep 17 00:00:00 2001 From: SerKo Date: Tue, 2 Dec 2025 03:33:21 +0800 Subject: [PATCH 07/10] fix: patch `htmlService.format` instead of `html_beautify` --- .../language-service/lib/htmlFormatter.ts | 187 ++++++++++++++++++ .../lib/plugins/vue-template.ts | 40 ++-- packages/language-service/lib/utils.ts | 44 ----- 3 files changed, 198 insertions(+), 73 deletions(-) create mode 100644 packages/language-service/lib/htmlFormatter.ts diff --git a/packages/language-service/lib/htmlFormatter.ts b/packages/language-service/lib/htmlFormatter.ts new file mode 100644 index 0000000000..cbd7e872d1 --- /dev/null +++ b/packages/language-service/lib/htmlFormatter.ts @@ -0,0 +1,187 @@ +/* + * original file: https://github.com/microsoft/vscode-html-languageservice/blob/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 { HTMLFormatConfiguration, Position, Range, TextDocument, TextEdit } from 'vscode-html-languageservice'; + +// @ts-expect-error +import { html_beautify, IBeautifyHTMLOptions } from 'vscode-html-languageservice/lib/umd/beautify/beautify-html'; + +// @ts-expect-error +import { repeat } from 'vscode-html-languageservice/lib/umd/utils/strings'; + +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: IBeautifyHTMLOptions = { + 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 = html_beautify(trimLeft(value), htmlOptions); + if (initialIndentLevel > 0) { + const indent = options.insertSpaces ? repeat(' ', tabSize * initialIndentLevel) : 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 316cacebd4..bd55f42271 100644 --- a/packages/language-service/lib/plugins/vue-template.ts +++ b/packages/language-service/lib/plugins/vue-template.ts @@ -20,8 +20,9 @@ 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 { createModulesLoader, createReferenceResolver, resolveEmbeddedCode } from '../utils'; +import { createReferenceResolver, resolveEmbeddedCode } from '../utils'; const specialTags = new Set([ 'slot', @@ -49,8 +50,6 @@ const builtInComponents = new Set([ let builtInData: html.HTMLDataV1 | undefined; let modelData: html.HTMLDataV1 | undefined; -const patchHtmlBeautify = createHtmlBeautifyPatcher(); - export function create( languageId: 'html' | 'jade', { @@ -118,10 +117,12 @@ export function create( }, create(context) { const baseServiceInstance = baseService.create(context); + let patchHtmlServiceFormat: ReturnType | undefined; if (baseServiceInstance.provide['html/languageService']) { const htmlService: html.LanguageService = baseServiceInstance.provide['html/languageService'](); const parseHTMLDocument = htmlService.parseHTMLDocument.bind(htmlService); + patchHtmlServiceFormat = createHtmlServiceFormatPatcher(htmlService); htmlService.parseHTMLDocument = document => { const info = resolveEmbeddedCode(context, document.uri); @@ -397,12 +398,12 @@ export function create( token, ); - if (!patchHtmlBeautify) { + if (!patchHtmlServiceFormat) { return baseFn(); } const voidElements = await getFormattingVoidElements(info.root, info.script.id); - return patchHtmlBeautify(voidElements, baseFn); + return patchHtmlServiceFormat(voidElements, baseFn); }, async provideDocumentLinks(document, token) { @@ -862,39 +863,20 @@ function getPropName( return { isEvent, propName: name }; } -function createHtmlBeautifyPatcher() { - const ensureModules = createModulesLoader<{ - html_beautify: (text: string, options: any) => string; - }>( - () => require('vscode-html-languageservice/lib/umd/beautify/beautify-html.js'), - // @ts-ignore - () => import('vscode-html-languageservice/lib/esm/beautify/beautify-html.js'), - ); - - return async function patchVoidElements(voidElements: string[], run: () => T) { +function createHtmlServiceFormatPatcher(htmlService: html.LanguageService) { + return async function patchVoidElements(voidElements: string[], run: () => T | PromiseLike) { if (voidElements.length === 0) { return await run(); } - const modules = await ensureModules(); - const originals = modules.map(m => m.html_beautify); + const originalFormat = htmlService.format; + htmlService.format = (document, range, options) => format(document, range, options, voidElements); try { - for (let i = 0; i < modules.length; i++) { - const module = modules[i]!; - module.html_beautify = (text, options) => - originals[i]!(text, { - ...options, - void_elements: voidElements, - }); - } return await run(); } finally { - for (let i = 0; i < modules.length; i++) { - const module = modules[i]!; - module.html_beautify = originals[i]!; - } + htmlService.format = originalFormat; } }; } diff --git a/packages/language-service/lib/utils.ts b/packages/language-service/lib/utils.ts index 43fef2747f..52866a951d 100644 --- a/packages/language-service/lib/utils.ts +++ b/packages/language-service/lib/utils.ts @@ -39,47 +39,3 @@ export function createReferenceResolver( return moduleName ?? resolveReference(ref, uri, context.env.workspaceFolders); }; } - -export function createModulesLoader(cjsLoader?: () => T, esmLoader?: () => Promise): () => Promise { - let cached: Promise | undefined; - return () => { - if (cached) { - return cached; - } - cached = (async () => { - const loaded: T[] = []; - if (cjsLoader) { - try { - const cjs = cjsLoader(); - if (cjs) { - loaded.push(cjs); - } - } - catch { - // ignore, will still try ESM path below - console.error('Failed to load CJS module.'); - } - } - - if (esmLoader) { - try { - const esm = await esmLoader(); - if (esm && !loaded.includes(esm)) { - loaded.push(esm); - } - } - catch { - // ignore; CJS path may still be available - console.error('Failed to load ESM module.'); - } - } - - if (loaded.length === 0) { - console.error('Failed to load modules.'); - } - - return loaded; - })(); - return cached; - }; -} From 0fcba1c692c6bdfb951f4b33eff51af3e87c5aab Mon Sep 17 00:00:00 2001 From: SerKo Date: Tue, 2 Dec 2025 04:02:26 +0800 Subject: [PATCH 08/10] fix: add `tryRequire` utility and update `htmlFormatter` to check module is ready --- .../language-service/lib/htmlFormatter.ts | 24 ++++++++++++------- .../lib/plugins/vue-template.ts | 6 ++++- packages/language-service/lib/utils.ts | 13 ++++++++++ 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/language-service/lib/htmlFormatter.ts b/packages/language-service/lib/htmlFormatter.ts index cbd7e872d1..53d499b371 100644 --- a/packages/language-service/lib/htmlFormatter.ts +++ b/packages/language-service/lib/htmlFormatter.ts @@ -1,5 +1,19 @@ +import { tryRequire } from './utils'; + +const { html_beautify } = tryRequire( + () => require('vscode-html-languageservice/lib/umd/beautify/beautify-html'), + 'Failed to load vscode-html-languageservice/lib/umd/beautify/beautify-html', +); + +const { repeat } = tryRequire( + () => require('vscode-html-languageservice/lib/umd/utils/strings'), + 'Failed to load vscode-html-languageservice/lib/umd/utils/strings', +); + +export const isReady = !!html_beautify && !!repeat; + /* - * original file: https://github.com/microsoft/vscode-html-languageservice/blob/src/services/htmlFormatter.ts + * 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 */ @@ -11,12 +25,6 @@ import { HTMLFormatConfiguration, Position, Range, TextDocument, TextEdit } from 'vscode-html-languageservice'; -// @ts-expect-error -import { html_beautify, IBeautifyHTMLOptions } from 'vscode-html-languageservice/lib/umd/beautify/beautify-html'; - -// @ts-expect-error -import { repeat } from 'vscode-html-languageservice/lib/umd/utils/strings'; - export function format( document: TextDocument, range: Range | undefined, @@ -79,7 +87,7 @@ export function format( else { range = Range.create(Position.create(0, 0), document.positionAt(value.length)); } - const htmlOptions: IBeautifyHTMLOptions = { + const htmlOptions = { indent_size: tabSize, indent_char: options.insertSpaces ? ' ' : '\t', indent_empty_lines: getFormatOption(options, 'indentEmptyLines', false), diff --git a/packages/language-service/lib/plugins/vue-template.ts b/packages/language-service/lib/plugins/vue-template.ts index bd55f42271..7ab347c7c3 100644 --- a/packages/language-service/lib/plugins/vue-template.ts +++ b/packages/language-service/lib/plugins/vue-template.ts @@ -20,7 +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 { format, isReady as isHtmlFormatterReady } from '../htmlFormatter'; import { AttrNameCasing, getAttrNameCasing, getTagNameCasing, TagNameCasing } from '../nameCasing'; import { createReferenceResolver, resolveEmbeddedCode } from '../utils'; @@ -864,6 +864,10 @@ function getPropName( } function createHtmlServiceFormatPatcher(htmlService: html.LanguageService) { + if (!isHtmlFormatterReady) { + return undefined; + } + return async function patchVoidElements(voidElements: string[], run: () => T | PromiseLike) { if (voidElements.length === 0) { return await run(); diff --git a/packages/language-service/lib/utils.ts b/packages/language-service/lib/utils.ts index 52866a951d..34d6d8685a 100644 --- a/packages/language-service/lib/utils.ts +++ b/packages/language-service/lib/utils.ts @@ -39,3 +39,16 @@ export function createReferenceResolver( return moduleName ?? resolveReference(ref, uri, context.env.workspaceFolders); }; } + +// for checking module is exists when build +export function tryRequire(fn: () => T, errorMessage?: string): T | {} | undefined { + try { + return fn(); + } + catch { + if (errorMessage !== undefined) { + console.error(errorMessage); + } + return {}; + } +} From a4129c93eb9744bf23f36c3ee0ebe33999f450b1 Mon Sep 17 00:00:00 2001 From: SerKo Date: Tue, 2 Dec 2025 04:12:54 +0800 Subject: [PATCH 09/10] fix: lint --- packages/language-service/lib/htmlFormatter.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/language-service/lib/htmlFormatter.ts b/packages/language-service/lib/htmlFormatter.ts index 53d499b371..1dfa171fa1 100644 --- a/packages/language-service/lib/htmlFormatter.ts +++ b/packages/language-service/lib/htmlFormatter.ts @@ -23,7 +23,13 @@ export const isReady = !!html_beautify && !!repeat; * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { HTMLFormatConfiguration, Position, Range, TextDocument, TextEdit } from 'vscode-html-languageservice'; +import { + type HTMLFormatConfiguration, + Position, + Range, + type TextDocument, + type TextEdit, +} from 'vscode-html-languageservice'; export function format( document: TextDocument, From 7281bfdf41d0909ee325eb50120ad74c4907f887 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Tue, 2 Dec 2025 16:06:58 +0800 Subject: [PATCH 10/10] updates --- packages/language-core/lib/plugins/vue-tsx.ts | 2 + .../language-service/lib/htmlFormatter.ts | 23 ++-- .../lib/plugins/vue-template.ts | 117 ++++++------------ packages/language-service/lib/utils.ts | 13 -- 4 files changed, 50 insertions(+), 105 deletions(-) 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 index 1dfa171fa1..b85c23ce45 100644 --- a/packages/language-service/lib/htmlFormatter.ts +++ b/packages/language-service/lib/htmlFormatter.ts @@ -1,16 +1,7 @@ -import { tryRequire } from './utils'; - -const { html_beautify } = tryRequire( - () => require('vscode-html-languageservice/lib/umd/beautify/beautify-html'), - 'Failed to load vscode-html-languageservice/lib/umd/beautify/beautify-html', -); - -const { repeat } = tryRequire( - () => require('vscode-html-languageservice/lib/umd/utils/strings'), - 'Failed to load vscode-html-languageservice/lib/umd/utils/strings', -); - -export const isReady = !!html_beautify && !!repeat; +// @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 @@ -115,9 +106,11 @@ export function format( ...voidElements !== undefined && { void_elements: voidElements }, }; - let result = html_beautify(trimLeft(value), htmlOptions); + let result = beautify.html_beautify(trimLeft(value), htmlOptions); if (initialIndentLevel > 0) { - const indent = options.insertSpaces ? repeat(' ', tabSize * initialIndentLevel) : repeat('\t', initialIndentLevel); + 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 diff --git a/packages/language-service/lib/plugins/vue-template.ts b/packages/language-service/lib/plugins/vue-template.ts index 7ab347c7c3..79f3c21120 100644 --- a/packages/language-service/lib/plugins/vue-template.ts +++ b/packages/language-service/lib/plugins/vue-template.ts @@ -20,7 +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, isReady as isHtmlFormatterReady } from '../htmlFormatter'; +import { format } from '../htmlFormatter'; import { AttrNameCasing, getAttrNameCasing, getTagNameCasing, TagNameCasing } from '../nameCasing'; import { createReferenceResolver, resolveEmbeddedCode } from '../utils'; @@ -117,12 +117,10 @@ export function create( }, create(context) { const baseServiceInstance = baseService.create(context); - let patchHtmlServiceFormat: ReturnType | undefined; if (baseServiceInstance.provide['html/languageService']) { const htmlService: html.LanguageService = baseServiceInstance.provide['html/languageService'](); const parseHTMLDocument = htmlService.parseHTMLDocument.bind(htmlService); - patchHtmlServiceFormat = createHtmlServiceFormatPatcher(htmlService); htmlService.parseHTMLDocument = document => { const info = resolveEmbeddedCode(context, document.uri); @@ -143,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'); @@ -380,32 +417,6 @@ export function create( return baseServiceInstance.provideHover?.(document, position, token); }, - async provideDocumentFormattingEdits(document, range, options, embeddedCodeContext, token) { - if (document.languageId !== languageId) { - return; - } - const info = resolveEmbeddedCode(context, document.uri); - if (info?.code.id !== 'template') { - return; - } - - const baseFn = () => - baseServiceInstance.provideDocumentFormattingEdits?.( - document, - range, - options, - embeddedCodeContext, - token, - ); - - if (!patchHtmlServiceFormat) { - return baseFn(); - } - - const voidElements = await getFormattingVoidElements(info.root, info.script.id); - return patchHtmlServiceFormat(voidElements, baseFn); - }, - async provideDocumentLinks(document, token) { if (document.languageId !== languageId) { return; @@ -439,32 +450,6 @@ export function create( return { result, ...lastSync }; } - async function getFormattingVoidElements(root: VueVirtualCode, sourceDocumentUri: URI) { - const tagNameCasing = await getTagNameCasing(context, sourceDocumentUri); - const componentNames = new Set(); - const scriptSetupRanges = tsCodegen.get(root.sfc)?.getScriptSetupRanges(); - const componentList = (await getComponentNames(root.fileName) ?? []) - .filter(name => !builtInComponents.has(name)); - - const addName = (name: string) => { - const tagName = tagNameCasing === TagNameCasing.Kebab ? hyphenateTag(name) : name; - componentNames.add(tagName.toLowerCase()); - }; - - for (const tag of componentList) { - addName(tag); - } - if (root.sfc.scriptSetup) { - for (const binding of scriptSetupRanges?.bindings ?? []) { - addName(root.sfc.scriptSetup.content.slice(binding.range.start, binding.range.end)); - } - } - - return htmlDataProvider.provideTags() - .filter(tag => tag.void && !componentNames.has(tag.name.toLowerCase())) - .map(tag => tag.name); - } - async function provideHtmlData(sourceDocumentUri: URI, root: VueVirtualCode) { await (initializing ??= initialize()); @@ -862,25 +847,3 @@ function getPropName( } return { isEvent, propName: name }; } - -function createHtmlServiceFormatPatcher(htmlService: html.LanguageService) { - if (!isHtmlFormatterReady) { - return undefined; - } - - return async function patchVoidElements(voidElements: string[], run: () => T | PromiseLike) { - if (voidElements.length === 0) { - return await run(); - } - - const originalFormat = htmlService.format; - - htmlService.format = (document, range, options) => format(document, range, options, voidElements); - try { - return await run(); - } - finally { - htmlService.format = originalFormat; - } - }; -} diff --git a/packages/language-service/lib/utils.ts b/packages/language-service/lib/utils.ts index 34d6d8685a..52866a951d 100644 --- a/packages/language-service/lib/utils.ts +++ b/packages/language-service/lib/utils.ts @@ -39,16 +39,3 @@ export function createReferenceResolver( return moduleName ?? resolveReference(ref, uri, context.env.workspaceFolders); }; } - -// for checking module is exists when build -export function tryRequire(fn: () => T, errorMessage?: string): T | {} | undefined { - try { - return fn(); - } - catch { - if (errorMessage !== undefined) { - console.error(errorMessage); - } - return {}; - } -}