Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 21 additions & 1 deletion src/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
41 changes: 41 additions & 0 deletions test.node.js
Original file line number Diff line number Diff line change
Expand Up @@ -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\' }');
});
});
});