From 4bdfb83b270d3b217de9f5fa94b13f000d5961bc Mon Sep 17 00:00:00 2001 From: martin <43253759+martinrrm@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:01:47 +0000 Subject: [PATCH] fix: improve range parsing performance Replace comparator whitespace normalization with a linear string scanner while preserving existing parsing behavior. Add performance and compatibility coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- classes/range.js | 74 +++++++++++++++++++++++++++++++++- test/fixtures/range-parse.js | 15 +++++++ test/integration/whitespace.js | 21 ++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/classes/range.js b/classes/range.js index a7d6556f..689f9c47 100644 --- a/classes/range.js +++ b/classes/range.js @@ -119,7 +119,7 @@ class Range { debug('hyphen replace', range) // `> 1.2.3 < 1.2.5` => `>1.2.3 <1.2.5` - range = range.replace(re[t.COMPARATORTRIM], comparatorTrimReplace) + range = trimComparatorWhitespace(range) debug('comparator trim', range) // `~ 1.2.3` => `~1.2.3` @@ -228,7 +228,6 @@ const { safeRe: re, src, t, - comparatorTrimReplace, tildeTrimReplace, caretTrimReplace, } = require('../internal/re') @@ -237,6 +236,77 @@ const { FLAG_INCLUDE_PRERELEASE, FLAG_LOOSE } = require('../internal/constants') // unbounded global build-metadata stripper used by parseRange const BUILDSTRIPRE = new RegExp(src[t.BUILD], 'g') +const isVersionPrefix = char => + char === 'v' || + char === '=' || + char === ' ' + +const isVersionStart = char => + (char >= '0' && char <= '9') || + char === 'x' || + char === 'X' || + char === '*' + +// Normalize comparator whitespace without running the unanchored +// COMPARATORTRIM regex against the full range. +const trimComparatorWhitespace = range => { + const removals = [] + + for (let versionStart = 0; versionStart < range.length; versionStart++) { + if (!isVersionStart(range[versionStart])) { + continue + } + + let prefixStart = versionStart + while (prefixStart > 0 && isVersionPrefix(range[prefixStart - 1])) { + prefixStart-- + } + + let operatorEnd + if ( + prefixStart > 0 && + (range[prefixStart - 1] === '<' || range[prefixStart - 1] === '>') + ) { + operatorEnd = prefixStart + if (range[operatorEnd] === '=') { + operatorEnd++ + } + } else { + operatorEnd = prefixStart + if (range[operatorEnd] === ' ') { + operatorEnd++ + } + if (range[operatorEnd] !== '=') { + if ( + range[prefixStart] !== ' ' || + range[prefixStart + 1] !== ' ' + ) { + continue + } + operatorEnd = prefixStart + 1 + } else { + operatorEnd++ + } + } + + if (range[operatorEnd] === ' ') { + removals.push([operatorEnd, operatorEnd + 1]) + } + } + + if (!removals.length) { + return range + } + + let result = '' + let position = 0 + for (const [start, end] of removals) { + result += range.slice(position, start) + position = end + } + return result + range.slice(position) +} + const isNullSet = c => c.value === '<0.0.0-0' const isAny = c => c.value === '' diff --git a/test/fixtures/range-parse.js b/test/fixtures/range-parse.js index 1c69c96d..49d78d0c 100644 --- a/test/fixtures/range-parse.js +++ b/test/fixtures/range-parse.js @@ -31,6 +31,15 @@ module.exports = [ ['<= 2.0.0', '<=2.0.0'], ['< 2.0.0', '<2.0.0'], ['<\t2.0.0', '<2.0.0'], + ['1.2.3 = 1.2.3', '1.2.3'], + ['== 1', null], + ['>v= 1.2.3', '1.2.3', { loose: true }], + ['> +foo 1.2.3', null], + ['> +foo 1.2.3', '1.2.3', { loose: true }], + ['1 +a +b = 2', null], + ['~ +build 1', '>=1.0.0 <2.0.0-0'], + ['~> +build 1', '>=1.0.0 <2.0.0-0'], + ['^ +build 1', '>=1.0.0 <2.0.0-0'], ['>=0.1.97', '>=0.1.97'], ['0.1.20 || 1.2.4', '0.1.20||1.2.4'], ['>=0.2.3 || <0.0.1', '>=0.2.3||<0.0.1'], @@ -41,10 +50,16 @@ module.exports = [ ['1.x.5', null], ['1.*.5', null], ['1.x.5 || 2.x', null], + ['> invalid', null], ['x.1', null], ['x.1.2', null], ['x.x.1', null], ['x', '*'], + ['vvv1', '>=1.0.0 <2.0.0-0'], + ['>= vvv1', '>=1.0.0'], + ['vvv1.2.3', '1.2.3', { loose: true }], + ['===1.2.3', '1.2.3', { loose: true }], + ['v=1.2.3', '1.2.3', { loose: true }], ['2.*.*', '>=2.0.0 <3.0.0-0'], ['1.2.*', '>=1.2.0 <1.3.0-0'], ['1.2.* || 2.*', '>=1.2.0 <1.3.0-0||>=2.0.0 <3.0.0-0'], diff --git a/test/integration/whitespace.js b/test/integration/whitespace.js index 881edffe..b5263157 100644 --- a/test/integration/whitespace.js +++ b/test/integration/whitespace.js @@ -8,10 +8,12 @@ const validRange = require('../../ranges/valid') const minVersion = require('../../ranges/min-version') const minSatisfying = require('../../ranges/min-satisfying') const maxSatisfying = require('../../ranges/max-satisfying') +const satisfies = require('../../functions/satisfies') const wsMedium = ' '.repeat(125) const wsLarge = ' '.repeat(500000) const zeroLarge = '0'.repeat(500000) +const versionPrefixLarge = 'v'.repeat(500000) test('range with whitespace', (t) => { // a range with these extra characters would take a few minutes to process if @@ -23,6 +25,7 @@ test('range with whitespace', (t) => { t.equal(minVersion(r).version, '1.2.3') t.equal(minSatisfying(['1.2.3'], r), '1.2.3') t.equal(maxSatisfying(['1.2.3'], r), '1.2.3') + t.throws(() => new Range('> invalid')) t.end() }) @@ -36,6 +39,24 @@ test('range with 0', (t) => { t.end() }) +test('range with repeated version prefix', (t) => { + t.throws(() => new Range(versionPrefixLarge)) + t.equal(validRange(versionPrefixLarge), null) + t.equal(satisfies('1.2.3', versionPrefixLarge), false) + t.throws(() => minVersion(versionPrefixLarge)) + t.equal(minSatisfying(['1.2.3'], versionPrefixLarge), null) + t.equal(maxSatisfying(['1.2.3'], versionPrefixLarge), null) + + const r = `>= ${versionPrefixLarge}1` + t.equal(new Range(r).range, '>=1.0.0') + t.equal(validRange(r), '>=1.0.0') + t.equal(satisfies('1.2.3', r), true) + t.equal(minVersion(r).version, '1.0.0') + t.equal(minSatisfying(['1.2.3'], r), '1.2.3') + t.equal(maxSatisfying(['1.2.3'], r), '1.2.3') + t.end() +}) + test('semver version', (t) => { const v = `${wsMedium}1.2.3${wsMedium}` const tooLong = `${wsLarge}1.2.3${wsLarge}`