Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 4 additions & 4 deletions src/Lexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,11 +306,11 @@ export class _Lexer<ParserOutput = string, RendererOutput = string> {
let maskedSrc = src;

// Mask out reflinks
if (this.tokens.links) {
const links = Object.keys(this.tokens.links);
if (links.length > 0) {
if (this.tokens.links && src.includes('[')) {
const links = new Set(Object.keys(this.tokens.links));
if (links.size > 0) {
maskedSrc = maskedSrc.replace(this.tokenizer.rules.inline.reflinkSearch, match0 =>
links.includes(match0.slice(match0.lastIndexOf('[') + 1, -1))
links.has(match0.slice(match0.lastIndexOf('[') + 1, -1))
? '[' + 'a'.repeat(match0.length - 2) + ']'
: match0);
}
Expand Down
49 changes: 49 additions & 0 deletions test/unit/inlineTokens-masking.test.js

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you create a test in test/specs/redos instead

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — moved the regression coverage to test/specs/redos as quadratic_footnote_defs.cjs (13000 footnote-shaped defs + refs; ~24s pre-fix, ~0.07s post-fix) and dropped the unit test file. Re-verified before push: full suite green (npm test) and the scaling repro reports exponent 1.151.

Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { marked } from '../../lib/marked.esm.js';
import { describe, it } from 'node:test';
import assert from 'node:assert';

/**
* Regression: many reference definitions + references must not be quadratic.
*
* Lexer.inlineTokens rebuilt its reflink-masking preamble (Object.keys over
* every link definition, then a reflinkSearch replace) on *every* call —
* including recursive link-text calls whose text cannot contain a reflink.
* With n defs and n refs that is O(n²) allocation churn (measured exponent
* ~3: 24s at n=13000). The fix skips the masking block when the source has
* no '[' (reflinkSearch cannot match without one) and uses a Set lookup.
*/
function footnoteShape(n) {
let refs = '';
const defs = [];
for (let i = 0; i < n; i++) {
refs += `[^${i}] `;
defs.push(`[^${i}]: x`);
}
return refs + '\n\n' + defs.join('\n');
}

function parseSeconds(text) {
marked.parse('warmup');
const t0 = process.hrtime.bigint();
marked.parse(text);
return Number(process.hrtime.bigint() - t0) / 1e9;
}

describe('inlineTokens masking scaling', () => {
it('stays near-linear over a doubling ladder', () => {
const t1 = parseSeconds(footnoteShape(2000));
const t2 = parseSeconds(footnoteShape(4000));
// pre-fix this ratio is ~5+ (exponent > 2); linear growth is ~2.
assert.ok(
t2 < Math.max(t1 * 3.5, 0.5),
`superlinear growth suspected: ${t1.toFixed(3)}s -> ${t2.toFixed(3)}s`,
);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — replaced the absolute-time fallback with a direct ratio assertion plus an epsilon floor (t2 / Math.max(t1, 1e-4) < 3.5) in abb5d89, so the check stays sensitive on fast runners. Re-verified: full suite green (1767 + 192 tests) and the scaling repro still reports sub-linear exponent (0.781).

});

it('renders reference links identically to the unmasked path', () => {
assert.strictEqual(
marked.parse('[a][b]\n\n[b]: /url "t"'),
'<p><a href="/url" title="t">a</a></p>\n',
);
});
});