Repository navigation
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughKitty Graphics emission limited to local PNGs; non-PNG images fall back to alt-text/URL or OSC-8 hyperlinks. Added theme Changes
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/md/ansi_renderer.zig`:
- Around line 1905-1910: The kittyColumnBudget() helper currently returns 0 when
indent >= self.theme.columns which causes writeKittyApcHeader to omit the "c="
width cap; change kittyColumnBudget so that when self.theme.columns > 0 it
returns at least 1 (e.g., return 1 instead of 0 when indent >= columns) so the
"c=" parameter remains present and enforces a minimal cap even in
narrow/deeply-indented layouts; update the function kittyColumnBudget (and any
callers like writeKittyApcHeader if necessary) to rely on a minimum budget of 1
rather than 0.
In `@test/regression/issue/29118.test.ts`:
- Around line 2-12: The leading multi-line issue-summary banner in
test/regression/issue/29118.test.ts (the comment that starts with
"Bun.markdown.ansi() image-rendering improvements:") should be trimmed to a
single-line GitHub issue URL comment on the first line; remove the remaining
explanatory lines in that banner and keep only concise comments that document
test design rationale or specific fixtures/assertions as needed.
- Around line 194-199: The test "image without a src still works (doesn't crash,
doesn't print URL)" currently only rejects the string " ()" which misses other
empty-paren forms; update the assertion to check for bare parentheses by
replacing the negative assertion expect(out).not.toContain(" ()") with
expect(out).not.toContain("()") so the output variable out produced by
Bun.markdown.ansi("![alt]()\n", { colors: true, hyperlinks: false }) is verified
to never contain an empty-URL suffix; keep the existing assertions for "alt"
intact.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: f6e7db2f-0a77-425a-b244-1a56d8b1d6e7
📥 Commits
Reviewing files that changed from the base of the PR and between d81b0ca and 32f18abecb3d20f78b2b18d3e24f769fa33a453e.
📒 Files selected for processing (4)
packages/bun-types/bun.d.tssrc/bun.js/api/MarkdownObject.zigsrc/md/ansi_renderer.zigtest/regression/issue/29118.test.ts
There was a problem hiding this comment.
♻️ Duplicate comments (2)
test/regression/issue/29118.test.ts (2)
2-12:⚠️ Potential issue | 🟡 MinorTrim the top banner to the single issue URL comment.
Lines 2-12 are mostly issue/PR context prose; keep only Line 1 (
// https://github.com/oven-sh/bun/issues/29118) and retain inline comments only where they explain test-signal rationale.Based on learnings: in
test/regression/issue/, the standard is a single-line GitHub issue URL comment; avoid extra inline bug-context prose.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@test/regression/issue/29118.test.ts` around lines 2 - 12, The top comment block in test/regression/issue/29118.test.ts contains extra prose; trim it to the single-line GitHub issue URL comment (“// https://github.com/oven-sh/bun/issues/29118”) and remove the surrounding explanatory lines from the banner, preserving only any inline comments that are directly used as test-signal rationale; locate the header comment at the top of the file and reduce it accordingly.
199-199:⚠️ Potential issue | 🟡 MinorStrengthen the empty-
srcguard assertion.Line 199 only rejects
" ()". Reject bare()so variants likealt()are also caught.Proposed fix
- expect(out).not.toContain(" ()"); + expect(out).not.toContain("()");🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@test/regression/issue/29118.test.ts` at line 199, The test currently only rejects the string " ()" but should reject bare parentheses; update the assertion that checks the output variable out (the expect(out)... line in the test) to assert not.toContain("()") instead of not.toContain(" ()") so bare "()" occurrences (e.g., alt()) are caught as well.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@test/regression/issue/29118.test.ts`:
- Around line 2-12: The top comment block in test/regression/issue/29118.test.ts
contains extra prose; trim it to the single-line GitHub issue URL comment (“//
https://github.com/oven-sh/bun/issues/29118”) and remove the surrounding
explanatory lines from the banner, preserving only any inline comments that are
directly used as test-signal rationale; locate the header comment at the top of
the file and reduce it accordingly.
- Line 199: The test currently only rejects the string " ()" but should reject
bare parentheses; update the assertion that checks the output variable out (the
expect(out)... line in the test) to assert not.toContain("()") instead of
not.toContain(" ()") so bare "()" occurrences (e.g., alt()) are caught as well.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 1b38b72f-d69b-4dbf-8da1-d1b4d80eaf45
📥 Commits
Reviewing files that changed from the base of the PR and between 32f18abecb3d20f78b2b18d3e24f769fa33a453e and 002d1d7c5a160a2a13bca904f90d50478bab2f9e.
📒 Files selected for processing (1)
test/regression/issue/29118.test.ts
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/md/ansi_renderer.zig`:
- Around line 1909-1913: The kittyColumnBudget function currently bases the APC
width only on self.theme.columns minus currentIndent(), which ignores visible
text already on the current line and lets images overflow; update
kittyColumnBudget in AnsiRenderer to compute the remaining line width as
theme.columns minus the current column position (not just the indent) — e.g.,
subtract the current cursor/column (use an existing method like currentColumn()
or compute visibleTextWidthOnLine()) in addition to currentIndent(), then clamp
with `@max`(1, remaining) so the returned u32 is at least 1; keep the function
name kittyColumnBudget and the theme.columns usage but replace the single
subtract with a subtraction of the full consumed width.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 6b58422a-ce77-4835-838a-9738c3c71e4e
📥 Commits
Reviewing files that changed from the base of the PR and between 002d1d7c5a160a2a13bca904f90d50478bab2f9e and 3f77240551f65b4bd1c1c24f3be9a8ab34ee7d0a.
📒 Files selected for processing (2)
src/md/ansi_renderer.zigtest/regression/issue/29118.test.ts
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/md/ansi_renderer.zig`:
- Around line 1855-1857: The guard for data: URLs only checks lowercase "data:"
so uppercase/mixed-case URIs still pass; update the is_data_url logic (where
is_data_url is defined using bun.strings.startsWith(src.?, "data:")) to perform
a case-insensitive check by normalizing src (or comparing a lowercased prefix)
before startsWith, and apply the same change to the other instances referenced
around the other block (the section noted around lines 1882-1899) so any
variation like "DATA:" or "Data:" is treated as a data URL and suppressed
consistently.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 2fe1a45a-826d-4b77-8184-ebbab6d109f4
📥 Commits
Reviewing files that changed from the base of the PR and between 3f77240551f65b4bd1c1c24f3be9a8ab34ee7d0a and 0b327a3657453d1121f13732235eddaaa34b1319.
📒 Files selected for processing (2)
src/md/ansi_renderer.zigtest/regression/issue/29118.test.ts
f5d038c to
073ad9b
Compare
There was a problem hiding this comment.
LGTM — all bugs found in prior review rounds have been addressed.
Extended reasoning...
Overview
This PR adds three image-rendering improvements to Bun.markdown.ansi: URL display in the fallback path, PNG signature verification before Kitty transmission, and a column-width cap via c=<cols> in the APC header. It also threads a cwd option through the JS API. Changes touch src/md/ansi_renderer.zig, src/bun.js/api/MarkdownObject.zig, src/cli/run_command.zig, type definitions, and a new regression test file.
Security risks
No security concerns. The code reads local files (PNG signature check) and writes terminal escape sequences. The explicit guards against dumping data: URI payloads (case-insensitive per RFC 3986) prevent accidental megabyte base64 output. No user-controlled input reaches shell execution or SQL.
Level of scrutiny
This is rendering/display code, not production-critical. The main risk surface is subtle state-machine bugs in the ANSI renderer (block stack, last_was_newline, in_cell, heading_level) and edge cases in URI scheme matching. Multiple rounds of review found and fixed exactly these kinds of issues.
Other factors
After several rounds of review, all bugs I flagged were fixed: the in_cell and heading_level guards on the URL-parens fallback, kittyColumnBudget using self.col instead of currentIndent(), all data:/http:/file: scheme checks made case-insensitive, the isPngFile PATH_MAX guard corrected to >=, the empty-payload guard in extractPngDataUrlBase64, and the MIME type/encoding checks made case-insensitive. The only remaining issue from the bug hunter is pre-existing and does not block this PR. 294 lines of new regression tests cover the key paths.
bf5117b to
2835149
Compare
There was a problem hiding this comment.
No further issues found after 785e70a — the IHDR-gated c= emission resolves the upscaling regression. Deferring to a human for final sign-off since this adds a public API option (cwd on AnsiTheme) and a hardcoded heuristic (ASSUMED_CELL_PX = 16) that are worth a maintainer's eye.
Extended reasoning...
Overview
This PR touches the markdown→ANSI renderer (src/md/ansi_renderer.zig, ~200 lines net), the JS binding (src/runtime/api/MarkdownObject.zig), the CLI prefetch path (src/cli/run_command.zig), the public type surface (packages/bun-types/bun.d.ts), one snapshot, and a 334-line regression test. It introduces several new helpers (readPngDims, parsePngDims, parsePngDimsFromBase64, writeKittyApcHeader, kittyColumnBudget, endsWithCaseInsensitiveAscii) and a new cwd field on the public AnsiTheme interface.
Security risks
Low. The new file-read in readPngDims opens an already-resolved absolute path read-only, peeks 24 bytes, and closes — gated behind the existing resolveLocalImagePath stat check and the user opting into kittyGraphics: true. No shell execution, no network, no path-traversal surface beyond what already existed. The cwd option only feeds image_base_dir, which was already settable internally.
Level of scrutiny
Medium. This is not security- or correctness-critical infrastructure, but it:
- Adds a documented public API option (
cwd), which is a one-way door. - Encodes a heuristic constant (
ASSUMED_CELL_PX = 16) that determines when images get force-scaled vs. rendered natively — a UX judgment call a maintainer should ratify. - Went through ~10 rounds of review-and-fix on this branch (table/heading guards, case-insensitive scheme matching, PATH_MAX off-by-one, empty-payload data URIs, and the Kitty
c=exact-vs-max semantics), which signals the change was non-trivial to get right.
Other factors
All prior inline comments (mine and CodeRabbit's) are resolved, the latest bug-hunt pass on 785e70a is clean, and test coverage for the new behavior is thorough (20 targeted assertions including edge cases for tables, headings, inline-prefix images, empty payloads, and case-insensitive MIME). The remaining reason not to auto-approve is scope, not correctness.
There was a problem hiding this comment.
Thanks for the 785e70a fix — the IHDR-gated c= emission looks right. No further issues from me, but deferring to a human for the new cwd API option and the ASSUMED_CELL_PX = 16 heuristic, since both are judgment calls worth a second pair of eyes.
Extended reasoning...
Overview
Touches src/md/ansi_renderer.zig (~200 new lines: PNG signature + IHDR parsing, partial base64 decode, conditional Kitty c= emission, URL-parens fallback with in_cell/heading/link_depth/data-URI guards), src/runtime/api/MarkdownObject.zig (new cwd theme option), src/cli/run_command.zig (case-insensitive http(s) scheme), bun.d.ts, a snapshot update, and a 334-line regression test.
Security risks
None identified. readPngDims opens user-supplied paths read-only, but only behind an explicit kittyGraphics: true opt-in and after the pre-existing resolveLocalImagePath stat() gate. No injection, auth, or data-exposure surface.
Level of scrutiny
Medium. This is a rendering/UX feature, not a hot path or security boundary, but it went through ~10 review iterations with real bugs caught each round (table-cell guard, heading guard, self.col vs indent, four separate case-insensitivity gaps, MAX_PATH_BYTES off-by-one, empty-payload APC, and the c= upscaling regression). The most recent fix (785e70a) added a meaningful chunk of new logic — parsePngDims, readPngDims, parsePngDimsFromBase64, and the ASSUMED_CELL_PX threshold — in response to my last comment, and that code hasn't had independent review yet.
Other factors
Two design decisions warrant human sign-off: (1) the new public cwd?: string field on AnsiTheme, and (2) ASSUMED_CELL_PX = 16 — the comment notes real cells are 6–12 px, so images in the budget*6 .. budget*16 px range will still render at native size and may overflow on narrow-cell fonts; that's a deliberate under-cap tradeoff someone should ack. CI is green except for an unrelated Windows http-close flake. All prior reviewer comments (mine and CodeRabbit's) are resolved.
c17e715 to
98d246d
Compare
There was a problem hiding this comment.
No new issues found and all prior review feedback has been addressed — deferring to a human for final sign-off on the new cwd API option and the ASSUMED_CELL_PX scaling heuristic.
Extended reasoning...
Overview
This PR touches six files: ~250 lines of logic in src/md/ansi_renderer.zig (PNG IHDR parsing, Kitty APC header emission with conditional c= scaling, URL-parens fallback for images, case-insensitive URI scheme handling), ~50 lines in src/runtime/api/MarkdownObject.zig (new cwd theme option with relative-path absolutization), a small case-insensitivity fix in src/runtime/cli/run_command.zig, the AnsiTheme type definition in bun.d.ts, one snapshot update, and a 360-line regression test suite.
Security risks
None identified. The new file I/O (readPngDims) opens files read-only with a length-guarded stack buffer and only reads 24 bytes; the path comes from markdown the caller already chose to render. The cwd option is absolutized before use and only feeds into stat()/open() for read — no write, exec, or traversal beyond what the existing resolveLocalImagePath already did with process cwd.
Level of scrutiny
Medium. This is not a simple/mechanical change: it adds a new public API field (AnsiTheme.cwd), introduces binary parsing of PNG headers and base64 prefixes, and encodes a heuristic (ASSUMED_CELL_PX = 16) that governs when Kitty images get downscaled vs. rendered native-size. The fallback-rendering guards (in_cell, heading_level, link_depth, is_data_url) interact with several layout subsystems.
Other factors
The PR has been through ~10 rounds of automated review; every flagged issue (in_cell/heading guards, self.col vs indent, case-insensitive scheme/MIME, empty-payload data URI, MAX_PATH_BYTES off-by-one, Kitty c= upscaling semantics, relative-cwd absolutization) was fixed and has a corresponding regression test. The current bug-hunting pass found nothing. CI has been retriggered for unrelated infra flakes only. Given the new public API surface and the scaling heuristic being a judgment call, a human should give the final approval rather than auto-merging.
|
Diff is green — the only red on the latest run (#54503) is two SIGKILLs on the same Ubuntu 25.04 x64 runner ( Ready for a maintainer to merge or kick CI once the infra settles. |
fc874f7 to
6f963c8
Compare
|
CI build #56618 has one Diff-relevant lanes are green. Ready for a maintainer to merge or kick the one Darwin 14 aarch64 lane. |
8e0d295 to
9cb2eb7
Compare
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/md/ansi_renderer.rs:2930-2932— The explicitlet _ = file.close();and its comment are redundant/misleading:bun_sys::Filealready closes onDrop(src/sys/file.rs:17-24, documented in src/CLAUDE.md), so the fd is closed on every path out regardless.File::close(self)'s only documented benefit over Drop is that "the syscall result is observable" — butlet _ =discards that. Just delete both lines and let RAII do it (matching src/CLAUDE.md's own idiom// file closes on Drop.); at minimum drop the comment, since "we must be sure it runs on every path out" wrongly implies Drop doesn't cover it.Extended reasoning...
What the issue is
read_png_dims(src/md/ansi_renderer.rs:2930-2932) contains:let read_result = file.read_all(&mut buf); // Always close — File::close consumes self, so we must be sure it runs // on every path out. let _ = file.close(); match read_result { bun_sys::Result::Ok(amt) if amt >= 24 => parse_png_dims(&buf), _ => None, }
The comment claims the explicit
.close()is needed to ensure the fd is released "on every path out." That is factually wrong forbun_sys::File, and the explicit call gains nothing because its return is discarded.Why the comment is wrong
bun_sys::Fileowns its fd and closes it inDrop:// src/sys/file.rs:17-24 impl Drop for File { #[inline] fn drop(&mut self) { if self.handle != Fd::INVALID && !self.handle.is_stdio() { let _ = close(self.handle); } } }
This is documented at
src/CLAUDE.md—bun_sys::File (owns the fd; closes on Drop)— and the crate's own canonical usage example there ends with the comment// file closes on Drop.with no explicit close call. The project convention (CLAUDE.md rule 8) is explicit: "Prefer RAII (Drop) over manual cleanup."Why the explicit
.close()gains nothing hereFile::close(self)(src/sys/file.rs:313-321) is documented as "Close now. Equivalent to droppingselfbut the syscall result is observable." Its sole distinguishing feature is that it returns theclose(2)result — but this call site immediately discards it withlet _ =. So the ONLY reason the API exists to prefer.close()over Drop is thrown away. Thematchon line 2933 is the last statement in the function, sofilewould drop at exactly the same point in execution anyway; there is no timing benefit either. And there is no double-close risk (.close()consumesselfviainto_raw(), disarming Drop), so the call is harmless — it is just dead weight.Why the comment is the actionable problem
REVIEW.md: "Only comment what the code cannot say. One line. Never restate what the code does." This comment does worse than restate — it misstates. A reader coming to this file learns "
bun_sys::Filedoesn't close on Drop, you must manually ensure.close()on every path" — the opposite of the truth. That is exactly the kind of misdirection REVIEW.md's "verify semantics empirically, never from names or intuition" rule tries to prevent readers from having to do. Existing callers elsewhere in the tree (e.g.src/install/migration.rs:44carries// file closes on Drop) rely on the RAII behavior, so this comment also disagrees with established convention.Step-by-step proof the fd closes without the explicit call
Delete lines 2930-2932. Trace both paths out of the
match:bun_sys::Result::Ok(amt) if amt >= 24→parse_png_dims(&buf)returns;file(a local owned by this stack frame) goes out of scope;Drop::dropruns;self.handle != Fd::INVALID(open just succeeded) and!is_stdio()(a regular file we opened) →close(self.handle)fires._ => None→ identical:filegoes out of scope at the closing brace; same Drop path.- Early returns before
fileexists (lines 2917, 2923) never opened an fd.
There is no path on which the fd survives function return.
Impact
None functionally — no fd leak, no double-close, no UB. This is purely comment-accuracy + a redundant call on new code introduced by this PR, hence nit.
Fix
Delete both lines (comment +
let _ = file.close();) and letfiledrop at end-of-scope — matchingsrc/CLAUDE.md's own example. If keeping the explicit close is preferred for symmetry with some other site, at minimum delete the two-line comment: it is what actively misleads. -
🟡
src/md/ansi_renderer.rs:2115-2142—format_c_hinthand-rolls a 25-lineBufWriterstruct +core::fmt::Writeimpl to format oneu32into a stack buffer, butbun_core::fmt::itoa(&mut ItoaBuf, n) -> &[u8]already does exactly this and is what the sibling renderer uses (src/md/html_renderer.rs:597,src/md/helpers.rs:476). The whole helper collapses toself.write_raw_no_color(b",c="); self.write_raw_no_color(bun_core::fmt::itoa(&mut bun_core::fmt::ItoaBuf::new(), budget));inlined at the single call site inwrite_kitty_apc_header, which also drops thelet _ = write!(...)discarded-result.Extended reasoning...
What the issue is
format_c_hint(src/md/ansi_renderer.rs:2118-2143) defines an inlineBufWriter<'a>struct with acore::fmt::Writeimpl, constructs it over a caller-provided[u8; 16], callswrite!(w, ",c={}", budget)with the result discarded vialet _ =, and returns the written slice. This is 25 lines of bespoke machinery whose only job is to format a singleu32into a small stack buffer.The codebase already has a helper for exactly this:
bun_core::fmt::itoa(&mut ItoaBuf, n) -> &[u8](src/bun_core/fmt.rs:3203, withItoaBufre-exported at :3198). It's the tree-wide convention for base-10 integer formatting into a stack buffer, used at 15+ call sites — including two in the same crate:src/md/html_renderer.rs:597(self.write(bun_core::fmt::itoa(&mut buf, value))) andsrc/md/helpers.rs:476. So the sibling HTML renderer already does this the canonical way;format_c_hintis the odd one out.Why REVIEW.md flags this
REVIEW.md's Code style & idioms section is explicit: "In runtime native code, grep for the in-tree helper before hand-writing anything. File I/O, paths, strings, hashing, formatting … Being the only file touching a raw primitive is a red flag." Grep confirms this is the only place in the tree hand-rolling a
core::fmt::Writeimpl to format an integer into a stack buffer.src/CLAUDE.mdalso listsbun_core::fmtalongsidebun_core::stringsas the canonical toolkit. Additionally,bun_alloc::SliceCursoralready provides acore::fmt::Writeimpl over&mut [u8], so even thewrite!-based approach wouldn't need a hand-rolled struct.Why this is a nit, not normal
The code is functionally correct:
u32::MAXis 10 digits, plus",c="= 13 bytes < 16, so the buffer never overflows and the discardedwrite!result is alwaysOk. There is no user-visible behavior difference. This is purely a convention/code-quality finding on new code introduced by this PR — merging as-is causes no failure, so per the severity rubric it does not justify blocking merge.How to fix
Delete
format_c_hintentirely and inline at the single call site inwrite_kitty_apc_header(line 2107-2111):if budget > 0 && width_px > budget.saturating_mul(ASSUMED_CELL_PX) { self.write_raw_no_color(b",c="); let mut buf = bun_core::fmt::ItoaBuf::new(); self.write_raw_no_color(bun_core::fmt::itoa(&mut buf, budget)); }
This drops ~25 lines, removes the discarded-result pattern, and matches the sibling renderer at html_renderer.rs:595-598 exactly.
Step-by-step proof
Input:
[](./wide.png)with{ kittyGraphics: true, columns: 40 }and a 2000px-wide PNG.write_kitty_apc_header(b"t=f", 2000)is called;budget = 40,2000 > 40*16 = 640, so thec=branch runs.- Current code: allocates
[0u8; 16]on the stack, constructsBufWriter { buf, pos: 0 }, dispatches through thecore::fmt::Writevtable viawrite!(w, ",c={}", 40)→w.write_str(",c=")copies 3 bytes, thencore::fmt's integer formatter callsw.write_str("40")copies 2 bytes;pos = 5; returns&buf[..5]=b",c=40"; caller writes it viawrite_raw_no_color. - Proposed code:
write_raw_no_color(b",c=")writes 3 bytes;ItoaBuf::new()is a const uninit 40-byte array;itoa(&mut buf, 40u32)returnsb"40";write_raw_no_colorwrites 2 bytes. - Output is byte-identical:
\x1b_Ga=T,t=f,f=100,q=2,c=40;. The test attest/regression/issue/29118.test.ts:159(expect(out).toContain("\x1b_Ga=T,t=f,f=100,q=2,c=40;")) and thec=30inline-prefix test at :291 continue to pass unchanged.
format_c_hint hand-rolled a core::fmt::Write buffer when bun_core::fmt::itoa is the in-tree helper (html_renderer already uses it). bun_sys::File closes on Drop, so the explicit close discarding its result added nothing.
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/md/ansi_renderer.rs:2073-2097—format_c_hinthand-rolls a 26-linecore::fmt::Writeimpl over a stack buffer to format,c=<u32>— but this file already usesbun_core::fmt::buf_print(line 380) for exactly this, and sibling md files usebun_core::fmt::itoa. The whole helper collapses tobun_core::fmt::buf_print(buf, format_args!(",c={budget}"))or twowrite_raw_no_colorcalls (b",c="+itoa) inline inwrite_kitty_apc_header.Extended reasoning...
What the issue is
The new
format_c_hinthelper (src/md/ansi_renderer.rs:2073-2097) defines a bespoke localBufWriterstruct with a hand-writtencore::fmt::Writeimpl, then callswrite!(w, ",c={}", budget)— 26 lines total — to format oneu32into a 16-byte stack buffer:fn format_c_hint(buf: &mut [u8; 16], budget: u32) -> &[u8] { use core::fmt::Write as _; struct BufWriter<'a> { buf: &'a mut [u8], pos: usize } impl<'a> core::fmt::Write for BufWriter<'a> { fn write_str(&mut self, s: &str) -> core::fmt::Result { ... } } let mut w = BufWriter { buf: &mut buf[..], pos: 0 }; let _ = write!(w, ",c={}", budget); &buf[..w.pos] }
Why this diverges from convention
REVIEW.md is explicit: "grep for the in-tree helper before hand-writing anything … bun_core strings/fmt/Output … Being the only file touching a raw primitive is a red flag" and "Match the exact file's local conventions." Both apply here:
- This same file already uses the in-tree helper.
ansi_renderer.rs:380doesbun_core::fmt::buf_print(&mut num_buf, format_args!("{num}. "))for the ordered-list marker — aSliceCursor-basedcore::fmt::Writeover a caller stack buffer, i.e. precisely whatBufWriterreinvents. - Sibling files in the same crate use
itoafor u32→ASCII.src/md/html_renderer.rs:596-597haslet mut buf = bun_core::fmt::ItoaBuf::new(); self.write(bun_core::fmt::itoa(&mut buf, value));andsrc/md/helpers.rs:475-476does the same.bun_core::fmt::itoa(fmt.rs:3203) is the documentedcore::fmt-free integer-to-ASCII path.
So
format_c_hintis new code that hand-rolls a helper the file itself already imports and uses ~1700 lines above, and that the crate uses in two sibling files.How to fix
Either delete
format_c_hintentirely and inline intowrite_kitty_apc_header:if budget > 0 && width_px > budget.saturating_mul(ASSUMED_CELL_PX) { self.write_raw_no_color(b",c="); let mut buf = bun_core::fmt::ItoaBuf::new(); self.write_raw_no_color(bun_core::fmt::itoa(&mut buf, budget)); }
…or, if you want to keep the single-write shape, replace the body with the same helper line 380 uses:
let s = bun_core::fmt::buf_print(&mut buf[..], format_args!(",c={budget}")).unwrap_or(b""); self.write_raw_no_color(s);
Both drop the bespoke
BufWriterstruct +core::fmt::Writeimpl and align with the file-family convention.Impact / why this is a nit
There is no functional bug. The hand-rolled version is correct: 16 bytes comfortably fits
",c="(3) +u32::MAX(10 digits) = 13 bytes max, so theErr(core::fmt::Error)branch is unreachable and the discardedwrite!result is fine. Output is byte-identical to whatbuf_print/itoawould produce. This is purely a code-quality / convention finding on new code introduced by the PR — nice to fix but not merge-blocking.Step-by-step proof
Take
budget = 40:- Current:
BufWriter::write_strreceives",c="then"40", copies tobuf[0..3]andbuf[3..5], returns&buf[..5]=b",c=40". bun_core::fmt::buf_print(&mut buf, format_args!(",c={}", 40)): internalSliceCursorreceives the same two fragments, writes tobuf[0..5], returnsOk(&buf[..5])=b",c=40".itoavariant: writesb",c="thenitoa(&mut ItoaBuf::new(), 40)=b"40"— same bytes on the wire.
All three produce identical APC output; the only difference is 26 lines of bespoke type vs. one line calling the documented in-tree helper the file already uses.
- This same file already uses the in-tree helper.
- Move the issue-29118 tests to test/js/bun/md/md-image.test.ts: the fixes are first-time behavior and cwd is a new option, so the regression directory rule does not apply. - Reword the stale threadlocal-buffer comment in resolve_local_image_path (the checked join writes a stack buffer). - Use bare Ok/Err arms and the strings:: alias to match file conventions.
PathBuffer is ~96 KB on Windows, and the new cwd_abs_buf/proc_cwd_buf pair plus the join and IHDR-read buffers were unconditional stack reservations in a hot host-fn frame. Swap the four new buffers to bun_paths::path_buffer_pool per src/CLAUDE.md.
There was a problem hiding this comment.
I reviewed this revision and the automated bug-hunt found no issues — all prior nits (path-buffer pool, bare Ok/Err arms, strings:: alias, test-file placement, stderr assertions, and the pooled-scratch-buffer comment wording) are addressed. Because the PR adds new public API surface (cwd on AnsiTheme) and ~250 lines of renderer/file-I/O logic (PNG IHDR parsing, the ASSUMED_CELL_PX=16 scaling heuristic, path-buffer join guards), a maintainer sign-off on those design choices would still be worthwhile.
What was reviewed:
- URL-parens fallback: checked the
in_cell/heading_level/link/is_data_urlguards match the sibling link-fallback and Kitty-gate conditions; snapshot update is consistent. read_png_dims/parse_png_dims_from_base64: bounds are checked before slicing;bun_sys::Filecloses on Drop; over-long paths returnNonebeforeresolve_path::z.cwdhandling inMarkdownObject.rs:to_sliceresult is copied into an ownedVec/pooled buffer before render, so no borrow outlives the JS string; over-long input degrades to the process-cwd fallback via_checkedjoin.- Case-insensitive scheme/MIME checks applied consistently across
emitImage,resolve_local_image_path,extract_png_data_url_base64, and the CLI prefetch filter.
Extended reasoning...
Overview
This PR touches src/md/ansi_renderer.rs (~200 LOC: URL-parens fallback for images, PNG signature/IHDR parsing, Kitty c= width capping, case-insensitive scheme checks, checked path-buffer joins), src/runtime/api/MarkdownObject.rs (~50 LOC: new cwd option with absolute/relative resolution and overflow guards), a 3-line case-insensitivity fix in src/runtime/cli/run_command.rs, the cwd/PNG-only doc update in bun.d.ts, a new 421-line test/js/bun/md/md-image.test.ts, two assertion updates in md-edge-cases.test.ts, and one snapshot update.
Security risks
Low. The renderer already resolved and stat'd local image paths pre-PR; this change adds a 24-byte read of the resolved file (PNG header sniff) via bun_sys::File::open(O_RDONLY), gated behind the existing kittyGraphics: true opt-in. No new network I/O. The URL-parens fallback writes document-sourced bytes through write_styled, which goes through the same control-byte sanitization path already covered by the "link and metadata control bytes" tests in md-edge-cases.test.ts. Path handling uses the checked join variant and length-guards the fixed-size PathBuffer before resolve_path::z, with subprocess crash-guard tests for over-long cwd/src.
Level of scrutiny
Medium-high. This is not a mechanical change: it introduces a new public API option (cwd), a heuristic constant (ASSUMED_CELL_PX = 16), and product-visible fallback formatting decisions (skip URL in tables/headings, PNG-only Kitty). Those are design calls a maintainer should confirm, which is why I'm deferring rather than approving.
Other factors
The PR has been through many review rounds since April; every substantive finding (missing in_cell guard, path-buffer overflow, stack PathBuffer on Windows) and every recent nit has a corresponding fix commit, and the current diff reflects all of them — including my most recent note (b2fd2e7 rewords the join comment to "pooled scratch buffer"). The two open github-actions comment-cop flags are the automated heuristic firing on a 3-line factual comment, not a workaround justification. Test coverage is thorough (20 targeted tests plus the 3 subprocess crash guards, all following harness conventions), and the existing markdown-entrypoint snapshot was updated to match the new fallback.
|
Current state on build #97856 (tip b2fd2e7): every test lane passed except Review is fully converged: the latest automated review pass found no issues and all review threads are resolved. Ready for a maintainer to merge or re-kick the one Darwin lane. |
|
Closing this PR because it has been inactive for more than 90 days. |
Summary
Closes #29118. Three fallback fixes for how
Bun.markdown.ansi()handles images.1. Show the image URL in the fallback path
Before this change,
rendered as just📷 altwith no way to reach the source when OSC 8 hyperlinks were off. Now the URL is shown in dim parens after the alt text, matching the link-fallback style already used by the anchor span:Skipped for
data:URIs (megabyte base64 payloads would dominate the output), when the image is inside an enclosing link span (the outer link already shows its own URL), and inside table cells / headings (their width machinery would count the URL against cell / underline size).2. Verify the PNG signature before handing a file to Kitty
emit_imagewas happily sending any regular file on disk through Kitty'st=f,f=100(PNG) transmission. For a.jpg/.gif/.webpthe terminal would decode the bytes as PNG, fail, and render the broken-image indicator with no caption. Fix: read the PNG signature + IHDR before callingemit_kitty_image_file. Non-PNGs fall through to the URL-label fallback from (1).3. Scale Kitty image width only when it would overflow
The APC sequence now includes
c=<cols>when (and only when) the image's pixel width exceeds what the remaining line can hold at native size. Per the Kitty Graphics Protocol spec,c=<cols>is the exact number of cells to display over — Kitty enlarges images smaller thanccells just as readily as it shrinks larger ones. So emittingc=unconditionally would upscale small icons (favicons, badges) to fill the column budget, a regression vs. pre-PR native-size rendering. The fix is to parse the PNG IHDR and comparewidth_pxagainstbudget * ASSUMED_CELL_PX(16, a conservative upper bound on cell width) — only emitc=<budget>when the image definitely would overflow.c=is also omitted whencolumns: 0so wrapping-disabled callers always get native-size rendering.For the
t=ddata-URL path the first 24 bytes of raw PNG are decoded out of the base64 payload (~32 base64 chars) to get the IHDR without scanning the whole payload.API addition
Bun.markdown.ansi(text, theme)now accepts acwdoption to set the base directory used to resolve relative imagesrcpaths. Defaults to the process cwd (unchanged behaviour). Added toAnsiThemeinbun.d.ts.Verification
test/regression/issue/29118.test.ts(20 tests) covers: URL in parens, no OSC 8 withouthyperlinks:true,c=only for wide images, PNG signature check rejecting JPEG, nested link handling, data URI skip (case-insensitive scheme + MIME), colors-off fallback,data:…;base64,empty payload, inline-image remaining-line budget, table cell / heading guards.test/cli/run/markdown-entrypoint.test.ts(29 tests) passes with the updated snapshot.Implementation note
The fix lives in the Rust renderer (
src/md/ansi_renderer.rs,src/runtime/api/MarkdownObject.rs,src/runtime/cli/run_command.rs). An earlier revision of this branch also edited the parallel.zigporting-reference sources; those files were removed from the tree in #32621, so this branch was rebased and squashed to a single commit dropping the now-deleted.zigedits.