From 158febfed16c9dd05d310a025515b49cb27fa8e8 Mon Sep 17 00:00:00 2001 From: Yarchik Date: Thu, 25 Jun 2026 21:01:06 +0100 Subject: [PATCH] fix: don't double-process %% before a format specifier `debug` ran its own format pass that collapsed every `%%` to `%`, then handed the result to `util.formatWithOptions` (Node) / `console.*` (browser), which collapse `%%` again. For an even number of leading `%` before a real specifier this re-collapse shifted the specifier boundary: debug('%%%s', 'X') // logged "%s X", util.format gives "%X" debug('100%%%d done', 50) // logged "100%d done 50", expected "100%50 done" Leave `%%` untouched when the downstream logger still has arguments (it performs the single, correct collapse), and only collapse it ourselves when no arguments remain, since those loggers keep `%%` verbatim with zero args. Custom formatters, `%s`/`%d`/`%j` and lone `%%` all stay correct. --- src/common.js | 22 +++++++++++++++++++++- test.node.js | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/common.js b/src/common.js index 141cb578..23294367 100644 --- a/src/common.js +++ b/src/common.js @@ -86,12 +86,32 @@ function setup(env) { args.unshift('%O'); } + // Count how many arguments the downstream printf-style logger + // (`util.formatWithOptions` in Node, `console.*` in browsers) will be + // left with after our own `formatters` consume theirs. This decides how + // an escaped `%%` must be handled: those loggers only collapse `%%` to a + // literal `%` when at least one argument is present, so when arguments + // remain we must leave `%%` untouched and let them perform the single, + // correct collapse. Collapsing it here as well would re-process the + // escape and shift the boundary of any specifier that follows it (e.g. + // `debug('%%%s', 'X')` should render `%X`, matching `util.format`). + let downstreamArgs = args.length - 1; + args[0].replace(/%([a-zA-Z%])/g, (match, format) => { + if (match !== '%%' && typeof createDebug.formatters[format] === 'function') { + downstreamArgs--; + } + return match; + }); + // Apply any `formatters` transformations let index = 0; args[0] = args[0].replace(/%([a-zA-Z%])/g, (match, format) => { // If we encounter an escaped % then don't increase the array index if (match === '%%') { - return '%'; + // Defer collapsing to the downstream logger when it still has + // arguments to format; otherwise it would leave `%%` verbatim, + // so collapse it ourselves. + return downstreamArgs > 0 ? match : '%'; } index++; const formatter = createDebug.formatters[format]; diff --git a/test.node.js b/test.node.js index 4cc3c051..17bcb819 100644 --- a/test.node.js +++ b/test.node.js @@ -37,4 +37,45 @@ describe('debug node', () => { stdErrWriteStub.restore(); }); }); + + describe('escaped percent (%%) before a specifier', () => { + // Returns the body of the logged line (namespace prefix stripped) so it can + // be compared against `util.format`, which the README documents as the + // source of truth for `%s`/`%d`/`%j`. + function render(template, ...rest) { + debug.enable('*'); + debug.inspectOpts.hideDate = true; + debug.inspectOpts.colors = false; + + const stdErrWriteStub = sinon.stub(process.stderr, 'write'); + try { + debug('render')(template, ...rest); + } finally { + stdErrWriteStub.restore(); + } + + const line = stdErrWriteStub.getCall(0).args[0].replace(/\n$/, ''); + const marker = 'render '; + return line.slice(line.indexOf(marker) + marker.length); + } + + it('keeps parity with util.format for %% followed by a specifier', () => { + assert.strictEqual(render('%%%s', 'X'), util.format('%%%s', 'X')); + assert.strictEqual(render('100%%%d done', 50), util.format('100%%%d done', 50)); + }); + + it('still collapses %% when no arguments remain', () => { + assert.strictEqual(render('100%% done'), '100% done'); + assert.strictEqual(render('a %% b %% c'), 'a % b % c'); + }); + + it('keeps custom formatters working alongside %%', () => { + assert.strictEqual(render('%o %% done', {a: 1}), '{ a: 1 } % done'); + assert.strictEqual(render('%o %%%s', {a: 1}, 'X'), '{ a: 1 } %X'); + }); + + it('does not collapse %% inside custom formatter output', () => { + assert.strictEqual(render('%o', {k: '50%% off'}), '{ k: \'50%% off\' }'); + }); + }); });