Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8478aa6
util.inspect, Console#table: read Map and Set iterators from storage
robobun Sep 11, 2026
204b594
Console#table: do not bound a Map or Set by its size
robobun Sep 12, 2026
2c8f81e
Shorten the comments on previewEntries
robobun Sep 12, 2026
29be5f2
ci: retrigger
robobun Sep 12, 2026
495e5be
test: run the Console#table iterator tests concurrently
robobun Sep 12, 2026
efbaf29
Merge branch 'main' into robobun/c4e079fe/preview-entries-native
robobun Oct 29, 2026
6531d5c
previewEntries: step a private copy of the iterator with the engine's…
robobun Oct 29, 2026
ce44fdd
console.log, Bun.inspect: print a Map or Set iterator without running it
robobun Oct 29, 2026
bd3ca42
console.table, the error printer: read a Map or Set iterator they are…
robobun Oct 29, 2026
2b74f8d
test: a print does not run or use up a Map or Set iterator
robobun Oct 29, 2026
5aee966
Shorten the comments on the native arm
robobun Oct 29, 2026
0f971e7
Merge remote-tracking branch 'origin/main' into robobun/c4e079fe/prev…
robobun Oct 29, 2026
7f3e989
Declare jsFunctionPreviewEntries in the table of built-ins
robobun Oct 29, 2026
a7ff980
Close a Set iterator that is printed on one line with a space
robobun Oct 29, 2026
16482df
Console#table: take isMapIterator and isSetIterator from native code
robobun Oct 29, 2026
6fd8950
test: run the replaced-guard case of Console#table through the shared…
robobun Oct 29, 2026
304b381
previewEntries: reserve for what the iterator has left, not for the c…
robobun Oct 29, 2026
15d410f
Lint: a printer walks a value through formatter::for_each_listed
robobun Oct 29, 2026
ec4b93a
test: more places where native code formats a Map or Set iterator
robobun Oct 29, 2026
0334d09
Lint: tell JSValue::for_each from Iterator::for_each by its arguments
robobun Oct 29, 2026
8548f2c
test: assert the failure text, the entry order and the iterator position
robobun Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/codegen/builtins/host/features-node.builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ export default b.declarations(
...nativeBindings("NewCppFunction", "JSEventTargetNode.cpp", "jsFunctionNodeEventsGetEventListeners"),
...nativeBindings("NewCppFunction", "NodeUtilTypesModule.cpp", "jsFunctionIsError"),
...nativeBindings("NewCppFunction", "UtilInspect.cpp", "jsFunctionGetOwnNonIndexProperties"),
...nativeBindings("NewCppFunction", "UtilInspect.cpp", "jsFunctionPreviewEntries"),
),

