Skip to content

fix: don't double-process %% before a format specifier#1040

Open
spokodev wants to merge 1 commit into
debug-js:masterfrom
spokodev:fix/double-percent-before-specifier
Open

fix: don't double-process %% before a format specifier#1040
spokodev wants to merge 1 commit into
debug-js:masterfrom
spokodev:fix/double-percent-before-specifier

Conversation

@spokodev

Copy link
Copy Markdown

Problem

A literal %% immediately followed by a real format specifier is mis-rendered. The README states debug uses printf-style formatting and delegates %s/%d/%j to Node's util.format, so util.format is the source of truth:

const debug = require('debug')('app'); // DEBUG=*, colors off

debug('%%%s', 'X');        // logged: "app %s X"        util.format('%%%s', 'X')        === '%X'
debug('100%%%d done', 50); // logged: "app 100%d done 50"  util.format('100%%%d done', 50) === '100%50 done'

The output should match util.format. It only breaks for an even number of leading % before a specifier; odd counts (e.g. %%s) were already correct.

Root cause

src/common.js runs debug's own format pass before the result reaches the downstream logger:

args[0] = args[0].replace(/%([a-zA-Z%])/g, (match, format) => {
    if (match === '%%') {
        return '%';   // collapse here
    }
    ...
});

That collapsed string is then passed to util.formatWithOptions (Node) / console.* (browser), which collapse %% again. The escape is processed twice. For '%%%s', the first pass emits '%' + '%s' = '%%s', and util.format('%%s', 'X') is '%s X': the specifier boundary has shifted.

The reason debug collapses at all is that these loggers only collapse %% when at least one argument is present:

util.format('100%%')        // '100%%'   (0 args, not collapsed)
util.format('100%%', 'X')   // '100% X'  (1 arg, collapsed)

So with zero remaining arguments debug has to collapse %% itself, otherwise it would leak as %%.

Fix

Decide per-call whether the downstream logger will still have arguments:

  • arguments remain → leave %% verbatim and let the logger perform the single, correct collapse.
  • no arguments remain → collapse %%% ourselves, as before.

The remaining-argument count is the original argument count minus those consumed by debug's own custom formatters (%o/%O/registered), computed before the pass so a %% appearing before a custom formatter is decided correctly.

util.format('%%%s', 'X')        // '%X'        ✓
util.format('100%%%d done', 50) // '100%50 done' ✓

This keeps %s/%d/%j, custom formatters, separated %% %s, zero-arg 100%% done, and %% inside custom-formatter output all correct.

Relationship to #1028 / #770

Both rewrite the custom-formatter output branch so a % produced by %o/%O is not re-interpreted (issue #766). They leave the %% escape branch untouched. Applying #1028 on top of current HEAD, debug('%%%s', 'X') still logs %s X: a separate defect in the %% branch, which is what this PR addresses.

Tests

Added a node test group in test.node.js asserting util.format parity for %% before a specifier, plus the preserved behaviors (zero-arg collapse, custom formatters, formatter-output verbatim). The parity tests fail on master and pass with the fix. test:node (20) and test:browser (14) are green.

`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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant