Repository navigation
Conversation
A list is loose if a blank line is between two of its items, or between two blocks of one item (CommonMark 5.3). The parser kept that in a flag for one line, and three paths dropped it: a blank line behind indented code, a blank line that ends an HTML block of type 6 or 7, and a blank line before a line that has only a container marker. In the other direction, a line with only the `>` of a quote that holds a list made the list around the quote loose. The end of `analyze_line` now has one setter and one reader. An empty line in a list item records the lowest container index that it can make loose: one past the innermost `>` on that line. The next line that is not empty reads it, and makes its list loose unless it is more of the indented code that the empty line is in. The blank line that ends HTML of types 6 and 7 falls through to the one blank-line arm, so that arm is the only producer of `is_empty`. No statement about looseness is left in the line-typing loop, and the two rare paths are out of line.
|
Updated 9:54 AM PT - Oct 21st, 2026
✅ @robobun, your commit d2e069fd29c1a17c8c6c3b41ae05a001be81f2ab passed in 🧪 To try this PR locally: bunx bun-pr 46119That installs a local version of the PR into your bun-46119 --bun |
|
Status Reproduction: PR: #46119 |
|
No actionable comments were generated in the recent review. WalkthroughThe parser now determines list looseness from blank-line boundaries within list containers. HTML block continuation handling also changed. Edge-case tests cover list output across block types and rendering APIs. ChangesMarkdown list parsing
Suggested reviewers: Merge Risk:
|
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked the release-build safety of the new reader: blank_line_in only runs when n_parents > 0, and end_blank_line_in_list is reached with count = n_parents + n_brothers <= n_containers, so the count - 1 index is in bounds; the debug_assert! on > holds because any quote the blank line did not mark is closed before the next line and any quote it did mark lies below floor. The closing-fence and fenced/HTML-1-5 content paths neither set nor read blank_line_in_list, matching the old flag's clearing there.
Extended reasoning...
The change replaces a per-line looseness flag in the md4c-port block parser (src/md/blocks.rs, src/md/parser.rs) with two Parser fields and a setter/reader pair, and restructures the HTML type 6/7 end-condition branch; it touches no security-sensitive surface beyond parsing untrusted Markdown with safe indexing. Two confirmed findings are posted inline, so a human look is already signalled; this note only records the index-bounds, debug_assert, and fence/HTML-content paths that were examined and ruled out.
|
A second report of this bug arrived. Its input has two items, and the first item is only an indented code block: Bun.markdown.html("1. code\n\n1. text\n");
No new test is necessary. The |
Problem
Bun.markdown.html("- code\n\n text\n")gives</pre>\ntext</li>: no<p>. The same happens behind HTML of type 6 or 7, and before a marker-only line. CommonMark 5.3 makes these lists loose.- > - b\n >\n- c\nbecomes loose, but its>-only line is in the quote.- a\n\n- [ ]\n b\nregressed in 1.4.4 with Addbun lintandbun format#44805.Fix
analyze_linekept a one-line flag that three paths dropped (src/md/blocks.rs:224,:248,:792). Now an empty line in a list item stores one past the innermost>on it. The next non-empty line in a deeper container makes its list loose, unless it continues indented code.test/js/bun/md/md-edge-cases.test.ts(69 new tests, 51 fail on main). Alsotest/js/bun/md/,test/cli/format/conformance.test.ts.bun lintdifferential test (Notes).Background
<p>. The flag comes from md4c, which has the same paths.Downsides
>-only line in a quote: 7 of 100,000 fuzz documents, 0 of 11,888 real files.Bun.markdown.ansithen prints the text behind it at column 0, without the bar of an outer quote. markdown: in ansi(), write a line's quote bars and list indent with its first content #46348 fixes that.Parsergrows 8 bytes,.text256 bytes.html()runs 1.2% to 1.6% fewer instructions.Notes
Where it comes from. A differential fuzz run reported five forms in the team chat. There is no GitHub issue and no user report. Of 11,888 Markdown files on the build machine (5,579 with a list), none renders differently in HTML or ANSI between release builds of main and of this branch. The wrong output is a missing or an extra
<p>, never lost text.What changes (
Bun.markdown.html, main and this branch):- code\n\n text\n...</pre>\ntext</li>...</pre>\n<p>text</p>\n</li>- <div>\n\n text\n<div>\ntext</li><div>\n<p>text</p>\n</li>- code\n\n- text\n<li>text</li><li>\n<p>text</p>\n</li>- a\n\n-\n<li>a</li><li>\n<p>a</p>\n</li>- a\n\n >\n<li>a\n<blockquote><li>\n<p>a</p>\n<blockquote>- > - b\n >\n- c\n<li>\n<p>c</p>\n</li><li>c</li>render(), react(), ansi(),
bun file.mdand an imported.mdfile use the same parser. The first three have a test. The last two call the same two functions and have none.The two shapes from #44805 (c39c718, first released in 1.4.4: tag
bun-v1.4.4has it,bun-v1.4.3does not). Checked on 1.3.13, 1.4.2, 1.4.4, main and this branch.- a\n\n- [ ]\n b\nis loose on 1.3.13 and 1.4.2, tight on 1.4.4 and main, loose here. Addbun lintandbun format#44805 types that lineBlank, and the old reader skippedBlanklines.- | a |\n |---|\n | 1 |\n </details>\n\n text\n):<p>text</p>on 1.4.2, baretexton 1.4.4 and main,<p>text</p>here.bun lintandbun format#44805 stay: an item with only a task mark has no empty<p></p>, and- [ ]\n\n text\nis loose.bun fmtshows it inside one binary. On mainbun fmtrewrites- a\n\n-\n b\nto- a\n\n- b, and the two render with 0 and 2<p>. It rewrites- > - b\n >\n- c\nto- > - b\n- c, with 1 and 0<p>. On this branch both spellings of each render the same. This is a repro for the marker and quote shapes only, not a claim that the formatter and the renderer now agree everywhere.How it works now.
blank_line_in_list, oneu32. It isNO_BLANK_LINE, or the lowest index incontainersof a list that the empty line before this one can make loose.blank_line_marksis how many containers a blank line has the marker or the indentation of. The blank line writes it before the lazy match, so the setter does not scan the lists that were matched lazily.NO_BLANK_LINEif it is in no list item. Otherwise it stores one past the innermost>that is on the line. In a run of empty lines the last one decides.- ```\n a\n\n- b\ntight, and so does this.md4c. By a read of mity/md4c@60e8065 (not a run),
src/md4c.chas the same four paths::6743,:6722-6726,:7063, and:6671-6676with:6747-6749. No md4c issue covers them, and I did not file one. From hereanalyze_linedoes not mirrormd_analyze_linefor looseness: a later diff of the port against upstream must not bring the flag back.Measurements. Release builds (
bun run build:release, linux-x64) of 91e7e3b and of f107088, which is this change on that commit.Instructions per input line in the block pass. gdb single-step count of one
Bun.markdown.htmlcall, documents of 100 and 200 lines, the difference divided by 100. Symbols:Parser::process_doc,analyze_line,is_container_mark,is_hr_line, the two HTML block conditions,is_table_underline,count_table_row_columns,leave_child_containers,end_current_block, and the two new functions.word wordp, blank line# h- item> word> - a- a,- b, blank,text, blank- a, blank- c, blank> - a,>analyze_lineinto the per-line loop ofprocess_doc. On main it is a function of 15,034 bytes with a call per line. The numbers are instruction counts of one link, not a property of the design.cmpb,jne,cmpl,je). The reader is one call in a block out of line.Bun.markdown.htmlcall, every instruction of the binary: the small document ofbench/snippets/markdown.mjs21,648 to 21,382, the medium one 115,620 to 113,780.nm -S,size):process_doc8,822 +analyze_line15,034 = 23,856 on main.process_doc23,942 +blank_line_in117 +end_blank_line_in_list69 = 24,128 here..text107,072,443 to 107,072,699. The strippedbunhas 107,243,232 bytes on both.rustc -Zprint-type-sizes):Parser1,272 to 1,280, one per call.Container32,BlockHeader16,Line20, all unchanged.mi_mallocandmi_realloc): 5 + 17 for 200 lines of a loose list and 5 + 19 for 200 items, on both. Block metadata (smallestsetMaxMarkdownBlockBytesForTestinglimit that parses a fixed document): the same on both for three documents.'- '.repeat(n) + 'a\n' + '\n'.repeat(n), CPU ms: n = 10,000 43.7 to 42.1, n = 20,000 172.6 to 165.3, n = 40,000 720.8 to 696.7. Quadratic before and after.Bun.markdown.htmlon the three documents ofbench/snippets/markdown.mjs, 28 interleaved runs per build on one core: small 1,854 to 1,869 ns, medium 8,340 to 8,330 ns, large 139,052 to 139,739 ns. A second run of the same pair gave +1.6%, +0.6% and +1.7%. Two builds of main five commits apart differ by 2.0% to 4.1% on the same documents, so this is inside the noise. The bench file itself needsmarkedandremark, which are not installed there, so the documents were timed with a loop.What the first version cost. The first version put
is_emptyin the tail as one expression and kept two more locals alive across the function. It cost 22 to 39 instructions more per line than main on the rows above. The cause was the register allocation of the whole function, not the looseness code. The final version keeps no new local alive across the function, and the two rare paths are out of line, after the precedent ofis_html_block_start_condition.The patch of the three sites. A draft with the quote guard is +25/-3 in the two files, against +64/-39 here. Without the guard it makes quote inputs wrong that main has right, such as
- a\n- > - code\n >\n- c\n(three such rows are in the test). Built in a small harness that links the samebun_mdbitcode, it runs 11 to 17 instructions more per line than main, where this branch runs 22 to 40 fewer in the same harness. It leaves six writes of the flag in the line-typing loop. That shape is how #44805 made- a\n\n- [ ]\n b\ntight.Where the expected values come from.
<p>.<p>follow CommonMark 5.3 (the example with> band>in an item) and a read of cmark'sS_ends_with_blank_line, which does not look into a block quote.- a\n- code\n\n [r]: /u\n), goes from tight to loose. commonmark.js, markdown-it and micromark are loose there. cmark is tight by a read. Bun was already loose when the block before the blank line is a paragraph.<p>, 7 lose it, nothing else differs.Left as it is.
- <pre>\n\n- b\n: a blank line in unclosed HTML of types 1 to 5, then a sibling. commonmark.js 0.31.2 is tight: the blank line is a line of the HTML block, andendsWithBlankLinecompares source positions (lib/blocks.js:78-81). cmark 0.31.2 is loose by a read:add_text_to_containerexcludes fenced code but not HTML (src/blocks.c:1175-1180). Stays tight, and the test row says that the two disagree.> - a\n> > b\n> >\n> c\nprintscalone at column 0 there. This PR moves the newly tight quote shape onto that path and the newly loose shapes off it (- code\n\n text\nhastextat column 0 on 1.4.4 and at column 4 here).Bun.markdown.ansi("> - a\n> > - b\n> >\n> c\n", { colors: false }):"| * a\n|\n| | * b\n|\n| c\n"on 1.4.4,"| * a\n|\n| | * b\nc\n"here."| * a\n|\n| | * b\n| c\n". The tests of both pass together: 1,403 intest/js/bun/md/andtest/cli/run/markdown-entrypoint.test.ts.bun fmtandbun lintdecide looseness by themselves from line positions (src/format/markdown/parse.rs). A test that compares that tree withBun.markdowncan only pass once this has landed.Not done from the self-review.
bun lintandBun.markdown. It fails on main, so it can only follow this change. The formatter follows Prettier's rule for each item on purpose, so the two need not agree on every input.Suites run on the debug build. With main at 2d6d445 merged in:
test/js/bun/md/(1,266 pass),test/cli/format/conformance.test.ts,test/cli/run/markdown-entrypoint.test.ts. Before that merge:test/internal/source-lints/. The 27 rows of #45924 pass with and without the change.