Skip to content

markdown: in ansi(), write a line's quote bars and list indent with its first content - #46348

Open
robobun wants to merge 3 commits into
mainfrom
robobun/fa403766/md-ansi-container-prefix
Open

robobun wants to merge 3 commits into
mainfrom
robobun/fa403766/md-ansi-container-prefix

Conversation

@robobun

@robobun robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • No user reported this. Bun.markdown.ansi() draws an HTML block in a list item at column 0 and moves the next marker right: - a\n <div>\n- b\n prints * a\n<div>\n * b\n (type-fest readme).
  • Text behind a code block in a tight list item is at column 0 (uglify-js README).
  • write_wrapped (src/md/ansi_renderer.rs:764, :789) writes the next line's indent (quote bars, list spaces) after each line break, also when no line follows.

Fix

  • A line break with nothing behind it writes no indent. The first inline write of a line does (begin_line()).
  • write_indent() clears last_was_newline, so a line with only an indent ends like any line.
  • Verified: 106 new tests in test/js/bun/md/md-edge-cases.test.ts (81 fail on main), one CLI test, all of test/js/bun/md/.
  • Self-reviewed: 13 concerns raised, 12 addressed. Not done: a code-span PR (Notes).

Background

  • An HTML block arrives as text events: each line, then "\n", also the last.
  • Considered a flag in the HTML arms alone: it puts text behind the block at column 0.

Downsides

  • Output changes in 11 of 4,598 .md files: 6 for the HTML block (none in a block quote), 5 for text behind a code block.
  • At columns > 0 a render takes 0.3% (README.md) to 2.4% (2,000 HTML blocks) more instructions. .text: +512 bytes.
  • []() alone behind a code block in a tight list item prints a line of only the indent. 0 of 4,598 files.
Notes

Where this comes from. No user reported it and there is no issue. A review comment on #45611 asked about the layout of an indented HTML-block line in ansi(). That layout is not changed here: columns is documented for paragraphs, headings and the rule, and no text is lost. A read of the same path showed that an HTML block in a list item or a block quote is drawn outside of it, at every width, on 1.4.3 and on main. #46119 names the tight-item part as a separate renderer change. #46119 lists one ansi() case that it makes worse (c at column 0 behind a block in a tight item). On this branch the same order of events (- a\n > - # b\n c\n) puts c at the indent of the item.

What changes (Bun.markdown.ansi(input, { colors: false }), the same at columns: 0 and 80):

input main this branch
- a\n <div>\n- b\n * a\n<div>\n * b\n * a\n <div>\n * b\n
> <div>\n <div>\n| | <div>\n
1. <link to issue>\n2. <link to issue>\n\nafter\n 1. \n<link to issue>\n 2. \n<link to issue>\n \nafter\n 1. \n <link to issue>\n 2. \n <link to issue>\n\nafter\n
- > <div>\n > x\n > </div>\n- next\n * \n\n<div>\n| x\n| </div>\n| * next\n * \n\n| <div>\n| x\n| </div>\n * next\n
a tight item with a, a fenced code block, then b b at column 0 b at column 4
- a&#10;\n- next\n * a\n * next\n * a\n * next\n
> []()\n | | \n

Every kind of output change.

  • An HTML block in a list item or a block quote has the indent on each line, and leaves none behind its last line.
  • An empty line comes before a paragraph that follows an HTML block in a loose list item, as it does outside of a list. On main the paragraph started on the left-over indent.
  • Text behind a code block, a heading, a rule, or a nested block quote or list that does not end in a paragraph, in a tight list item, has the indent and quote bars of the item. Text directly behind a table is a row of that table and does not change.
  • A line break with nothing behind it leaves no indent: &#10; at the end of a paragraph, a hard break that ends link text or image alt text, a space at the wrap column, an image that Kitty draws at the end of a paragraph.
  • A line that has only an indent is ended. That is a paragraph or a link that draws nothing ([](), or text of control characters only). What follows is then where it is without it. On main the next list marker goes behind the left-over indent: - a\\\n []()\n- next\n prints * a\n * next\n on main and * a\n \n * next\n here.
  • With colors, the quote bar of an HTML-block line comes before the dim style and not inside it. An indent that follows a closing escape comes behind that escape (link text that ends with a hard break).

Not changed.

  • Where an HTML-block line wraps, and that a run of spaces inside it becomes one space at columns > 0.
  • The bytes of an HTML block outside of a container. The ansi() expectations that markdown: write code and HTML lines as source slices, so a leading tab stays a tab #45611 adds hold on this branch (9 of 9).
  • No separator line before an HTML block. The marker stays alone on its line when an HTML block is the first child of a list item.
  • An empty line inside an HTML block in a container prints the indent alone.
  • bun lint --explain calls the same function. It has no test here.

