|
| 1 | +import ava from 'ava'; |
| 2 | +import parser from '../index.js'; |
| 3 | + |
| 4 | +// Regression tests for the quadratic parsing of flat selectors: `.` and `#` are |
| 5 | +// not word delimiters, so `.a.a.a...` reaches splitWord as a single word token |
| 6 | +// carrying n class indexes. Three passes used to scan those index arrays |
| 7 | +// linearly for every index, making the parse O(n^2): |
| 8 | +// |
| 9 | +// 1. uniqs(), deduplicating with filter + indexOf |
| 10 | +// 2. the indices.forEach loop, calling hasClass.indexOf / hasId.indexOf |
| 11 | +// 3. the Sass-interpolation filter, calling interpolations.indexOf |
| 12 | +// |
| 13 | +// Nesting depth is 0 for such a selector, so maxNestingDepth offers no |
| 14 | +// protection. All three now use Set membership tests. |
| 15 | + |
| 16 | +const N = 60000; |
| 17 | + |
| 18 | +// Fastest of a few runs, to shrug off GC pauses and scheduling noise. |
| 19 | +function fastest (input) { |
| 20 | + let best = Infinity; |
| 21 | + for (let i = 0; i < 3; i++) { |
| 22 | + const start = process.hrtime.bigint(); |
| 23 | + parser().astSync(input); |
| 24 | + best = Math.min(best, Number(process.hrtime.bigint() - start) / 1e6); |
| 25 | + } |
| 26 | + return best; |
| 27 | +} |
| 28 | + |
| 29 | +// Compare the hostile payload (one word token holding N indexes) against a |
| 30 | +// benign control of the same repeated atom, space separated, which forces the |
| 31 | +// same amount of real parsing work spread over N word tokens. Deriving the |
| 32 | +// budget from a measurement taken on the same machine keeps this stable across |
| 33 | +// CI runners and under coverage instrumentation, where an absolute time budget |
| 34 | +// or a raw input-scaling ratio would not be. |
| 35 | +// |
| 36 | +// The control does strictly more work than the payload once parsing is linear, |
| 37 | +// so a healthy ratio sits below 1. It reached 4-8 while the passes were |
| 38 | +// quadratic. The threshold sits well clear of both. |
| 39 | +function costRatio (atom) { |
| 40 | + const hostile = fastest(atom.repeat(N)); |
| 41 | + const benign = fastest(`${atom} `.repeat(N)); |
| 42 | + return hostile / Math.max(benign, 1); |
| 43 | +} |
| 44 | + |
| 45 | +for (const [label, atom] of [ |
| 46 | + ['flat class selectors', '.a'], |
| 47 | + ['flat id selectors', '#a'], |
| 48 | + ['repeated Sass interpolations', '#{a}'], |
| 49 | +]) { |
| 50 | + ava(`${label} parse in time proportional to their size`, t => { |
| 51 | + const ratio = costRatio(atom); |
| 52 | + t.true( |
| 53 | + ratio < 2, |
| 54 | + `parsing "${atom}" repeated ${N} times as one token cost ${ratio.toFixed(1)}x the same atoms parsed separately, which suggests a super-linear pass` |
| 55 | + ); |
| 56 | + }); |
| 57 | +} |
| 58 | + |
| 59 | +ava('a large flat selector still produces one node per class', t => { |
| 60 | + const tree = parser().astSync('.a'.repeat(20000)); |
| 61 | + const nodes = tree.first.nodes; |
| 62 | + t.is(nodes.length, 20000); |
| 63 | + t.true(nodes.every(node => node.type === 'class' && node.value === 'a')); |
| 64 | +}); |
| 65 | + |
| 66 | +// The leading 0 pushed into the index list collides with hasClass[0] or |
| 67 | +// hasId[0] whenever the word starts with `.` or `#`, so the deduplication in |
| 68 | +// uniqs() is load-bearing: dropping it emits a spurious first node covering the |
| 69 | +// whole word. These pin that down. |
| 70 | +ava('deduplication keeps a leading class from being emitted twice', t => { |
| 71 | + t.deepEqual( |
| 72 | + parser().astSync('.a.b').first.nodes.map(node => [node.type, node.value]), |
| 73 | + [ |
| 74 | + ['class', 'a'], |
| 75 | + ['class', 'b'], |
| 76 | + ] |
| 77 | + ); |
| 78 | +}); |
| 79 | + |
| 80 | +ava('deduplication keeps a leading id from being emitted twice', t => { |
| 81 | + t.deepEqual( |
| 82 | + parser().astSync('#x.y').first.nodes.map(node => [node.type, node.value]), |
| 83 | + [ |
| 84 | + ['id', 'x'], |
| 85 | + ['class', 'y'], |
| 86 | + ] |
| 87 | + ); |
| 88 | +}); |
| 89 | + |
| 90 | +ava('a single leading class is emitted once', t => { |
| 91 | + const nodes = parser().astSync('.a').first.nodes; |
| 92 | + t.is(nodes.length, 1); |
| 93 | + t.is(nodes[0].type, 'class'); |
| 94 | +}); |
0 commit comments