From 8f0f98a20f003e5f49dc79e3afede0a9af68721d Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sat, 2 May 2026 14:23:08 +0100 Subject: [PATCH] Replace O(n^2) interval merging with sort-then-merge mergeIntervals used a nested loop to find merge candidates, making union-heavy operations cubic when combined with iterative Union calls in npm || and hex or parsing. Now sorts intervals by lower bound and merges in a single linear pass. The npm and hex parsers also collect all intervals first instead of merging incrementally. --- parser.go | 38 +++++++++++++++++++++++++------------- parser_test.go | 25 ++++++++++++++++++++++++- range.go | 50 ++++++++++++++++++++++++++++++++++---------------- 3 files changed, 83 insertions(+), 30 deletions(-) diff --git a/parser.go b/parser.go index ffd4664..7f5bc9f 100644 --- a/parser.go +++ b/parser.go @@ -260,23 +260,30 @@ func (p *Parser) parseNpmRange(s string) (*Range, error) { return Unbounded(), nil } - // Handle || (OR) + // Handle || (OR) -- collect all parts, then merge once if strings.Contains(s, "||") { parts := strings.Split(s, "||") - var result *Range + var allIntervals []Interval + var allExclusions []string + var allRaw []Interval for _, part := range parts { - // Each OR part may contain AND constraints, so recurse r, err := p.parseNpmRange(strings.TrimSpace(part)) if err != nil { return nil, err } - if result == nil { - result = r + allIntervals = append(allIntervals, r.Intervals...) + allExclusions = append(allExclusions, r.Exclusions...) + if len(r.RawConstraints) > 0 { + allRaw = append(allRaw, r.RawConstraints...) } else { - result = result.Union(r) + allRaw = append(allRaw, r.Intervals...) } } - return result, nil + return &Range{ + Intervals: mergeIntervals(allIntervals), + Exclusions: allExclusions, + RawConstraints: allRaw, + }, nil } // Handle space-separated AND constraints @@ -661,22 +668,27 @@ func (p *Parser) parseGoRange(s string) (*Range, error) { func (p *Parser) parseHexRange(s string) (*Range, error) { s = strings.TrimSpace(s) - // Handle "or" disjunction first + // Handle "or" disjunction -- collect all parts, then merge once if strings.Contains(s, " or ") { parts := strings.Split(s, " or ") - var result *Range + var allIntervals []Interval + var allRaw []Interval for _, part := range parts { r, err := p.parseHexSingleRange(strings.TrimSpace(part)) if err != nil { return nil, err } - if result == nil { - result = r + allIntervals = append(allIntervals, r.Intervals...) + if len(r.RawConstraints) > 0 { + allRaw = append(allRaw, r.RawConstraints...) } else { - result = result.Union(r) + allRaw = append(allRaw, r.Intervals...) } } - return result, nil + return &Range{ + Intervals: mergeIntervals(allIntervals), + RawConstraints: allRaw, + }, nil } return p.parseHexSingleRange(s) diff --git a/parser_test.go b/parser_test.go index 95184d3..7eaed8e 100644 --- a/parser_test.go +++ b/parser_test.go @@ -1,6 +1,10 @@ package vers -import "testing" +import ( + "fmt" + "strings" + "testing" +) func TestParseVersURI(t *testing.T) { tests := []struct { @@ -530,3 +534,22 @@ func TestPublicAPISatisfies(t *testing.T) { }) } } + +func TestNpmParseManyClauses(t *testing.T) { + // Build a constraint with many || parts. Before the fix, this was O(n^3) + // and would take seconds or minutes. Now it should complete quickly. + parts := make([]string, 500) + for i := range parts { + parts[i] = fmt.Sprintf(">=%d.0.0 <%d.0.0", i, i+1) + } + input := strings.Join(parts, " || ") + + p := NewParser() + r, err := p.ParseNative(input, "npm") + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + if r == nil || r.IsEmpty() { + t.Error("expected non-empty range") + } +} diff --git a/range.go b/range.go index e1f91bf..5877a80 100644 --- a/range.go +++ b/range.go @@ -1,6 +1,9 @@ package vers -import "strings" +import ( + "sort" + "strings" +) // Range represents a version range as a collection of intervals. // Multiple intervals represent a union (OR) of ranges. @@ -198,24 +201,39 @@ func mergeIntervals(intervals []Interval) []Interval { return intervals } - // Simple implementation: try to merge each pair - result := make([]Interval, 0, len(intervals)) - - for _, interval := range intervals { - if interval.IsEmpty() { - continue + // Filter empty intervals and sort by lower bound + sorted := make([]Interval, 0, len(intervals)) + for _, iv := range intervals { + if !iv.IsEmpty() { + sorted = append(sorted, iv) } + } + if len(sorted) == 0 { + return nil + } - merged := false - for i, existing := range result { - if union := existing.Union(interval); union != nil { - result[i] = *union - merged = true - break - } + sort.Slice(sorted, func(i, j int) bool { + a, b := sorted[i], sorted[j] + if a.Min == "" && b.Min != "" { + return true // unbounded lower comes first + } + if a.Min != "" && b.Min == "" { + return false + } + cmp := CompareVersions(a.Min, b.Min) + if cmp != 0 { + return cmp < 0 } - if !merged { - result = append(result, interval) + return a.MinInclusive && !b.MinInclusive + }) + + result := []Interval{sorted[0]} + for _, iv := range sorted[1:] { + last := &result[len(result)-1] + if union := last.Union(iv); union != nil { + *last = *union + } else { + result = append(result, iv) } }