From 3664c2cccb71e99134694b1a5fefda108b48c94b Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sat, 18 Jul 2026 17:16:44 +0200 Subject: [PATCH 1/2] Use tight end positions for lazily closed blocks. Blocks that close when the parser recognizes the following construct (lists, tables, block quotes, definition lists, footnotes) reported their end position from the parse offset at close time. That included the separator blank line, and for lists the end could equal the next sibling's start, so sibling ranges overlapped and a parent list could end before its last item. Tighten these containers to end at their last child's end position, the same convention paragraphs and code fences already follow. List items are tightened as well so the parent range always contains them. Closes #141. --- src/parse.spec.ts | 82 +++++++++++++++++++++++++++++++++++++++++++++ src/parse.ts | 59 ++++++++++++++++++++++++++++++++ test/sourcepos.test | 2 +- 3 files changed, 142 insertions(+), 1 deletion(-) diff --git a/src/parse.spec.ts b/src/parse.spec.ts index faf1733..1d0a0ac 100644 --- a/src/parse.spec.ts +++ b/src/parse.spec.ts @@ -197,6 +197,88 @@ describe("Parser", () => { ); }); + it("uses tight source position ends for lazy-closing block quotes", () => { + const ast = parse("> q\n\nAfter.\n", {sourcePositions: true}) as any; + const blockQuote = ast.children[0]; + const para = ast.children[1]; + expect(blockQuote.pos).toEqual({ + "start": { "line": 1, "col": 1, "offset": 0 }, + "end": { "line": 2, "col": 0, "offset": 3 } + }); + expect(para.pos).toEqual({ + "start": { "line": 3, "col": 1, "offset": 5 }, + "end": { "line": 4, "col": 0, "offset": 11 } + }); + expect(blockQuote.pos.end.offset).toBeLessThan(para.pos.start.offset); + }); + + it("uses tight source position ends for lazy-closing tables", () => { + const ast = parse("| a |\n| --- |\n| 1 |\n\nAfter.\n", + {sourcePositions: true}) as any; + const table = ast.children[0]; + const para = ast.children[1]; + expect(table.pos).toEqual({ + "start": { "line": 1, "col": 1, "offset": 0 }, + "end": { "line": 4, "col": 0, "offset": 19 } + }); + expect(para.pos).toEqual({ + "start": { "line": 5, "col": 1, "offset": 21 }, + "end": { "line": 6, "col": 0, "offset": 27 } + }); + expect(table.pos.end.offset).toBeLessThan(para.pos.start.offset); + }); + + it("uses tight source position ends for lazy-closing lists", () => { + const ast = parse("- a\n- b\n\nAfter.\n", + {sourcePositions: true}) as any; + const list = ast.children[0]; + const para = ast.children[1]; + expect(list.pos).toEqual({ + "start": { "line": 1, "col": 1, "offset": 0 }, + "end": { "line": 3, "col": 0, "offset": 7 } + }); + expect(para.pos).toEqual({ + "start": { "line": 4, "col": 1, "offset": 9 }, + "end": { "line": 5, "col": 0, "offset": 15 } + }); + expect(list.pos.end.offset).toBeLessThan(para.pos.start.offset); + // The lazily closed last item must stay contained in the list's range. + const lastItem = list.children[list.children.length - 1]; + expect(lastItem.pos.end.offset).toBeLessThanOrEqual(list.pos.end.offset); + }); + + it("uses tight source position ends for lazy-closing definition lists", () => { + const ast = parse(": term\n def\n\nAfter.\n", + {sourcePositions: true}) as any; + const list = ast.children[0]; + const para = ast.children[1]; + expect(list.pos).toEqual({ + "start": { "line": 1, "col": 1, "offset": 0 }, + "end": { "line": 3, "col": 0, "offset": 12 } + }); + expect(para.pos).toEqual({ + "start": { "line": 4, "col": 1, "offset": 14 }, + "end": { "line": 5, "col": 0, "offset": 20 } + }); + expect(list.pos.end.offset).toBeLessThan(para.pos.start.offset); + }); + + it("uses tight source position ends for lazy-closing footnotes", () => { + const ast = parse("[^a]: note\n\nAfter.\n", + {sourcePositions: true}) as any; + const footnote = ast.footnotes.a; + const para = ast.children[0]; + expect(footnote.pos).toEqual({ + "start": { "line": 1, "col": 1, "offset": 0 }, + "end": { "line": 2, "col": 0, "offset": 10 } + }); + expect(para.pos).toEqual({ + "start": { "line": 3, "col": 1, "offset": 12 }, + "end": { "line": 4, "col": 0, "offset": 18 } + }); + expect(footnote.pos.end.offset).toBeLessThan(para.pos.start.offset); + }); + it("renders pretty", () => { const ast = parse("hi there\nfriend\n\nnew para\n", {sourcePositions: true}); expect(renderAST(ast)).toEqual( diff --git a/src/parse.ts b/src/parse.ts index e094801..3de8e9a 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -252,6 +252,53 @@ const parseFromEvents = function(events: Event[], } } + const getLastChildEnd = function(node: Container): SourceLoc | undefined { + for (let i = node.children.length - 1; i >= 0; i--) { + const child = node.children[i]; + if (child.pos) { + return child.pos.end; + } + } + return undefined; + } + + const getLastBlockEnd = function(node: Container | AstNode): + SourceLoc | undefined { + if ("children" in node) { + for (let i = node.children.length - 1; i >= 0; i--) { + const child = node.children[i]; + if ("tag" in child && isBlock(child) && child.pos) { + return child.pos.end; + } + const end = getLastBlockEnd(child); + if (end) { + return end; + } + } + } + return undefined; + } + + const tightenContainerEnd = function(node: Container): void { + if (!node.pos) { + return; + } + const end = getLastChildEnd(node); + if (end) { + node.pos.end = end; + } + } + + const tightenListEnd = function(node: Container): void { + if (!node.pos) { + return; + } + const end = getLastBlockEnd(node); + if (end) { + node.pos.end = end; + } + } + const handlers : Record { const node = popContainer(pos); + // A lazily closed item (e.g. before a blank line that ends the list) + // must not extend past its own content, so the parent list's + // tightened range still contains it. + tightenContainerEnd(node); if (node.data.definitionList) { if (node.children[0] && node.children[0].tag === "para") { const term: Term = @@ -934,6 +990,7 @@ const parseFromEvents = function(events: Event[], ["-block_quote"]: (suffixes, startpos, endpos, pos) => { const node = popContainer(pos); + tightenContainerEnd(node); addChildToTip({ tag: "block_quote", children: node.children, @@ -949,6 +1006,7 @@ const parseFromEvents = function(events: Event[], ["-table"]: (suffixes, startpos, endpos, pos) => { const node = popContainer(pos); + tightenContainerEnd(node); const rows = node.children; let caption: Caption = { tag: "caption", @@ -1059,6 +1117,7 @@ const parseFromEvents = function(events: Event[], ["-footnote"]: (suffixes, startpos, endpos, pos) => { const node = popContainer(pos); + tightenContainerEnd(node); if (node.data.label) { const lab = normalizeLabel(node.data.label); footnotes[lab] = diff --git a/test/sourcepos.test b/test/sourcepos.test index 643816e..aeb8d7f 100644 --- a/test/sourcepos.test +++ b/test/sourcepos.test @@ -4,7 +4,7 @@ . doc bullet_list (1:2:1-3:0:9) tight=true style="-" - list_item (1:2:1-2:1:5) + list_item (1:2:1-2:0:4) para (1:4:3-2:0:4) str (1:4:3-1:4:3) text="a" list_item (2:2:6-3:0:9) From 2020fd149b5443ad992c9ddb3eb72701e1f13288 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Sun, 19 Jul 2026 11:05:44 +0200 Subject: [PATCH 2/2] Simplify end-position tightening to a single last-child check. Since -list_item events fire before their parent -list, the last child already carries a tight end position when the list closes. The recursive getLastBlockEnd and the fallback to earlier siblings were unnecessary; one function handles all containers. --- src/parse.ts | 52 ++++------------------------------------------------ 1 file changed, 4 insertions(+), 48 deletions(-) diff --git a/src/parse.ts b/src/parse.ts index 3de8e9a..833daf9 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -252,50 +252,10 @@ const parseFromEvents = function(events: Event[], } } - const getLastChildEnd = function(node: Container): SourceLoc | undefined { - for (let i = node.children.length - 1; i >= 0; i--) { - const child = node.children[i]; - if (child.pos) { - return child.pos.end; - } - } - return undefined; - } - - const getLastBlockEnd = function(node: Container | AstNode): - SourceLoc | undefined { - if ("children" in node) { - for (let i = node.children.length - 1; i >= 0; i--) { - const child = node.children[i]; - if ("tag" in child && isBlock(child) && child.pos) { - return child.pos.end; - } - const end = getLastBlockEnd(child); - if (end) { - return end; - } - } - } - return undefined; - } - const tightenContainerEnd = function(node: Container): void { - if (!node.pos) { - return; - } - const end = getLastChildEnd(node); - if (end) { - node.pos.end = end; - } - } - - const tightenListEnd = function(node: Container): void { - if (!node.pos) { - return; - } - const end = getLastBlockEnd(node); - if (end) { - node.pos.end = end; + const lastChild = node.children[node.children.length - 1]; + if (node.pos && lastChild && lastChild.pos) { + node.pos.end = lastChild.pos.end; } } @@ -859,11 +819,7 @@ const parseFromEvents = function(events: Event[], if (!listStyle) { throw (new Error("No style defined for list")); } - if (listStyle === ":") { - tightenContainerEnd(node); - } else { - tightenListEnd(node); - } + tightenContainerEnd(node); const listStart = getListStart(node.data.firstMarker, listStyle); if (listStyle === ":") { addChildToTip({