-- SPDX-FileCopyrightText: 2026 Alexandra de Wit
--
-- SPDX-License-Identifier: MIT

{- | The lexical atoms and the length bound the per-ecosystem version grammars share.

A 'VToken' is a single numeric or textual run, ordered by the rule the RubyGems and PEP
440-local grammars have in common. The semver prerelease rule is the opposite, so it lives
with the semver grammar. Everything here is purely lexical.
-}
module Ecluse.Core.Version.Token (
    VToken (..),
    parseNumSeg,
    numOr0,
    isAsciiAlphaNum,
    digitRuns,
    classifyRun,
    withinVersionLength,
) where

import Data.Char (isAsciiLower, isAsciiUpper, isDigit)
import Data.Text qualified as T

import Ecluse.Core.Text (readDecimalText)

{- | A version token: a numeric or a textual run. Numeric tokens outrank textual ones,
numerics compare numerically, and text compares lexically.
-}
data VToken = VNum Integer | VStr Text
    deriving stock (VToken -> VToken -> Bool
(VToken -> VToken -> Bool)
-> (VToken -> VToken -> Bool) -> Eq VToken
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: VToken -> VToken -> Bool
== :: VToken -> VToken -> Bool
$c/= :: VToken -> VToken -> Bool
/= :: VToken -> VToken -> Bool
Eq, Int -> VToken -> ShowS
[VToken] -> ShowS
VToken -> String
(Int -> VToken -> ShowS)
-> (VToken -> String) -> ([VToken] -> ShowS) -> Show VToken
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> VToken -> ShowS
showsPrec :: Int -> VToken -> ShowS
$cshow :: VToken -> String
show :: VToken -> String
$cshowList :: [VToken] -> ShowS
showList :: [VToken] -> ShowS
Show, (forall x. VToken -> Rep VToken x)
-> (forall x. Rep VToken x -> VToken) -> Generic VToken
forall x. Rep VToken x -> VToken
forall x. VToken -> Rep VToken x
forall a.
(forall x. a -> Rep a x) -> (forall x. Rep a x -> a) -> Generic a
$cfrom :: forall x. VToken -> Rep VToken x
from :: forall x. VToken -> Rep VToken x
$cto :: forall x. Rep VToken x -> VToken
to :: forall x. Rep VToken x -> VToken
Generic)

instance NFData VToken

instance Ord VToken where
    compare :: VToken -> VToken -> Ordering
compare (VNum Integer
m) (VNum Integer
n) = Integer -> Integer -> Ordering
forall a. Ord a => a -> a -> Ordering
compare Integer
m Integer
n
    compare (VStr Text
s) (VStr Text
t) = Text -> Text -> Ordering
forall a. Ord a => a -> a -> Ordering
compare Text
s Text
t
    compare (VNum Integer
_) (VStr Text
_) = Ordering
GT
    compare (VStr Text
_) (VNum Integer
_) = Ordering
LT

-- The bound caps the quadratic cost of reading a digit run into an 'Integer', which hostile
-- registry metadata could otherwise turn into an algorithmic-complexity DoS.
maxVersionLength :: Int
maxVersionLength :: Int
maxVersionLength = Int
1024

{- | Whether a raw version string is short enough for the grammars to parse. Past the bound a
version gets no ordering key and is served raw.
-}
withinVersionLength :: Text -> Bool
withinVersionLength :: Text -> Bool
withinVersionLength Text
raw = Text -> Int -> Ordering
T.compareLength Text
raw Int
maxVersionLength Ordering -> Ordering -> Bool
forall a. Eq a => a -> a -> Bool
/= Ordering
GT

-- | Parse a non-empty, all-digit segment as an integer.
parseNumSeg :: Text -> Maybe Integer
parseNumSeg :: Text -> Maybe Integer
parseNumSeg = Text -> Maybe Integer
forall a. Integral a => Text -> Maybe a
readDecimalText

-- | Read an all-digit (already validated) run as an integer, defaulting to 0.
numOr0 :: Text -> Integer
numOr0 :: Text -> Integer
numOr0 = Integer -> Maybe Integer -> Integer
forall a. a -> Maybe a -> a
fromMaybe Integer
0 (Maybe Integer -> Integer)
-> (Text -> Maybe Integer) -> Text -> Integer
forall b c a. (b -> c) -> (a -> b) -> a -> c
. Text -> Maybe Integer
forall a. Integral a => Text -> Maybe a
readDecimalText

{- | An ASCII letter or ASCII digit. The PEP 440 and @Gem::Version@ grammars are ASCII-only,
so the Unicode-aware 'Data.Char.isAlphaNum' would over-accept and mis-order a non-ASCII digit.
-}
isAsciiAlphaNum :: Char -> Bool
isAsciiAlphaNum :: Char -> Bool
isAsciiAlphaNum Char
c = Char -> Bool
isAsciiUpper Char
c Bool -> Bool -> Bool
|| Char -> Bool
isAsciiLower Char
c Bool -> Bool -> Bool
|| Char -> Bool
isDigit Char
c

-- | Split text into its maximal digit and non-digit runs.
digitRuns :: Text -> [Text]
digitRuns :: Text -> [Text]
digitRuns = (Char -> Char -> Bool) -> Text -> [Text]
T.groupBy (\Char
c1 Char
c2 -> Char -> Bool
isDigit Char
c1 Bool -> Bool -> Bool
forall a. Eq a => a -> a -> Bool
== Char -> Bool
isDigit Char
c2)

-- | Classify one run: all-digit reads as a 'VNum', anything else stays a 'VStr'.
classifyRun :: Text -> VToken
classifyRun :: Text -> VToken
classifyRun Text
run = if (Char -> Bool) -> Text -> Bool
T.all Char -> Bool
isDigit Text
run then Integer -> VToken
VNum (Text -> Integer
numOr0 Text
run) else Text -> VToken
VStr Text
run