extendAll(["NodeSmallModules"], "node:util/types", "node:constants"),
Expand Down
21 changes: 21 additions & 0 deletions src/codegen/builtins/host/native-functions.builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,27 @@ export default b.declarations(
uses: { object: [b.runs("OwnKeys"), b.readsContents()], filter: converted },
implementation: b.hostCall,
}),
b.tuple("IteratorPreview", [newArrayOf(b.any).dense, b.bool, b.u32]),
// It steps a copy of the Map or Set iterator, so the iterator stays where it is and no code of the program runs. It answers what the collection has left, whether that comes in pairs, and how many entries are left. Anything else is refused with a TypeError.
b.static(cpp("UtilInspect.cpp", "jsFunctionPreviewEntries"), [
{
args: { iterator: b.union(b.instance("MapIterator"), b.instance("SetIterator")), limit: b.any.optional },
returns: b.layoutOf("IteratorPreview").fresh,
capability: "heap",
throws: true,
uses: { iterator: [b.readsContents(), b.reachableValuesStoredIn(b.result)], limit: b.inspected },
reads: ["CollectionStorage", "CollectionEntries"],
implementation: b.hostCall,
},
{
args: { iterator: b.any.optional, limit: b.any.optional },
returns: b.never,
capability: "heap",
throws: true,
uses: { iterator: b.inspected, limit: b.inspected },
implementation: b.hostCall,
},
]),
// It lists the own keys of the holder and asks first `except` and then the holder for the property under each: no getter of either runs. Of each function that the holder has for a value it reads `length`, and those without parameters it calls on the receiver. Of an object that such a call answers it asks whether it has @@iterator.
b.static(cpp("BunCompiledModules.cpp", "jsFunctionCallOwnFunctionsWithoutParameters"), {
args: { holder: b.object, except: b.object, receiver: b.any.optional },
Expand Down
22 changes: 20 additions & 2 deletions src/js/builtins/ConsoleObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,6 @@ export function write(this: Console & { $writer: $Node.FileSink | undefined }, i
export function createConsoleConstructor(console: Console) {
const { inspect, formatWithOptions } = require("node:util");
require("node:buffer");
const { isMapIterator, isSetIterator } = require("node:util/types");

const { validateObject, validateInteger, validateArray, validateOneOf } = require("internal/validators");
const kMaxGroupIndentation = 1000;
Expand All @@ -170,6 +169,20 @@ export function createConsoleConstructor(console: Console) {
"jsFunctionBunStringWidth",
1,
);
// For a Map or Set iterator, without running or advancing it.
const previewEntries: (iterator: object) => [entries: unknown[], isKeyValue: boolean, length: number] =
$newCppFunction("UtilInspect.cpp", "jsFunctionPreviewEntries", 2);
// previewEntries() throws on anything but what these two accept, so a script must not be able to replace them.
const isMapIterator: (value: unknown) => boolean = $newCppFunction(
"NodeUtilTypesModule.cpp",
"jsFunctionIsMapIterator",
1,
);
const isSetIterator: (value: unknown) => boolean = $newCppFunction(
"NodeUtilTypesModule.cpp",
"jsFunctionIsSetIterator",
1,
);

/**
* Returns the number of columns required to display the given string.
Expand Down Expand Up @@ -748,6 +761,11 @@ export function createConsoleConstructor(console: Console) {
const mapIter = isMapIterator(tabularData);
let isKeyValue = false;
let i = 0;
if (mapIter) {
const res = previewEntries(tabularData);
Comment thread
robobun marked this conversation as resolved.
Resolved
tabularData = res[0];
isKeyValue = res[1];
}

if (isKeyValue || $isMap(tabularData)) {
const keys: string[] = [];
Expand All @@ -770,7 +788,7 @@ export function createConsoleConstructor(console: Console) {
}

const setIter = isSetIterator(tabularData);
// if (setIter) tabularData = previewEntries(tabularData);
if (setIter) tabularData = previewEntries(tabularData)[0];

const setlike = setIter || mapIter || $isSet(tabularData);
if (setlike) {
Expand Down
70 changes: 32 additions & 38 deletions src/js/internal/util/inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,7 @@ const {
TypedArrayPrototypeGetSymbolToStringTag,
} = primordials;

const ArrayFrom = Array.from;
const ArrayPrototypeFilter = Array.prototype.filter;
const ArrayPrototypeFlat = Array.prototype.flat;
const ArrayPrototypeForEach = Array.prototype.forEach;
const ArrayPrototypeIncludes = Array.prototype.includes;
const ArrayPrototypeIndexOf: (this: ArrayLike<unknown>, searchElement?: unknown, fromIndex?: number) => number =
Expand All @@ -63,8 +61,6 @@ const ErrorPrototypeToString = Error.prototype.toString;
const FunctionPrototypeToString = Function.prototype.toString;
const JSONStringify = JSON.stringify;
const MapPrototypeEntries = Map.prototype.entries;
const MapPrototypeValues = Map.prototype.values;
const MapPrototypeKeys = Map.prototype.keys;
const MathFloor = Math.floor;
const MathMax = Math.max;
const MathRound = Math.round;
Expand Down Expand Up @@ -98,7 +94,6 @@ const RegExpPrototypeSymbolReplace = RegExp.prototype[Symbol.replace] as (
) => string;
const RegExpPrototypeSymbolSplit = RegExp.prototype[Symbol.split];
const RegExpPrototypeToString = RegExp.prototype.toString;
const SetPrototypeEntries = Set.prototype.entries;
const SetPrototypeValues = Set.prototype.values;
const StringPrototypeCharCodeAt = String.prototype.charCodeAt as (this: string, index?: number) => number;
const StringPrototypeIncludes = String.prototype.includes;
Expand Down Expand Up @@ -171,7 +166,7 @@ const {
isTypedArray,
} = require("node:util/types");

// previewEntries() reads internal fields of whatever these two accept, so a script must not be able to replace them.
// previewEntries() throws on anything but what these two accept, so a script must not be able to replace them.
const isMapIterator = $newCppFunction("NodeUtilTypesModule.cpp", "jsFunctionIsMapIterator", 1);
const isSetIterator = $newCppFunction("NodeUtilTypesModule.cpp", "jsFunctionIsSetIterator", 1);

Expand Down Expand Up @@ -2372,9 +2367,16 @@ function formatMap(value, ctx: Context, ignored: object, recurseTimes: number) {
return output;
}

function formatSetIterInner(ctx: Context, recurseTimes: number, entries: unknown[], state: number) {
// `length` counts every entry there is. `entries` can stop at maxArrayLength of them.
function formatSetIterInner(
ctx: Context,
recurseTimes: number,
entries: unknown[],
state: number,
length = entries.length,
) {
const maxArrayLength = MathMax(ctx.maxArrayLength, 0);
const maxLength = $min(maxArrayLength, entries.length);
const maxLength = $min(maxArrayLength, length);
const output = new Array<string>(maxLength);
ctx.indentationLvl += 2;
for (let i = 0; i < maxLength; i++) {
Expand All @@ -2387,17 +2389,22 @@ function formatSetIterInner(ctx: Context, recurseTimes: number, entries: unknown
// output is not sorted anyway.
ArrayPrototypeSort.$call(output);
}
const remaining = entries.length - maxLength;
const remaining = length - maxLength;
if (remaining > 0) {
ArrayPrototypePush.$call(output, remainingText(remaining));
}
return output;
}

function formatMapIterInner(ctx: Context, recurseTimes: number, entries: unknown[], state: number) {
// Entries exist as [key1, val1, key2, val2, ...]. `len` counts every pair there is.
function formatMapIterInner(
ctx: Context,
recurseTimes: number,
entries: unknown[],
state: number,
len = entries.length / 2,
) {
const maxArrayLength = MathMax(ctx.maxArrayLength, 0);
// Entries exist as [key1, val1, key2, val2, ...]
const len = entries.length / 2;
const remaining = len - maxArrayLength;
const maxLength = $min(maxArrayLength, len);
const output = new Array<string>(maxLength);
Expand Down Expand Up @@ -2445,15 +2452,15 @@ function formatWeakMap(ctx: Context, value: object, recurseTimes: number) {
}

function formatIterator(braces: Braces, ctx: Context, value: object, recurseTimes: number) {
const { 0: entries, 1: isKeyValue } = previewEntries(value, true);
const { 0: entries, 1: isKeyValue, 2: length } = previewEntries(value, true, MathMax(ctx.maxArrayLength, 0));
if (isKeyValue) {
// TODO(bun): JSC can also differ between the keys and values iterator, maybe we should also distinguish those in the future?
// Mark entry iterators as such.
braces[0] = RegExpPrototypeSymbolReplace.$call(/ Iterator] {$/, braces[0], " Entries] {");
return formatMapIterInner(ctx, recurseTimes, entries, kMapEntries);
return formatMapIterInner(ctx, recurseTimes, entries, kMapEntries, length);
}

return formatSetIterInner(ctx, recurseTimes, entries, kIterator);
return formatSetIterInner(ctx, recurseTimes, entries, kIterator, length);
}

function formatPromise(ctx: Context, value, recurseTimes: number) {
Expand Down Expand Up @@ -2926,30 +2933,17 @@ function getProxyDetails(proxy: unknown, withHandler = true) {
if (withHandler) return [target, handler];
else return target;
}
type IteratorPreview = [entries: unknown[], isKeyValue: boolean, length: number];
// (iterator, limit) => [flat entries (at most `limit`), isKeyValue, entries left]
const previewIteratorEntries: (iterator: object, limit?: number) => IteratorPreview = $newCppFunction(
"UtilInspect.cpp",
"jsFunctionPreviewEntries",
2,
);
function previewEntries(val: object): unknown[];
function previewEntries(val: object, isIterator: true): [entries: unknown[], isKeyValue: boolean];
function previewEntries(val, isIterator = false) {
if (isIterator) {
// the Map or Set instance this iterator belongs to
const iteratedObject = $getInternalField(val, 1 /*iteratorFieldIteratedObject*/);
// for Maps: 0 = keys, 1 = values, 2 = entries
// for Sets: 1 = keys|values, 2 = entries
const kind = $getInternalField(val, 3 /*iteratorFieldKind*/);
const isEntries = kind === 2;
// TODO(bun): improve performance by not using Array.from and instead using the iterator directly to only get the first
// few entries which will actually be displayed (this requires changing some logic in the call sites of this function)
if ($isMap(iteratedObject)) {
if (isEntries) return [ArrayPrototypeFlat.$call(ArrayFrom(iteratedObject)), true];
else if (kind === 1) return [ArrayFrom(MapPrototypeValues.$call(iteratedObject)), false];
else return [ArrayFrom(MapPrototypeKeys.$call(iteratedObject)), false];
} else if ($isSet(iteratedObject)) {
if (isEntries) return [ArrayPrototypeFlat.$call(ArrayFrom(SetPrototypeEntries.$call(iteratedObject))), true];
else return [ArrayFrom(iteratedObject), false];
}
// TODO(bun): This function is currently only called for Map and Set iterators
// perhaps we should add support for other iterators in the future? (e.g. ArrayIterator and StringIterator)
else throw new Error("previewEntries(): Invalid iterator received");
}
function previewEntries(val: object, isIterator: true, limit?: number): IteratorPreview;
function previewEntries(val, isIterator = false, limit?: number) {
if (isIterator) return previewIteratorEntries(val, limit);
// TODO(bun): are there any JSC APIs for viewing the contents of these in JS?
if (isWeakMap(val)) return [];
if (isWeakSet(val)) return [];
Expand Down
57 changes: 50 additions & 7 deletions src/jsc/ConsoleObject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -670,12 +670,15 @@ impl<'a> TablePrinter<'a> {
) -> JsResult<Self> {
bun_removable_features::removable_definition!(ConsoleTable, Self::init);
let _ = level;
let jstype = tabular_data.js_type();
Ok(TablePrinter {
global_object,
tabular_data,
properties,
is_iterable: tabular_data.is_iterable(global_object)?,
jstype: tabular_data.js_type(),
// No `Symbol.iterator` lookup on a Map or Set iterator: it is not run.
is_iterable: formatter::is_map_or_set_iterator(jstype)
|| tabular_data.is_iterable(global_object)?,
jstype,
Comment thread
robobun marked this conversation as resolved.
Resolved
value_formatter: {
// `Formatter` has a `Drop` impl, so struct-update
// from a temporary is rejected (E0509).
Expand Down Expand Up @@ -968,7 +971,8 @@ impl<'a> TablePrinter<'a> {
}
ctx.idx += 1;
}
tabular_data.for_each_with_context(
formatter::for_each_listed(
tabular_data,
global_object,
(&raw mut ctx).cast::<c_void>(),
callback::<ENABLE_ANSI_COLORS>,
Expand Down Expand Up @@ -1883,6 +1887,8 @@ pub mod formatter {
| Tag::Object
| Tag::Map
| Tag::Set
| Tag::MapIterator
| Tag::SetIterator
| Tag::Error
| Tag::Class
| Tag::Event
Expand Down Expand Up @@ -3187,6 +3193,42 @@ pub mod formatter {
max_depth: u32,
colors: bool,
) -> JSValue;

/// C++ helper (`UtilInspect.cpp`): `callback` gets what a Map or Set iterator has left. The iterator is not run.
safe fn Bun__inspect__forEachRemainingEntry(
iterator: JSValue,
global: &JSGlobalObject,
ctx: *mut c_void,
callback: jsc::ForEachCallback,
);
}

pub(crate) fn for_each_remaining_entry(
iterator: JSValue,
global: &JSGlobalObject,
ctx: *mut c_void,
callback: jsc::ForEachCallback,
) -> JsResult<()> {
jsc::host_fn::from_js_host_call_generic(global, || {
Bun__inspect__forEachRemainingEntry(iterator, global, ctx, callback)
})
}

pub(crate) fn is_map_or_set_iterator(js_type: jsc::JSType) -> bool {
matches!(js_type, jsc::JSType::MapIterator | jsc::JSType::SetIterator)
}

/// `JSValue::for_each` for a printer: a Map or Set iterator is read, not run, so the print does not use it up.
pub(crate) fn for_each_listed(
value: JSValue,
global: &JSGlobalObject,
ctx: *mut c_void,
callback: jsc::ForEachCallback,
) -> JsResult<()> {
if is_map_or_set_iterator(value.js_type()) {
return for_each_remaining_entry(value, global, ctx, callback);
}
value.for_each(global, ctx, callback)
}

// ───────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -4666,7 +4708,8 @@ pub mod formatter {
writer: writer_,
count: 0,
};
value.for_each(
for_each_remaining_entry(
value,
global_this,
(&raw mut iter).cast::<c_void>(),
MapIteratorCtx::<C, true, true>::for_each,
Expand All @@ -4675,8 +4718,7 @@ pub mod formatter {
if iter.formatter.failed {
return Ok(());
}
// Only the MapIterator case writes a trailing space.
if count > 0 && label == "MapIterator" {
if count > 0 {
let _ = writer_.write_all(b" ");
}
} else {
Expand All @@ -4685,7 +4727,8 @@ pub mod formatter {
writer: writer_,
count: 0,
};
value.for_each(
for_each_remaining_entry(
value,
global_this,
(&raw mut iter).cast::<c_void>(),
MapIteratorCtx::<C, true, false>::for_each,
Expand Down
14 changes: 2 additions & 12 deletions src/jsc/JSValue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2158,7 +2158,7 @@ struct SerializedScriptValueExternal {
handle: *mut c_void,
}

/// Callback signature for [`JSValue::for_each`] / [`JSValue::for_each_with_context`].
/// Callback signature for [`JSValue::for_each`].
pub type ForEachCallback =
extern "C" fn(vm: *mut crate::VM, global: &JSGlobalObject, ctx: *mut c_void, next: JSValue);

Expand Down Expand Up @@ -2369,6 +2369,7 @@ impl JSValue {
host_fn::from_js_host_call_generic(global, || JSC__JSValue__isIterable(self, global))
}
/// `JSValue.forEach` — invoke `callback` for each iterable element.
/// It runs the iteration protocol, so it uses an iterator up. A printer calls `console_object::formatter::for_each_listed`.
pub fn for_each(
self,
global: &JSGlobalObject,
Expand All @@ -2379,17 +2380,6 @@ impl JSValue {
JSC__JSValue__forEach(self, global, ctx, callback)
})
}
/// `JSValue.forEachWithContext` — typed-ctx wrapper (callers pass
/// `*mut c_void` directly).
#[inline]
pub(crate) fn for_each_with_context(
self,
global: &JSGlobalObject,
ctx: *mut c_void,
callback: ForEachCallback,
) -> JsResult<()> {
self.for_each(global, ctx, callback)
}
/// `JSValue.forEachProperty` — enumerate own props,
/// invoking `callback` per (key, value, is_symbol, is_private_symbol).
///
Expand Down
10 changes: 7 additions & 3 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6405,9 +6405,13 @@ impl VirtualMachine {
allow_side_effects,
printed_member: false,
};
if errors
.for_each(global_ref, (&raw mut ctx).cast(), agg_iter)
.is_err()
if crate::console_object::formatter::for_each_listed(
errors,
global_ref,
(&raw mut ctx).cast(),
agg_iter,
)
.is_err()
{
global_ref.clear_exception();
}
Expand Down
Loading
Loading