Left as it is (the same bytes on main and here):

  • A line break inside a code span. The text behind it is at column 0: - x `a\n b` y\n- next\n prints * x a\nb y\n * next\n. 234 such spans are in 66 of the 4,598 files. 26 files have one in a list item, CLAUDE.md of this repository among them (lines 13 and 14). The cause is another one: text() writes the code text whole, so its line break does not pass break_line. Open Bun.markdown: implement the latexMath and underline options #39493 changes that arm. The change needs its own numbers, because it moves where lines wrap.
  • A line feed inside a heading or a table cell: > # a&#10;b\n prints | a\nb\n| ===\n, and a cell with &#10; breaks its row. The heading buffer and a cell are written whole. A setext heading with a hard break is the same case.
  • The bar of a block quote in a list item is left of the list indent: 1. step\n\n > **Note**\n > careful\n\n2. next\n prints 1. step\n\n| Note careful\n 2. next\n. write_indent() writes all bars, then all spaces. 8 of the 4,598 files have a block quote in a list item.
  • With colors, an escape that closes a span, an HTML text or a paragraph comes behind a line break that is the last thing drawn: a&#10;\n prints a\n\x1b[0m, and <div>\n prints \x1b[2m<div>\x1b[22m\x1b[2m\n\x1b[22m. Such output does not end in \n. Nothing of it is visible. To change the order, the line break has to wait for what follows it, and the bytes outside of a container change.

How it works.

  • last_was_newline is true while nothing is on the current line. write_indent() now clears it when it writes an indent. Before, a line with only an indent counted as empty, so ensure_newline() did not end it and the next block was drawn behind it.
  • begin_line() is called by the writers that can put the first byte on a line: write_wrapped, write_styled with text, write_no_wrap, enter_span (before the span opens, so that its style and link do not cover the indent) and the HTML arm of text (before the dim style).
  • break_line(rest) writes the indent at once when rest of the same text follows. That keeps the test off the path of a word.
  • The block arms that write their own indent (Li, Hr, P, the flush functions, wrap_break) are not changed. The two Kitty writers lose their write_indent().
  • write_wrapped and write_styled spell the test of begin_line() out, with their emptiness test in the same condition. A call of the helper behind the emptiness test costs more: README.md at 80 columns +0.48% against +0.29%, 40,000 spans +1.58% against +0.74% (the same main binary).
  • in_main_flow() is the condition that write_styled and write_no_wrap had inline.
  • The no-wrap path of write_wrapped finds the line break with strings::index_of_char_usize, not byte by byte. Its loop body changed for break_line, and the byte loop got about 1 instruction per byte slower or faster with each version of that body.

Tests.

  • 106 new tests in md-edge-cases.test.ts: 81 fail on main and on 1.4.3. The other 25 end in as before. They pass on main, and each fails when one part of the fix is taken out.
  • Each part of the fix was taken out or put back to what main does, one at a time, in a scratch build (21 variants). Every variant fails at least one new test.
  • The new test in markdown-entrypoint.test.ts fails on main. The snapshot file does not change.
  • Where an image that Kitty draws ends the paragraph, the two Kitty tests compare the text without the escapes (the last item of "Left as it is").
  • The test for an image file that Kitty draws writes the path with forward slashes and has no Windows skip. It ran on Linux only before this PR's CI.

