packages feed

registry-options-0.1.0.0: src/Data/Registry/Options/Lexemes.hs

{-# OPTIONS_GHC -fno-warn-orphans #-}

-- | This module parses strings coming from the command line
--   and tries to classify them as:
--
--     - option names + their associated values
--     - flag names
--     - arguments
--
--    It is however not always possible to know if a given list of string is:
--
--      - an option name + some values: find --files file1 file2
--      - a flag name + some arguments: copy --force source target
--
--    During lexing we leave this last case as "ambiguous".
--    This will be disambiguated during parsing where we know if
--    a given name is an option or a flag.
module Data.Registry.Options.Lexemes where

import Data.List qualified as L
import Data.Map.Strict qualified as Map
import Data.MultiMap (MultiMap)
import Data.MultiMap qualified as M
import Data.Text qualified as T
import Protolude as P
import Prelude (show)

-- | This data type helps pre-parsing option names and values
data Lexemes = Lexemes
  { -- | list of option names and associated values
    lexedOptions :: MultiMap Text Text,
    -- | list of flag names
    lexedFlags :: [Text],
    -- | list of argument values
    lexedArguments :: [Text],
    -- | possible ambiguous case: option + values or flag + arguments
    lexedAmbiguous :: Maybe (Text, [Text])
  }
  deriving (Eq, Show)

instance Semigroup Lexemes where
  (<>) = union

instance Monoid Lexemes where
  mempty = Lexemes M.empty mempty mempty Nothing
  mappend = (<>)

-- | Concatenate 2 lists of lexemes
union :: Lexemes -> Lexemes -> Lexemes
union (Lexemes m1 fs1 as1 am1) (Lexemes m2 fs2 as2 am2) =
  Lexemes
    (M.fromList $ M.toList m1 <> M.toList m2)
    (fs1 <> fs2)
    (as1 <> as2)
    (am1 <|> am2)

-- | Override the values from one Lexemes with the values from another
--   This is a bit tricky since ambiguous option/flags coming from the command can eventually
--   be detected to be valid options / flags when parsed as such in the environment or in a config file
override :: Lexemes -> Lexemes -> Lexemes
override (Lexemes m1 fs1 as1 am1) (Lexemes m2 fs2 as2 am2) =
  Lexemes
    mergeOptions
    (mergeMax fs1 fs2)
    (as1 <> as2)
    mergeAmbiguous
  where
    -- merge 2 lists so that every unique element of each list is present
    -- if there are duplicates in one list or the other, the max number of duplicates is kept
    mergeMax :: [Text] -> [Text] -> [Text]
    mergeMax vs1 vs2 = do
      let g1 = groupByEq vs1
      let g2 = groupByEq vs2
      join . Map.elems $ Map.unionWith (\v1 v2 -> if length v1 >= length v2 then v1 else v2) g1 g2

    groupByEq :: Ord a => [a] -> Map a [a]
    groupByEq = M.toMap . M.fromList . fmap (\a -> (a, a))

    mergeOptions = do
      let allOptions = M.fromMap . Map.fromList $ M.assocs m1 <> M.assocs m2
      case (am1, am2) of
        -- no ambiguous options
        (Nothing, Nothing) -> allOptions
        (Just _, Nothing) -> allOptions
        (_, Just (t2, v2)) ->
          if t2 `elem` M.keys allOptions then M.fromMap $ Map.fromList (M.assocs allOptions <> [(t2, v2)]) else allOptions

    mergeAmbiguous =
      case (am1, am2) of
        (Nothing, Nothing) -> Nothing
        (Just _, Just (t2, vs2)) -> Just (t2, vs2)
        (Just (t1, vs1), Nothing) ->
          if t1 `elem` M.keys m2 then Nothing else Just (t1, vs1)
        (Nothing, Just (t2, vs2)) ->
          if t2 `elem` M.keys m1 then Nothing else Just (t2, vs2)

-- * Create lexemes

-- | Lex some input arguments
--   They are first stripped of additional whitespace
--   and empty strings are removed (there shouldn't be any though, coming from the command line)
lexArgs :: [Text] -> Lexemes
lexArgs = mkLexemes . filter (not . T.null) . fmap T.strip

-- | Lex some input arguments
mkLexemes :: [Text] -> Lexemes
mkLexemes [] = mempty
mkLexemes ("--" : rest) = argsLexemes rest
mkLexemes [t] =
  -- this is either a single flag or an argument
  if isDashed t
    then -- if there is an = sign this an option

      if "=" `T.isInfixOf` t
        then makeEqualOptionLexeme t
        else makeFlagsLexeme t
    else argLexemes (dropDashed t)
mkLexemes (t : rest) =
  -- if we get an option name
  if isDashed t
    then -- if the option value is appended directly to the option name

      if "=" `T.isInfixOf` t
        then makeEqualOptionLexeme t <> mkLexemes rest
        else -- otherwise
        do
          let key = dropDashed t
          let (vs, others) = L.break isDashed rest
          -- if there are no values after the option name, we have a flag
          if null vs
            then makeFlagsLexeme t <> mkLexemes others
            else -- otherwise

            -- if there are additional options/flags, then we collect values for the
            -- current option and make lexemes for the rest

              if any isDashed others
                then optionsLexemes key vs <> mkLexemes others
                else -- this case is ambiguous, possibly the values are repeated values for an option
                -- or the option is a flag with no values and all the rest are arguments
                  ambiguousLexemes key rest
    else argLexemes t <> mkLexemes rest

-- | Create lexemes for an option name + an option value
optionLexemes :: Text -> Text -> Lexemes
optionLexemes k = optionsLexemes k . pure

-- | Create lexemes for an option name + a list of option values
optionsLexemes :: Text -> [Text] -> Lexemes
optionsLexemes k vs = Lexemes (M.fromList ((k,) <$> vs)) mempty mempty Nothing

-- | Create an option for --option=value or -o=value
--   Return mempty if no equal sign is present
makeEqualOptionLexeme :: Text -> Lexemes
makeEqualOptionLexeme t = do
  case T.splitOn "=" (dropDashed t) of
    [optionName, optionValue] -> optionLexemes optionName optionValue
    -- this case should not happen
    _ -> mempty

-- | Create lexemes for a list of potentially short flag names
--   e.g. makeFlagsLexeme "-opq" === flagsLexemes ["o", "p", "q"]
makeFlagsLexeme :: Text -> Lexemes
makeFlagsLexeme t =
  ( if isSingleDashed t
      then -- split the letters
        flagsLexemes . fmap T.singleton . T.unpack
      else flagLexemes
  )
    (dropDashed t)

-- | Create lexemes for a flag name
flagLexemes :: Text -> Lexemes
flagLexemes = flagsLexemes . pure

-- | Create lexemes for a list of flag names
flagsLexemes :: [Text] -> Lexemes
flagsLexemes fs = Lexemes M.empty fs mempty Nothing

-- | Create lexemes for an argument value
argLexemes :: Text -> Lexemes
argLexemes = argsLexemes . pure

-- | Create lexemes for several arguments
argsLexemes :: [Text] -> Lexemes
argsLexemes ts = Lexemes M.empty mempty ts Nothing

-- | Create lexemes an ambiguous flag an its values
--   Later parsing will indicate if the name is an option names and the values the option values
--   or if this is a flag + arguments
ambiguousLexemes :: Text -> [Text] -> Lexemes
ambiguousLexemes t ts = Lexemes M.empty mempty mempty (Just (t, ts))

-- | Return the possible list of argument values to parse from
--   Note that there can be ambiguous flags
getArguments :: Lexemes -> [Text]
getArguments (Lexemes _ _ as Nothing) = as
getArguments (Lexemes _ _ as1 (Just (_, as2))) = as1 <> as2

-- | Return option/flag names from lexed values
getFlagNames :: Lexemes -> [Text]
getFlagNames (Lexemes m fs _ am) = M.keys m <> fs <> (fst <$> toList am)

-- | Return a value for a given name
--   This can be a value associated to a given option
--   or just a flag name acting as a value to decode
--   (the value can also come from an ambiguous option value)
getValue :: Text -> Lexemes -> Maybe (Maybe Text)
getValue key (Lexemes options flags _ ambiguous) =
  case headMay (M.lookup key options) of
    Just v -> Just (Just v)
    Nothing ->
      case find (== key) flags of
        Just _ -> Just Nothing
        Nothing -> Just <$> getAmbiguousValue ambiguous
  where
    getAmbiguousValue Nothing = Nothing
    getAmbiguousValue (Just (k, vs)) =
      if k == key
        then headMay vs
        else Nothing

-- | Remove the value associated to an option name
--   The value might be:
--     - associated to an option name
--     - the name of a flag
--     - associated to an ambiguous flag name
popOptionValue :: Text -> Lexemes -> Lexemes
popOptionValue key ls =
  ls
    { lexedOptions = pop key $ lexedOptions ls,
      lexedFlags = filter (/= key) $ lexedFlags ls,
      lexedAmbiguous = case lexedAmbiguous ls of
        Just (k, []) | k == key -> Nothing
        Just (k, _ : as) | k == key -> Just (k, as)
        other -> other
    }

-- | Remove an argument value
--   first from the list of arguments if there are some`
--   otherwise remove a value in the list of values associated to an ambiguous flag
popArgumentValue :: Lexemes -> Lexemes
popArgumentValue ls =
  case lexedArguments ls of
    (_ : as) -> ls {lexedArguments = as}
    [] ->
      ls
        { lexedAmbiguous = case lexedAmbiguous ls of
            Nothing -> Nothing
            Just (_, []) -> Nothing
            Just (k, _ : as) -> Just (k, as)
        }

-- | Remove a flag
--   If the flag is actually an ambiguous flag with some associated values then
--   this means that those values were arguments and need to be treated as such
popFlag :: Text -> Lexemes -> Lexemes
popFlag f ls = do
  let (before, after) = L.break (== f) $ lexedFlags ls
  let (args, amb) =
        case lexedAmbiguous ls of
          Just (k, vs) | f == k -> (vs <> lexedArguments ls, Nothing)
          other -> (lexedArguments ls, other)

  ls
    { lexedFlags = before <> drop 1 after,
      lexedArguments = args,
      lexedAmbiguous = amb
    }

-- | Return True if some text starts with `-`
isDashed :: Text -> Bool
isDashed = T.isPrefixOf "-"

-- | Return True if some text starts with `-` but not with `--`
isSingleDashed :: Text -> Bool
isSingleDashed t = T.isPrefixOf "-" t && not (T.isPrefixOf "-" (T.drop 1 t))

-- | Drop dashes in front of a flag name
dropDashed :: Text -> Text
dropDashed = T.dropWhile (== '-')

-- * MultiMap functions

instance (Show k, Show v) => Show (MultiMap k v) where
  show = P.show . M.assocs

instance (Eq k, Eq v) => Eq (MultiMap k v) where
  m1 == m2 = M.assocs m1 == M.assocs m2

-- | Drop the first value associated to a key in the map
--   If a key has no more values drop the key
pop :: (Ord k) => k -> MultiMap k v -> MultiMap k v
pop key m =
  M.fromMap $ Map.fromList $ filter (not . null . snd) $ (\(k, vs) -> if k == key then (k, drop 1 vs) else (k, vs)) <$> M.assocs m