Skip to content

Commit 35a2e42

Browse files
authored
Replace O(n^2) interval merging with sort-then-merge (#15)
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.
1 parent 7afd6de commit 35a2e42

3 files changed

Lines changed: 83 additions & 30 deletions

File tree

parser.go

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -260,23 +260,30 @@ func (p *Parser) parseNpmRange(s string) (*Range, error) {
260260
return Unbounded(), nil
261261
}
262262

263-
// Handle || (OR)
263+
// Handle || (OR) -- collect all parts, then merge once
264264
if strings.Contains(s, "||") {
265265
parts := strings.Split(s, "||")
266-
var result *Range
266+
var allIntervals []Interval
267+
var allExclusions []string
268+
var allRaw []Interval
267269
for _, part := range parts {
268-
// Each OR part may contain AND constraints, so recurse
269270
r, err := p.parseNpmRange(strings.TrimSpace(part))
270271
if err != nil {
271272
return nil, err
272273
}
273-
if result == nil {
274-
result = r
274+
allIntervals = append(allIntervals, r.Intervals...)
275+
allExclusions = append(allExclusions, r.Exclusions...)
276+
if len(r.RawConstraints) > 0 {
277+
allRaw = append(allRaw, r.RawConstraints...)
275278
} else {
276-
result = result.Union(r)
279+
allRaw = append(allRaw, r.Intervals...)
277280
}
278281
}
279-
return result, nil
282+
return &Range{
283+
Intervals: mergeIntervals(allIntervals),
284+
Exclusions: allExclusions,
285+
RawConstraints: allRaw,
286+
}, nil
280287
}
281288

282289
// Handle space-separated AND constraints
@@ -661,22 +668,27 @@ func (p *Parser) parseGoRange(s string) (*Range, error) {
661668
func (p *Parser) parseHexRange(s string) (*Range, error) {
662669
s = strings.TrimSpace(s)
663670

664-
// Handle "or" disjunction first
671+
// Handle "or" disjunction -- collect all parts, then merge once
665672
if strings.Contains(s, " or ") {
666673
parts := strings.Split(s, " or ")
667-
var result *Range
674+
var allIntervals []Interval
675+
var allRaw []Interval
668676
for _, part := range parts {
669677
r, err := p.parseHexSingleRange(strings.TrimSpace(part))
670678
if err != nil {
671679
return nil, err
672680
}
673-
if result == nil {
674-
result = r
681+
allIntervals = append(allIntervals, r.Intervals...)
682+
if len(r.RawConstraints) > 0 {
683+
allRaw = append(allRaw, r.RawConstraints...)
675684
} else {
676-
result = result.Union(r)
685+
allRaw = append(allRaw, r.Intervals...)
677686
}
678687
}
679-
return result, nil
688+
return &Range{
689+
Intervals: mergeIntervals(allIntervals),
690+
RawConstraints: allRaw,
691+
}, nil
680692
}
681693

682694
return p.parseHexSingleRange(s)

parser_test.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package vers
22

3-
import "testing"
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
)
48

59
func TestParseVersURI(t *testing.T) {
610
tests := []struct {
@@ -530,3 +534,22 @@ func TestPublicAPISatisfies(t *testing.T) {
530534
})
531535
}
532536
}
537+
538+
func TestNpmParseManyClauses(t *testing.T) {
539+
// Build a constraint with many || parts. Before the fix, this was O(n^3)
540+
// and would take seconds or minutes. Now it should complete quickly.
541+
parts := make([]string, 500)
542+
for i := range parts {
543+
parts[i] = fmt.Sprintf(">=%d.0.0 <%d.0.0", i, i+1)
544+
}
545+
input := strings.Join(parts, " || ")
546+
547+
p := NewParser()
548+
r, err := p.ParseNative(input, "npm")
549+
if err != nil {
550+
t.Fatalf("Parse failed: %v", err)
551+
}
552+
if r == nil || r.IsEmpty() {
553+
t.Error("expected non-empty range")
554+
}
555+
}

range.go

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package vers
22

3-
import "strings"
3+
import (
4+
"sort"
5+
"strings"
6+
)
47

58
// Range represents a version range as a collection of intervals.
69
// Multiple intervals represent a union (OR) of ranges.
@@ -198,24 +201,39 @@ func mergeIntervals(intervals []Interval) []Interval {
198201
return intervals
199202
}
200203

201-
// Simple implementation: try to merge each pair
202-
result := make([]Interval, 0, len(intervals))
203-
204-
for _, interval := range intervals {
205-
if interval.IsEmpty() {
206-
continue
204+
// Filter empty intervals and sort by lower bound
205+
sorted := make([]Interval, 0, len(intervals))
206+
for _, iv := range intervals {
207+
if !iv.IsEmpty() {
208+
sorted = append(sorted, iv)
207209
}
210+
}
211+
if len(sorted) == 0 {
212+
return nil
213+
}
208214

209-
merged := false
210-
for i, existing := range result {
211-
if union := existing.Union(interval); union != nil {
212-
result[i] = *union
213-
merged = true
214-
break
215-
}
215+
sort.Slice(sorted, func(i, j int) bool {
216+
a, b := sorted[i], sorted[j]
217+
if a.Min == "" && b.Min != "" {
218+
return true // unbounded lower comes first
219+
}
220+
if a.Min != "" && b.Min == "" {
221+
return false
222+
}
223+
cmp := CompareVersions(a.Min, b.Min)
224+
if cmp != 0 {
225+
return cmp < 0
216226
}
217-
if !merged {
218-
result = append(result, interval)
227+
return a.MinInclusive && !b.MinInclusive
228+
})
229+
230+
result := []Interval{sorted[0]}
231+
for _, iv := range sorted[1:] {
232+
last := &result[len(result)-1]
233+
if union := last.Union(iv); union != nil {
234+
*last = *union
235+
} else {
236+
result = append(result, iv)
219237
}
220238
}
221239

0 commit comments

Comments
 (0)