oglyad-0.1.1.0: src/Oglyad/Version.hs
-- | RPM version handling: EVR parsing and the rpmvercmp algorithm
--
-- reference implementation: rpmio/rpmvercmp.c in rpm-software-management/rpm
-- test vectors: tests/rpmvercmp.at in the same repo
module Oglyad.Version
( EVR (..),
parseEVR,
renderEVR,
rpmvercmp,
compareEVR,
normalizeVersion,
compareUpstream,
)
where
import Data.Aeson (FromJSON, ToJSON)
import Data.Char (isAlphaNum, isAsciiLower, isAsciiUpper, isDigit)
import Data.List (find)
import Data.Text (Text)
import Data.Text qualified as T
import GHC.Generics (Generic)
import Text.Read (readMaybe)
-- | epoch:version-release
--
-- note: 'Eq' is structural but 'Ord' goes through rpmvercmp, so "1.05" and
-- "1.5" compare EQ while being /=. build-lag relies on the structural Eq on
-- purpose, since any textual difference between git and the repo is worth
-- reporting
data EVR = EVR
{ evrEpoch :: !Int,
evrVersion :: !Text,
evrRelease :: !Text
}
deriving stock (Eq, Show, Generic)
deriving anyclass (ToJSON, FromJSON)
instance Ord EVR where
compare = compareEVR
-- | parse "[epoch:]version-release". a missing epoch or "(none)" means 0
parseEVR :: Text -> Maybe EVR
parseEVR raw = do
let (epochPart, rest) = case T.breakOn ":" raw of
(vr, "") -> ("0", vr)
(e, r) -> (e, T.drop 1 r)
epoch <- if epochPart == "(none)" then Just 0 else readMaybe (T.unpack epochPart)
-- release is everything after the *last* dash, versions can't contain dashes
let (verDash, rel) = T.breakOnEnd "-" rest
ver <- T.stripSuffix "-" verDash
if T.null ver || T.null rel then Nothing else Just (EVR epoch ver rel)
renderEVR :: EVR -> Text
renderEVR (EVR 0 v r) = v <> "-" <> r
renderEVR (EVR e v r) = T.pack (show e) <> ":" <> v <> "-" <> r
compareEVR :: EVR -> EVR -> Ordering
compareEVR (EVR e1 v1 r1) (EVR e2 v2 r2) =
compare e1 e2 <> rpmvercmp v1 v2 <> rpmvercmp r1 r2
-- | compare two version (or release) strings exactly the way rpm does.
-- this is a line-by-line port of rpmvercmp.c
--
-- * anything that isn't an ascii letter, digit, '~' or '^' is a separator
-- * '~' sorts before everything, even the end of the string (1.0~rc1 < 1.0)
-- * '^' sorts after the end of the string but before anything else
-- (1.0 < 1.0^git1 < 1.0.1)
-- * otherwise compare segment by segment, where a segment is a run of
-- digits or a run of letters. numbers beat letters, numbers compare
-- numerically, letters compare lexically
-- * when one side runs out, the side with stuff left is newer
rpmvercmp :: Text -> Text -> Ordering
rpmvercmp a b
| a == b = EQ
| otherwise = go a b
where
go one0 two0
| T.null one0 && T.null two0 = EQ
| otherwise =
let one = T.dropWhile isSep one0
two = T.dropWhile isSep two0
in case (T.uncons one, T.uncons two) of
-- tilde: whoever has it is older
(Just ('~', one'), Just ('~', two')) -> go one' two'
(Just ('~', _), _) -> LT
(_, Just ('~', _)) -> GT
-- caret: older than anything except the end of the string
(Just ('^', one'), Just ('^', two')) -> go one' two'
(Nothing, Just ('^', _)) -> LT
(Just ('^', _), Nothing) -> GT
(Just ('^', _), Just _) -> LT
(Just _, Just ('^', _)) -> GT
-- one side ran out
(Nothing, Nothing) -> EQ
(Just _, Nothing) -> GT
(Nothing, Just _) -> LT
(Just (c, _), Just _)
| isDigit c -> segment True (T.span isDigit one) (T.span isDigit two)
| otherwise -> segment False (T.span isAlpha one) (T.span isAlpha two)
segment isNum (seg1, rest1) (seg2, rest2)
-- the other side has a different kind of segment here.
-- numeric beats alpha
| T.null seg2 = if isNum then GT else LT
| isNum =
let n1 = T.dropWhile (== '0') seg1
n2 = T.dropWhile (== '0') seg2
in compare (T.length n1) (T.length n2) <> compare n1 n2 <> go rest1 rest2
| otherwise = compare seg1 seg2 <> go rest1 rest2
isAlpha c = isAsciiLower c || isAsciiUpper c
isSep c = not (isDigit c || isAlpha c || c == '~' || c == '^')
-- | turn an upstream tag (or a sloppy spec Version) into something
-- rpmvercmp can compare. starts from what anda's rpm.version() does (trim,
-- drop a leading v, dashes become dots) and adds:
--
-- * monorepo tags like "pkg/v1.2" keep only the part after the last slash
-- * non-numeric prefixes go, so "release-1.2" and "jq-1.7" line up
-- * prerelease markers become tildes like Fedora spells them,
-- so "6.0.0-alpha1" becomes "6.0.0~alpha1"
normalizeVersion :: Text -> Text
normalizeVersion raw =
let t = T.takeWhileEnd (/= '/') (T.strip raw)
noV = maybe t id (T.stripPrefix "v" t)
fromDigit = case T.dropWhile (not . isDigit) noV of
"" -> noV
d -> d
in T.replace "-" "." (tildePre fromDigit)
-- | "-rc1" / ".beta" / "_alpha2" → "~rc1" / "~beta" / "~alpha2"
tildePre :: Text -> Text
tildePre t = case T.uncons t of
Nothing -> ""
Just (c, rest)
| c `elem` ("-._" :: String), Just _ <- marker rest -> T.cons '~' (tildePre rest)
| otherwise -> T.cons c (tildePre rest)
where
marker r = find (`T.isPrefixOf` T.toLower r) ["alpha", "beta", "rc", "pre", "dev"]
-- | compare an upstream version with a spec Version, forgiving the usual
-- ways packagers respell a version. returns how upstream compares to the spec
--
-- * same letters and digits, different punctuation is the same version
-- (0.4.2 vs 0.4~2, 2026.07.27 vs 20260727)
-- * the same leading dotted number with junk after it is the same version
-- (2.005R vs 2.005, 3.17.0-ogc1 vs 3.17.0^1), unless one side is a
-- prerelease, since 6.0.0 vs 6.0.0~alpha1 is real drift
-- * otherwise rpmvercmp decides
compareUpstream :: Text -> Text -> Ordering
compareUpstream up spec
| squash u == squash s = EQ
| not (T.null (core u)) && core u == core s && not (isPre u || isPre s) = EQ
| otherwise = rpmvercmp u s
where
u = normalizeVersion up
s = normalizeVersion spec
squash = T.toLower . T.filter isAlphaNum
core = T.dropWhileEnd (== '.') . T.takeWhile (\c -> isDigit c || c == '.')
-- a prerelease is never "the same version with junk after it"
isPre v = "~" `T.isPrefixOf` T.drop (T.length (core v)) (T.dropWhileEnd (== '.') v)