diff --git a/extensions/html-language-features/server/src/modes/htmlMode.ts b/extensions/html-language-features/server/src/modes/htmlMode.ts index 58a3ded2beed2..15066ecabd669 100644 --- a/extensions/html-language-features/server/src/modes/htmlMode.ts +++ b/extensions/html-language-features/server/src/modes/htmlMode.ts @@ -20,14 +20,45 @@ export function getHTMLMode(htmlLanguageService: HTMLLanguageService, workspace: async getSelectionRange(document: TextDocument, position: Position): Promise { return htmlLanguageService.getSelectionRanges(document, [position])[0]; }, - doComplete(document: TextDocument, position: Position, documentContext: DocumentContext, settings = workspace.settings) { + async doComplete(document: TextDocument, position: Position, documentContext: DocumentContext, settings = workspace.settings) { const htmlSettings = settings?.html; const options = merge(htmlSettings?.suggest, {}); options.hideAutoCompleteProposals = htmlSettings?.autoClosingTags === true; options.attributeDefaultValue = htmlSettings?.completion?.attributeDefaultValue ?? 'doublequotes'; const htmlDocument = htmlDocuments.get(document); - const completionList = htmlLanguageService.doComplete2(document, position, htmlDocument, documentContext, options); + const completionList = await htmlLanguageService.doComplete2(document, position, htmlDocument, documentContext, options); + + // Fix completion ranges for unclosed string literals (issue #273226). + // When an attribute value has an opening quote but no closing quote, + // the parser treats everything after the quote as the attribute value, + // causing the replacement range to extend too far (e.g., into subsequent tags). + // We clamp the replacement range to end before the next '<' character after the cursor. + const text = document.getText(); + const offset = document.offsetAt(position); + for (const item of completionList.items) { + if (!item.textEdit) { + continue; + } + const editRange = 'range' in item.textEdit ? item.textEdit.range : item.textEdit.replace; + const editEndOffset = document.offsetAt(editRange.end); + if (editEndOffset <= offset) { + continue; + } + // Check if the text between cursor and end of range contains a '<', + // which indicates the range leaked into subsequent HTML tags + const textAfterCursor = text.substring(offset, editEndOffset); + const angleBracketIndex = textAfterCursor.indexOf('<'); + if (angleBracketIndex !== -1) { + const clampedEnd = document.positionAt(offset + angleBracketIndex); + if ('range' in item.textEdit) { + item.textEdit.range = Range.create(editRange.start, clampedEnd); + } else { + item.textEdit.replace = Range.create(editRange.start, clampedEnd); + } + } + } + return completionList; }, async doHover(document: TextDocument, position: Position, settings?: Settings) { diff --git a/extensions/html-language-features/server/src/test/completions.test.ts b/extensions/html-language-features/server/src/test/completions.test.ts index fbad266e2dea2..0b6885b33aacf 100644 --- a/extensions/html-language-features/server/src/test/completions.test.ts +++ b/extensions/html-language-features/server/src/test/completions.test.ts @@ -94,6 +94,34 @@ suite('HTML Completion', () => { }); }); +suite('HTML Unclosed String Completions', () => { + test('Completion should not replace content after cursor when attribute value has unclosed quote', async () => { + // Issue #273226: When an attribute value has an opening quote but no closing quote, + // the replacement range should not extend into subsequent HTML tags. + await testCompletionFor('' }, + ] + }); + }); + + test('Completion should not replace content after cursor with space before closing tag', async () => { + await testCompletionFor('' }, + ] + }); + }); + + test('Completion with properly closed quotes should still work normally', async () => { + await testCompletionFor('', { + items: [ + { label: 'checkbox', resultText: '' }, + ] + }); + }); +}); + suite('HTML Path Completion', () => { const triggerSuggestCommand = { title: 'Suggest',