Measurements. Main is 9634520. Release builds of both. 4,598 distinct .md files: the tracked ones, and those of test/node_modules, node_modules and vendor. 1,801 of them are compiler fixtures (.expect.md) with no list and no block quote. 1,818 have a list, 531 have a block quote.

  • Output changes in 11 of 4,598 .md files (colors off and on, columns 0 and 80, and colors with hyperlinks): 6 with an HTML block in a list item (3 prompt files of this repository, the type-fest readme at 2 versions, undici's Client.md), 5 with text behind a code block in a tight list item (uglify-js, xml2js, fastify Server.md and TypeScript.md, google/benchmark reducing_variance.md), 0 other, 0 unclassified. Colors-on only: 0. No file has an HTML block in a block quote.
  • Instructions per render, colors on, in-process perf_event_open counters in user mode, medians of 10 interleaved runs. The highest spread of the 10 runs is 0.08% of the median. valgrind is not installed on the build machine.
input 80 columns columns: 0
README.md +0.29% (6,405,480 to 6,424,027) -0.81%
type-fest 0.21.3 readme.md +0.53% -0.35%
800 generated paragraphs with spans (250 KB) +0.43% -2.60%
800 paragraphs of plain words +0.28% -13.56%
500 nested list items +0.45% -2.43%
40,000 one-word **spans** +0.74% -0.37%
2,000 paragraphs of code spans +0.64% +0.15%
2,000 paragraphs of links +0.50% +0.17%
2,000 HTML blocks +2.41% +0.57%
2,000 HTML blocks in a block quote +1.44% -0.33%

The decreases at columns: 0 come from the line-break search, which takes the place of a byte loop, not from the fix. The HTML-block rows are the highest: each line of an HTML block outside of a container tests for an indent twice and finds none to write.

Self-review. 13 concerns, 12 addressed. The largest: the first version added a field, indent_pending, that mirrored last_was_newline. It is gone. That also removed a case that was worse than main (a paragraph of an outer list item drawn in a nested item behind a link that draws nothing). Not done: a PR of its own for the line break in a code span. It is another cause and another behaviour change, and it is named above with the arm that #39493 changes.

…ts first content

A line break in inline content wrote the indent of the next line at once,
also when no line followed. An HTML block in a list item or a block quote
ended with that indent and started without one, so the next list marker
moved right. Text behind a code block in a tight list item was at column 0.

A line break now writes the indent of the next line only when more of the
same text follows. Each inline write that can be the first of a line writes
the indent when nothing is on the line yet. A line that has only its indent
is ended like a line with content.
@robobun

robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:05 PM PT - Oct 21st, 2026

❌ @robobun, your commit 1c7a876 has some failures in Build #130267 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 46348

That installs a local version of the PR into your bun-46348 executable, so you can run:

bun-46348 --bun

@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduction (bun 1.4.3 and main):

const plain = md => JSON.stringify(Bun.markdown.ansi(md, { colors: false }));

console.log(plain("- a\n  <div>\n- b\n"));
// "  * a\n<div>\n      * b\n"
// `<div>` is at column 0 and the second marker is at column 6.

console.log(plain("- a\n  ```\n  x\n  ```\n  b\n- next\n"));
// "  * a\n\n    +-\n    | x\n    +-\nb\n  * next\n"
// `b` is at column 0.

The same happens with bun file.md, at every value of columns.

PR: #46348

@coderabbitai

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 2de57c53-7631-461e-af9e-4c2d869704cc
📥 Commits

Reviewing files that changed from the base of the PR and between d9438d9 and 1c7a876.

📒 Files selected for processing: 2
  • src/md/ansi_renderer.rs
  • test/js/bun/md/md-edge-cases.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

Walkthrough

The ANSI Markdown renderer centralizes line-start indentation and line breaking. CLI and JavaScript tests check indentation across HTML blocks, nested containers, wrapping, links, and Kitty images.

Changes

ANSI Markdown line indentation

Layer / File(s) Summary
Line starts and wrapping
src/md/ansi_renderer.rs
Centralized helpers manage indentation for inline and wrapped output. Kitty image emission no longer adds extra indentation after its terminating newline.
Indentation regression tests
test/cli/run/markdown-entrypoint.test.ts, test/js/bun/md/md-edge-cases.test.ts
Added tests for indentation and prefixes around HTML blocks, nested containers, wrapping, links, and Kitty images.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 1c7a8

No actionable merge-blocking issue is established; the indentation change is mergeable after normal checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly describes the main change: ANSI rendering now writes quote bars and list indentation with the line's first content.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The description is complete and relevant. It explains the problem, fix, scope, known limitations, test coverage, and performance measurements. It does not use the template headings exactly, but it pro…
Full details: Description check

Explanation

The description is complete and relevant. It explains the problem, fix, scope, known limitations, test coverage, and performance measurements. It does not use the template headings exactly, but it provides the required information, including how the changes were verified.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 two refactor concerns and ruled them out: break_line dropping the explicit last_was_newline = true is covered because write_raw(b"\n") sets it from the trailing byte (src/md/ansi_renderer.rs:1096), and the Li/P/Hr arms that call write_indent() before their first write_styled do not double-indent, since write_indent now clears last_was_newline when it draws and returns early (leaving it unchanged) when the indent is zero, so begin_line is a no-op either way. The #[cold]/#[inline(never)] cost on the first write of each line was also looked at and judged a one-test-per-write change, not a per-byte one.

Extended reasoning...

The change reworks indent emission in the Markdown ANSI renderer (src/md/ansi_renderer.rs) by deferring quote bars and list indent to the first inline write of a line, touching write_wrapped, write_styled, write_no_wrap, enter_span, the raw-HTML arm, write_indent and the two Kitty image emitters, with new tests in test/js/bun/md/md-edge-cases.test.ts and test/cli/run/markdown-entrypoint.test.ts. It touches no security-sensitive surface. Three inline findings were posted (indent-only lines from empty links, two hand-inlined copies of begin_line, and a colored trailing-reset case the new test snapshots), so the inline comments already signal that a human look is needed; this note only records the state-tracking and double-indent concerns that were checked and ruled out.

Comment thread src/md/ansi_renderer.rs Resolved
Comment thread src/md/ansi_renderer.rs Resolved
Comment thread src/md/ansi_renderer.rs Resolved
…ine() where it is spelled out

The two tests for an image that Kitty draws compare the text without the
escapes where the image ends the paragraph. The reset that ends a
paragraph comes behind the line break of the image, on main too, and the
tests no longer hold that order.

write_wrapped and write_styled spell the test of begin_line() out with
their emptiness test. Their comments now say so. The call behind a test
costs more instructions per render.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant