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 652e7ac..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,6 +137,13 @@ export class HTMLParser { curr.endTagStart = endTagStart; curr.end = scanner.getTokenEnd(); curr = curr.parent!; + } 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; + 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..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); } @@ -66,6 +66,28 @@ suite('HTML Parser', () => { 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) - opt-in', () => { + const withOrphans = { createNodesForOrphanEndTags: true }; + // standalone closing tag + 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: [] }] }], 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', () => { 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: [] }] }]);