Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ func (p *Parser) ParseNative(constraint string, scheme string) (*Range, error) {
return p.parseCargoRange(constraint)
case "go", "golang":
return p.parseGoRange(constraint)
case "hex", "elixir":
return p.parseHexRange(constraint)
case "deb", "debian":
return p.parseDebianRange(constraint)
case "rpm":
Expand Down Expand Up @@ -651,6 +653,78 @@ func (p *Parser) parseGoRange(s string) (*Range, error) {
return p.parseConstraints(s, "go")
}

// hex/elixir: ~> 1.2.3, >= 1.0.0 and < 2.0.0, ~> 1.0 or ~> 2.0
func (p *Parser) parseHexRange(s string) (*Range, error) {
s = strings.TrimSpace(s)

// Handle "or" disjunction first
if strings.Contains(s, " or ") {
parts := strings.Split(s, " or ")
var result *Range
for _, part := range parts {
r, err := p.parseHexSingleRange(strings.TrimSpace(part))
if err != nil {
return nil, err
}
if result == nil {
result = r
} else {
result = result.Union(r)
}
}
return result, nil
}

return p.parseHexSingleRange(s)
}

func (p *Parser) parseHexSingleRange(s string) (*Range, error) {
// Handle "and" conjunction
if strings.Contains(s, " and ") {
parts := strings.Split(s, " and ")
var result *Range
for _, part := range parts {
r, err := p.parseHexConstraint(strings.TrimSpace(part))
if err != nil {
return nil, err
}
if result == nil {
result = r
} else {
result = result.Intersect(r)
}
}
return result, nil
}

return p.parseHexConstraint(s)
}

func (p *Parser) parseHexConstraint(s string) (*Range, error) {
// Pessimistic operator: ~> 1.2.3
if strings.HasPrefix(s, "~>") {
version := strings.TrimSpace(s[2:])
return p.parsePessimisticRange(version)
}

// Normalize == to = for internal constraint parsing
normalized := strings.Replace(s, "==", "=", 1)
constraint, err := ParseConstraint(normalized)
if err != nil {
return nil, err
}

if constraint.IsExclusion() {
return Unbounded().Exclude(constraint.Version), nil
}

interval, ok := constraint.ToInterval()
if !ok {
return nil, fmt.Errorf("invalid hex constraint: %s", s)
}
return NewRange([]Interval{interval}), nil
}

// debian: >= 1.0, << 2.0
func (p *Parser) parseDebianRange(s string) (*Range, error) {
// Convert Debian operators to standard
Expand Down
84 changes: 84 additions & 0 deletions parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,90 @@ func TestParseRpmRange(t *testing.T) {
}
}

func TestParseHexRange(t *testing.T) {
tests := []struct {
name string
input string
version string
want bool
}{
// Pessimistic operator
{"~> 1.2.3 includes exact", "~> 1.2.3", "1.2.3", true},
{"~> 1.2.3 includes patch", "~> 1.2.3", "1.2.9", true},
{"~> 1.2.3 excludes minor", "~> 1.2.3", "1.3.0", false},
{"~> 1.2.3 excludes below", "~> 1.2.3", "1.2.2", false},
{"~> 2.0 includes minor", "~> 2.0", "2.9.9", true},
{"~> 2.0 excludes major", "~> 2.0", "3.0.0", false},
{"~> 2.1 includes above", "~> 2.1", "2.9.9", true},
{"~> 2.1 excludes major", "~> 2.1", "3.0.0", false},
{"~> 2.1 excludes below", "~> 2.1", "2.0.9", false},

// Exact match with ==
{"== 1.2.3 matches", "== 1.2.3", "1.2.3", true},
{"== 1.2.3 excludes above", "== 1.2.3", "1.2.4", false},
{"== 1.2.3 excludes below", "== 1.2.3", "1.2.2", false},

// Comparison operators
{">= 1.0.0 includes exact", ">= 1.0.0", "1.0.0", true},
{">= 1.0.0 includes above", ">= 1.0.0", "2.0.0", true},
{">= 1.0.0 excludes below", ">= 1.0.0", "0.9.0", false},
{"> 1.0.0 excludes exact", "> 1.0.0", "1.0.0", false},
{"> 1.0.0 includes above", "> 1.0.0", "1.0.1", true},
{"<= 2.0.0 includes exact", "<= 2.0.0", "2.0.0", true},
{"<= 2.0.0 excludes above", "<= 2.0.0", "2.0.1", false},
{"< 2.0.0 excludes exact", "< 2.0.0", "2.0.0", false},
{"< 2.0.0 includes below", "< 2.0.0", "1.9.9", true},

// Not equal
{"!= 1.5.0 includes other", "!= 1.5.0", "1.4.0", true},
{"!= 1.5.0 includes above", "!= 1.5.0", "1.6.0", true},
{"!= 1.5.0 excludes exact", "!= 1.5.0", "1.5.0", false},

// And conjunction
{">= 1.0.0 and < 2.0.0 includes", ">= 1.0.0 and < 2.0.0", "1.5.0", true},
{">= 1.0.0 and < 2.0.0 excludes below", ">= 1.0.0 and < 2.0.0", "0.9.0", false},
{">= 1.0.0 and < 2.0.0 excludes above", ">= 1.0.0 and < 2.0.0", "2.0.0", false},

// Or disjunction
{"~> 1.0 or ~> 2.0 includes first", "~> 1.0 or ~> 2.0", "1.5.0", true},
{"~> 1.0 or ~> 2.0 includes second", "~> 1.0 or ~> 2.0", "2.5.0", true},
{"~> 1.0 or ~> 2.0 excludes above", "~> 1.0 or ~> 2.0", "3.0.0", false},

// Combined and/or with exclusion
{"~> 1.0 and != 1.5.0 includes", "~> 1.0 and != 1.5.0", "1.4.0", true},
{"~> 1.0 and != 1.5.0 excludes version", "~> 1.0 and != 1.5.0", "1.5.0", false},
{"~> 1.0 and != 1.5.0 excludes major", "~> 1.0 and != 1.5.0", "2.0.0", false},
}

parser := NewParser()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r, err := parser.ParseNative(tt.input, "hex")
if err != nil {
t.Fatalf("ParseNative(%q, hex) error = %v", tt.input, err)
}
got := r.Contains(tt.version)
if got != tt.want {
t.Errorf("Contains(%q) = %v, want %v", tt.version, got, tt.want)
}
})
}
}

func TestParseHexElixirAlias(t *testing.T) {
parser := NewParser()
r, err := parser.ParseNative("~> 1.2.3", "elixir")
if err != nil {
t.Fatalf("ParseNative with elixir scheme error = %v", err)
}
if !r.Contains("1.2.3") {
t.Error("elixir scheme should contain 1.2.3")
}
if r.Contains("1.3.0") {
t.Error("elixir scheme should not contain 1.3.0")
}
}

func TestToVersString(t *testing.T) {
parser := NewParser()

Expand Down
Loading