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
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
53 changes: 53 additions & 0 deletions test/unit/inlineTokens-masking.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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 on the growth ratio directly (epsilon floor avoids divide-by-zero)
// so the check stays sensitive on fast runners instead of falling back to
// an absolute-time bound that can mask superlinear behavior.
const ratio = t2 / Math.max(t1, 1e-4);
assert.ok(
ratio < 3.5,
`superlinear growth suspected: ${t1.toFixed(3)}s -> ${t2.toFixed(3)}s (ratio ${ratio.toFixed(2)})`,
);
});

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',
);
});
});