packages feed

semver-0.4.0: src/Data/SemVer/Constraint.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
-- Module      : Data.SemVer.Constraint
-- Copyright   : (c) 2020 Brendan Hay <brendan.g.hay@gmail.com>, Keagan McClelland <keagan.mcclelland@gmail.com>
-- License     : This Source Code Form is subject to the terms of
--               the Mozilla Public License, v. 2.0.
--               A copy of the MPL can be found in the LICENSE file or
--               you can obtain it at http://mozilla.org/MPL/2.0/.
-- Maintainer  : Brendan Hay <brendan.g.hay@gmail.com>
-- Stability   : experimental
-- Portability : non-portable (GHC extensions)

-- | An implementation of the Semantic Versioning Constraints.
-- In absence of a standard around constraints, the behavior of node-semver is closely followed.
-- The behavior is outlined here: https://github.com/npm/node-semver#ranges
module Data.SemVer.Constraint
    ( Constraint(..)
    , satisfies
    , fromText
    )
where

import           Control.Applicative
import           Data.Attoparsec.Text
import           Data.SemVer.Internal
import qualified Data.SemVer.Delimited         as DL
import           Data.Text                      ( Text )

data Constraint
    = CAny
    | CLt !Version
    | CLtEq !Version
    | CGt !Version
    | CGtEq !Version
    | CEq !Version
    | CAnd !Constraint !Constraint
    | COr !Constraint !Constraint
    deriving (Eq, Show)

-- | Checks whether the 'Version' satisfies the 'Constraint'
--
-- Note: Semantics of this are strange in the presence of pre-release identifiers. Without a proper standard for how
-- constraint satisfaction should behave, this implementation attempts to follow the behavior of node-semver
-- which can be found here: https://github.com/npm/node-semver#prerelease-tags.
--
-- This choice was made because node-semver is the most widely deployed implementation of semantic versioning with
-- the best documentation around how to treat pre-release identifiers.
--
-- The summary is that you must opt into using pre-release identifiers by specifying them in the constraint
-- and they __must__ match the __exact__ version that attempts to use a pre-release identifier.
satisfies :: Version -> Constraint -> Bool
satisfies version constraint = if containsPrerelease version
    then if not . null . filter ((triple version ==) . triple . snd) $ (prereleaseComparators constraint)
        then go version constraint
        else if constraint == CAny then True else False
    else go version constraint
  where
    triple :: Version -> (Int, Int, Int)
    triple = liftA3 (,,) _versionMajor _versionMinor _versionPatch
    containsPrerelease :: Version -> Bool
    containsPrerelease v = not . null . _versionRelease $ v
    -- this helps us gather the comparators that actually consented to prerelease versions
    prereleaseComparators :: Constraint -> [(Version -> Constraint, Version)]
    prereleaseComparators = \case
        CAny     -> []
        CLt   v  -> if containsPrerelease v then [(CLt, v)] else []
        CLtEq v  -> if containsPrerelease v then [(CLtEq, v)] else []
        CGt   v  -> if containsPrerelease v then [(CGt, v)] else []
        CGtEq v  -> if containsPrerelease v then [(CGtEq, v)] else []
        CEq   v  -> if containsPrerelease v then [(CEq, v)] else []
        CAnd a b -> prereleaseComparators a <> prereleaseComparators b
        COr  a b -> prereleaseComparators a <> prereleaseComparators b
    -- naive satisfaction checking
    go :: Version -> Constraint -> Bool
    go v c = case c of
        CAny     -> True
        CLt   vc -> v < vc
        CLtEq vc -> v <= vc
        CGt   vc -> v > vc
        CGtEq vc -> v >= vc
        CEq   vc -> v == vc
        CAnd a b -> go v a && go v b
        COr  a b -> go v a || go v b

-- | Parsing function to create a 'Constraint' from 'Text' according to the rules specified
-- here: https://github.com/npm/node-semver#ranges
--
-- Advanced syntax is not yet supported.
fromText :: Text -> Either String Constraint
fromText = parseOnly parser

parser :: Parser Constraint
parser = parserD DL.semantic

parserD :: Delimiters -> Parser Constraint
parserD d@Delimiters {..} = choice . fmap (<* endOfInput) $ [primP, andP, orP]
  where
    primP = choice
        [ char '*' *> pure CAny
        , char '<' *> (CLt <$> DL.parser d False)
        , string "<=" *> (CLtEq <$> DL.parser d False)
        , char '>' *> (CGt <$> DL.parser d False)
        , string ">=" *> (CGtEq <$> DL.parser d False)
        , CEq <$> ((option '=' $ char '=') *> DL.parser d False)
        ]
    andP = liftA2 CAnd primP (skipSpace *> (andP <|> primP))
    orP = liftA2 COr (andP <|> primP) (skipSpace *> string "||" *> skipSpace *> (orP <|> andP <|> primP))