Skip to content

fix: improve range parsing performance - #899

Open
martinrrm wants to merge 1 commit into
mainfrom
martinrrm/improve-range-parsing-performance
Open

fix: improve range parsing performance#899
martinrrm wants to merge 1 commit into
mainfrom
martinrrm/improve-range-parsing-performance

Conversation

@martinrrm

Copy link
Copy Markdown

Summary

This change replaces the internal use of the global COMPARATORTRIM regular expression in Range.parseRange() with a linear string scanner.

The scanner preserves the existing comparator-normalization behavior, including loose parsing, unlimited leading v/= compatibility, build-metadata stripping, and unusual whitespace combinations. Public APIs and exported regex definitions remain unchanged.

Background

Range parsing currently normalizes whitespace between comparison operators and version expressions with:

range = range.replace(re[t.COMPARATORTRIM], comparatorTrimReplace)

COMPARATORTRIM combines several large regex fragments and runs globally against the complete range. Inputs containing long sequences of characters accepted by the version prefix can cause the expression to repeatedly scan overlapping portions of the input.

This work grows substantially faster than the input size, even when the range is ultimately invalid.

The normalization performed by COMPARATORTRIM is comparatively small: it removes whitespace between a comparison operator and the version expression that follows it. Performing that operation with a string scanner avoids repeatedly invoking the complete range grammar.

Implementation

Range.parseRange() now calls:

range = trimComparatorWhitespace(range)

The scanner:

  1. Locates possible version and X-range starts: digits, x, X, or *.
  2. Walks backward through the existing v, =, and whitespace prefix.
  3. Identifies the corresponding <, <=, >, >=, =, or empty comparator.
  4. Records the comparator-adjacent whitespace that should be removed.
  5. Reconstructs the range after completing the scan.

The implementation records removals rather than modifying the string during iteration, keeping indexes stable when a range contains multiple comparators.

It also mirrors the existing safe-regex whitespace behavior after build metadata has been removed. This matters because build stripping can create consecutive spaces, and the previous implementation removed at most one comparator-adjacent space during each match.

Why change the COMPARATORTRIM call site?

LOOSEPLAIN, XRANGEPLAIN, and XRANGEPLAINLOOSE are shared parsing fragments used by many expressions. Their normal uses are anchored and only scan an input from one starting position.

The expensive behavior came from composing those fragments into COMPARATORTRIM, which is global and unanchored. That expression could retry the same prefix matching from every input position.

Replacing the internal COMPARATORTRIM operation addresses the point where the repeated work occurs without changing the shared grammar.

This approach also avoids changing:

  • LOOSEPLAIN
  • XRANGEPLAIN
  • XRANGEPLAINLOOSE
  • COMPARATORTRIM
  • semver.re
  • semver.src
  • Existing support for repeated v and = prefixes

The low-level regex definitions remain available with their existing values. Only the internal normalization performed by Range changes implementation.

Compatibility

This change is intended to preserve existing parsing behavior.

In particular, previously accepted inputs remain accepted:

v1.2.3
=1.2.3
vvv1
vvv1.2.3
==1.2.3
===1.2.3
v=1.2.3
>= vvv1

The implementation also preserves existing strict and loose behavior for unusual cases involving equality, whitespace, and stripped build metadata:

1.2.3 = 1.2.3
== 1
>v= 1.2.3
> +foo 1.2.3
1 +a +b = 2
~ +build 1
~> +build 1
^ +build 1

No public API signatures, return types, canonical range results, or exception messages are intentionally changed.

Performance

Representative measurements for an invalid repeated-prefix input:

Input length Before After
2,000 11.6 ms 1.4 ms
8,000 167.9 ms 0.3 ms
16,000 653.0 ms 0.3 ms
32,000 3,398.6 ms 0.5 ms
64,000 9,716.3 ms 0.7 ms

The new implementation scales linearly instead of repeatedly scanning overlapping suffixes.

A workload containing 20,000 ordinary unique ranges also showed no regression:

Before: 264 ms
After:  245 ms

Very large valid prefixes incur a small linear scanning cost. A valid range with a 500,000-character prefix completed in approximately 10 ms, compared with approximately 5 ms previously, while producing the same result.

Validation

  • Complete node-semver test suite passes with 100% coverage.
  • Existing strict and loose range fixtures pass.
  • More than 10 million generated and explicit validRange() comparisons produced no differences from the previous implementation.
  • More than 1.5 million comparisons of Range results, raw values, and thrown error messages produced no differences.
  • Representative npm-package-arg inputs retained identical version, range, and tag classifications.
  • Large invalid and valid repeated-prefix inputs are covered through:
    • Range
    • validRange
    • satisfies
    • minVersion
    • minSatisfying
    • maxSatisfying

@martinrrm
martinrrm requested a review from a team as a code owner September 1, 2026 20:44
@martinrrm martinrrm changed the title perf: improve range parsing fix: improve range parsing performance Sep 1, 2026
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>
@martinrrm
martinrrm force-pushed the martinrrm/improve-range-parsing-performance branch from 4f4903c to 4bdfb83 Compare September 1, 2026 20:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants