From 8da6ea3bef369b7b372a19ebf8f303ad611a2852 Mon Sep 17 00:00:00 2001 From: Maruthan G Date: Sat, 11 Apr 2026 14:18:50 +0530 Subject: [PATCH 1/2] fix: parse closing tags without matching opening tags (#149) --- src/parser/htmlParser.ts | 7 +++++++ src/test/parser.test.ts | 16 ++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/parser/htmlParser.ts b/src/parser/htmlParser.ts index 652e7ac..ccea73d 100644 --- a/src/parser/htmlParser.ts +++ b/src/parser/htmlParser.ts @@ -137,6 +137,13 @@ export class HTMLParser { curr.endTagStart = endTagStart; curr.end = scanner.getTokenEnd(); curr = curr.parent!; + } else { + // closing tag without a matching opening tag + const orphan = new Node(endTagStart, scanner.getTokenEnd(), [], curr); + orphan.tag = endTagName; + orphan.closed = true; + orphan.endTagStart = endTagStart; + curr.children.push(orphan); } break; case TokenType.AttributeName: { diff --git a/src/test/parser.test.ts b/src/test/parser.test.ts index f4fbf0e..bedd0c6 100644 --- a/src/test/parser.test.ts +++ b/src/test/parser.test.ts @@ -59,13 +59,25 @@ suite('HTML Parser', () => { }); test('MissingTags', () => { - assertDocument('', []); - assertDocument('
', [{ tag: 'div', start: 0, end: 11, endTagStart: 5, closed: true, children: [] }]); + assertDocument('', [{ tag: 'meta', start: 0, end: 7, endTagStart: 0, closed: true, children: [] }]); + assertDocument('
', [{ tag: 'div', start: 0, end: 11, endTagStart: 5, closed: true, children: [] }, { tag: 'div', start: 11, end: 17, endTagStart: 11, closed: true, children: [] }]); assertDocument('
', [{ tag: 'div', start: 0, end: 16, endTagStart: void 0, closed: false, children: [{ tag: 'div', start: 5, end: 16, endTagStart: 10, closed: true, children: [] }] }]); assertDocument('<div>', [{ tag: 'title', start: 0, end: 20, endTagStart: 12, closed: true, children: [{ tag: 'div', start: 7, end: 12, endTagStart: void 0, closed: false, children: [] }] }]); assertDocument('

', [{ tag: 'h1', start: 0, end: 20, endTagStart: 15, closed: true, children: [{ tag: 'div', start: 4, end: 15, endTagStart: void 0, closed: false, children: [{ tag: 'span', start: 9, end: 15, endTagStart: void 0, closed: false, children: [] }] }] }]); }); + test('Closing tag without opening tag (issue #149)', () => { + // standalone closing tag + assertDocument('
', [{ tag: 'div', start: 0, end: 6, endTagStart: 0, closed: true, children: [] }]); + // closing tag for a different element than the one opened + assertDocument('
', [{ tag: 'div', start: 0, end: 18, endTagStart: 12, closed: true, children: [{ tag: 'span', start: 5, end: 12, endTagStart: 5, closed: true, children: [] }] }]); + // multiple orphan closing tags + assertDocument('', [ + { tag: 'a', start: 0, end: 4, endTagStart: 0, closed: true, children: [] }, + { tag: 'b', start: 4, end: 8, endTagStart: 4, closed: true, children: [] } + ]); + }); + test('MissingBrackets', () => { assertDocument('
', [{ tag: 'div', start: 0, end: 15, endTagStart: 9, closed: true, children: [{ tag: 'div', start: 5, end: 9, endTagStart: void 0, closed: false, children: [] }] }]); assertDocument('
', [{ tag: 'div', start: 0, end: 16, endTagStart: 10, closed: true, children: [{ tag: 'div', start: 5, end: 10, endTagStart: void 0, closed: false, children: [] }] }]); From 84d070dff9fc63c0a3c41788184b45811cb330a2 Mon Sep 17 00:00:00 2001 From: Maruthan G Date: Mon, 13 Jul 2026 22:50:44 +0530 Subject: [PATCH 2/2] fix(parser): make orphan end-tag nodes opt-in via parse option Guard the creation of nodes for closing tags without a matching opening tag behind a new createNodesForOrphanEndTags option so the default parse output is unchanged (orphan end tags remain discarded). Thread the option through HTMLParser.parse/parseDocument and expose it on the public parseHTMLDocument API. Revert MissingTags expectations to the original behavior and assert the issue #149 output only on the opt-in path. --- src/htmlLanguageService.ts | 2 +- src/parser/htmlParser.ts | 10 +++++----- src/test/parser.test.ts | 30 ++++++++++++++++++++---------- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/htmlLanguageService.ts b/src/htmlLanguageService.ts index 8ce5bc7..e0d13ed 100644 --- a/src/htmlLanguageService.ts +++ b/src/htmlLanguageService.ts @@ -30,7 +30,7 @@ export * from './htmlLanguageTypes.js'; export interface LanguageService { setDataProviders(useDefaultDataProvider: boolean, customDataProviders: IHTMLDataProvider[]): void; createScanner(input: string, initialOffset?: number): Scanner; - parseHTMLDocument(document: TextDocument): HTMLDocument; + parseHTMLDocument(document: TextDocument, options?: { createNodesForOrphanEndTags?: boolean }): HTMLDocument; findDocumentHighlights(document: TextDocument, position: Position, htmlDocument: HTMLDocument): DocumentHighlight[]; doComplete(document: TextDocument, position: Position, htmlDocument: HTMLDocument, options?: CompletionConfiguration): CompletionList; doComplete2(document: TextDocument, position: Position, htmlDocument: HTMLDocument, documentContext: DocumentContext, options?: CompletionConfiguration): Promise; diff --git a/src/parser/htmlParser.ts b/src/parser/htmlParser.ts index ccea73d..ba4af31 100644 --- a/src/parser/htmlParser.ts +++ b/src/parser/htmlParser.ts @@ -68,11 +68,11 @@ export class HTMLParser { } - public parseDocument(document: TextDocument): HTMLDocument { - return this.parse(document.getText(), this.dataManager.getVoidElements(document.languageId)); + public parseDocument(document: TextDocument, options?: { createNodesForOrphanEndTags?: boolean }): HTMLDocument { + return this.parse(document.getText(), this.dataManager.getVoidElements(document.languageId), options); } - public parse(text: string, voidElements: string[]): HTMLDocument { + public parse(text: string, voidElements: string[], options?: { createNodesForOrphanEndTags?: boolean }): HTMLDocument { const scanner = createScanner(text, undefined, undefined, true); const htmlDocument = new Node(0, text.length, [], void 0); @@ -137,8 +137,8 @@ export class HTMLParser { curr.endTagStart = endTagStart; curr.end = scanner.getTokenEnd(); curr = curr.parent!; - } else { - // closing tag without a matching opening tag + } else if (options?.createNodesForOrphanEndTags === true) { + // closing tag without a matching opening tag (opt-in behavior) const orphan = new Node(endTagStart, scanner.getTokenEnd(), [], curr); orphan.tag = endTagName; orphan.closed = true; diff --git a/src/test/parser.test.ts b/src/test/parser.test.ts index bedd0c6..64085be 100644 --- a/src/test/parser.test.ts +++ b/src/test/parser.test.ts @@ -9,9 +9,9 @@ import { HTMLParser, Node } from '../parser/htmlParser.js'; import { HTMLDataManager } from '../languageFacts/dataManager.js'; suite('HTML Parser', () => { - function parse(text: string) { + function parse(text: string, options?: { createNodesForOrphanEndTags?: boolean }) { const htmlDataManager = new HTMLDataManager({}); - return new HTMLParser(htmlDataManager).parse(text, htmlDataManager.getVoidElements('html')); + return new HTMLParser(htmlDataManager).parse(text, htmlDataManager.getVoidElements('html'), options); } function toJSON(node: Node): any { return { tag: node.tag, start: node.start, end: node.end, endTagStart: node.endTagStart, closed: node.closed, children: node.children.map(toJSON) }; @@ -21,8 +21,8 @@ suite('HTML Parser', () => { return { tag: node.tag, attributes: node.attributes, children: node.children.map(toJSONWithAttributes) }; } - function assertDocument(input: string, expected: any) { - const document = parse(input); + function assertDocument(input: string, expected: any, options?: { createNodesForOrphanEndTags?: boolean }) { + const document = parse(input, options); assert.deepEqual(document.roots.map(toJSON), expected); } @@ -59,23 +59,33 @@ suite('HTML Parser', () => { }); test('MissingTags', () => { - assertDocument('', [{ tag: 'meta', start: 0, end: 7, endTagStart: 0, closed: true, children: [] }]); - assertDocument('
', [{ tag: 'div', start: 0, end: 11, endTagStart: 5, closed: true, children: [] }, { tag: 'div', start: 11, end: 17, endTagStart: 11, closed: true, children: [] }]); + assertDocument('', []); + assertDocument('
', [{ tag: 'div', start: 0, end: 11, endTagStart: 5, closed: true, children: [] }]); assertDocument('
', [{ tag: 'div', start: 0, end: 16, endTagStart: void 0, closed: false, children: [{ tag: 'div', start: 5, end: 16, endTagStart: 10, closed: true, children: [] }] }]); assertDocument('<div>', [{ tag: 'title', start: 0, end: 20, endTagStart: 12, closed: true, children: [{ tag: 'div', start: 7, end: 12, endTagStart: void 0, closed: false, children: [] }] }]); assertDocument('

', [{ tag: 'h1', start: 0, end: 20, endTagStart: 15, closed: true, children: [{ tag: 'div', start: 4, end: 15, endTagStart: void 0, closed: false, children: [{ tag: 'span', start: 9, end: 15, endTagStart: void 0, closed: false, children: [] }] }] }]); }); - test('Closing tag without opening tag (issue #149)', () => { + test('Closing tag without opening tag (issue #149) - opt-in', () => { + const withOrphans = { createNodesForOrphanEndTags: true }; // standalone closing tag - assertDocument('
', [{ tag: 'div', start: 0, end: 6, endTagStart: 0, closed: true, children: [] }]); + assertDocument('', [{ tag: 'div', start: 0, end: 6, endTagStart: 0, closed: true, children: [] }], withOrphans); // closing tag for a different element than the one opened - assertDocument('
', [{ tag: 'div', start: 0, end: 18, endTagStart: 12, closed: true, children: [{ tag: 'span', start: 5, end: 12, endTagStart: 5, closed: true, children: [] }] }]); + assertDocument('
', [{ tag: 'div', start: 0, end: 18, endTagStart: 12, closed: true, children: [{ tag: 'span', start: 5, end: 12, endTagStart: 5, closed: true, children: [] }] }], withOrphans); // multiple orphan closing tags assertDocument('', [ { tag: 'a', start: 0, end: 4, endTagStart: 0, closed: true, children: [] }, { tag: 'b', start: 4, end: 8, endTagStart: 4, closed: true, children: [] } - ]); + ], withOrphans); + }); + + test('Closing tag without opening tag (issue #149) - default discards orphans', () => { + // default (option absent) keeps original behavior: orphan closing tags are discarded + assertDocument('', []); + assertDocument('', []); + assertDocument('
', [{ tag: 'div', start: 0, end: 18, endTagStart: 12, closed: true, children: [] }]); + // explicitly false behaves the same as absent + assertDocument('', [], { createNodesForOrphanEndTags: false }); }); test('MissingBrackets', () => {