console: fill in a format specifier that directly follows `%%`
Problem
console.log("%%%s", "a")prints%%s a. Node prints%a. A specifier directly after the%%escape is not filled in, and%%%%prints%%%.- The cause is
i = 1after the%%arm ofwrite_with_formatting(src/jsc/ConsoleObject.rs:2395). The scan continues at the second byte after the escape and never sees a%in the first.
Fix
- Set
i = 0after the escape. The scan then continues at the first byte. - Correct because Node's
formatWithOptionsInternalcontinues at that byte too. - Verified:
test/js/web/console/console-log.test.ts(one new test, fails on 1.4.3). Also the other console suites andutil-format.test.js.
Background
- The format string is the first argument of a
consolecall with more arguments. Each specifier uses one argument.%%prints one%and uses none. write_with_formattingscans with indexiintoslice, and cutssliceafter each escape or specifier.util.formatis separate JS code and already matches Node. Nothing else scans the console format string.
Downsides
- New throw: a specifier after
%%now takes its argument, so it throws for the values it already throws for alone.console.log("%%%d", 12n)printed%%d 12nand now throwsTypeError: Conversion from 'BigInt' to 'number' is not allowed.Notes lists six such calls and the open PR that removes each (#45935, #45337, #45325). - Output changes for
%%directly before%.console.log("%%%%", 1)printed%%% 1and now prints%% 1. Snapshots of the old text change. - Still wrong:
%%after the last used argument stays%%(#34239 changes that). Cost: one more loop iteration per%%.
Notes
Related to #34239. #34239 collapses %% after the last used argument in a pass after the loop. It does not change the %% arm. The two PRs merge without a conflict (git merge-tree against 292bffd859). A comment on #34239 reports this same fault: console.log("%%%% %c", "") prints %%% . With this change it prints %% , as Node does.
Scope. console.error, console.warn, console.info and console.debug share the fault, for every specifier (%s %d %i %f %j %o %O %c).
Calls that throw with this change. Each line is from a debug build of this branch, from 1.4.3 and from Node v26.3.0. cyclic is an object that contains itself.
| Call | Node | 1.4.3 | This PR | Open PR for the specifier |
|---|---|---|---|---|
console.log("%%%d", 12n) | %12n | %%d 12n | TypeError: Conversion from 'BigInt' to 'number' is not allowed. | #45935 |
console.log("%%%i", 5n) | %5n | %%i 5n | the same TypeError | #45935 |
console.log("%%%f", 5n) | %5 | %%f 5n | the same TypeError | #45935 |
console.log("%%%s", Symbol("x")) | %Symbol(x) | %%s Symbol(x) | TypeError: Cannot convert a symbol to a string | #45337 |
console.log("%%%s", Object.create(null)) | %[Object: null prototype] {} | %%s [Object: null prototype] {} | TypeError: No default value | #45337 |
console.log("%%%j", cyclic) | %[Circular] | %%j and the inspected object | TypeError: JSON.stringify cannot serialize cyclic structures. | #45325 |
console.log("load %d%%%d", 50, 2.75) | load 50%2.75 | load 50%%d 2.75 | load 50%2 | #45951 |
Each throw is the one that the same specifier throws today with no %% in front: console.log("%d", 12n) throws on 1.4.3. The % is written before the throw, so a caught throw leaves a partial line. If #45935, #45337 and #45325 merge before this PR, none of these calls throws. The last row does not throw. %d drops the fraction, as it does today with no %% in front.
History. The arm comes from #10910 (Zig): i = 0; continue; inside while (i < len) : (i += 1). The continue ran the loop step, so the scan continued at index 1. The Rust port wrote that result as i = 1 with a comment that named it an off-by-one.
Probes. Each line is console.log(...) on a debug build of this branch. All 28 lines equal the output of Node v26.3.0. The column "1.4.3" has the old text where it differs.
| Call | This PR and Node | 1.4.3 |
|---|---|---|
("%d%%%s", 50, "!") | 50%! | 50%%s ! |
("%%%s", "a") | %a | %%s a |
("%s is %d%%%s", "disk", 93, " full") | disk is 93% full | disk is 93%%s full |
("%%%%", "a") | %% a | %%% a |
("%%%%%s", "a") | %%a | %%%s a |
("%%%d", 7) | %7 | %%d 7 |
("%%%j", { a: 1 }) | %{"a":1} | %%j {, then a: 1,, then }, on three lines |
("%%%c", "x") | % | %%c x |
("%s%%%s", "a", "b") | a%b | a%%s b |
("a%%%%b%%%sc", "X") | a%%b%Xc | a%%%b%%sc X |
("%%%x%s", "a") | %%xa | the same |
("%%a%s", "x") | %ax | the same |
("Hello %%i %i", 5, 6) | Hello %i 5 6 | the same |
Suites run on the debug build. test/js/web/console/ (12 pass), then test/js/bun/console/, test/js/node/console/ and test/js/node/util/node-inspect-tests/parallel/util-format.test.js (100 pass, 1 skip, 2 todo, 0 fail).
Other scanners. The test.each title formatter (format_label, src/runtime/test_runner/jest.rs) steps over both bytes of %% and does not have this fault.