packages feed

porcupine-core-0.1.0.0: src/Data/Locations/Loc.hs

{-# LANGUAGE DeriveAnyClass        #-}
{-# LANGUAGE DeriveDataTypeable    #-}
{-# LANGUAGE DeriveFoldable        #-}
{-# LANGUAGE DeriveFunctor         #-}
{-# LANGUAGE DeriveGeneric         #-}
{-# LANGUAGE DeriveTraversable     #-}
{-# LANGUAGE FlexibleContexts      #-}
{-# LANGUAGE FlexibleInstances     #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE NamedFieldPuns        #-}
{-# LANGUAGE RecordWildCards       #-}
{-# LANGUAGE StaticPointers        #-}
{-# LANGUAGE TemplateHaskell       #-}
{-# LANGUAGE TypeSynonymInstances  #-}
{-# OPTIONS_GHC -Wall #-}

module Data.Locations.Loc where

import           Control.Applicative
import           Control.Funflow.ContentHashable
import           Control.Lens
import           Control.Monad                   (foldM)
import           Data.Aeson
import           Data.Binary                     (Binary)
import           Data.Char                       (toLower)
import qualified Data.HashMap.Strict             as HM
import           Data.Locations.LocVariable
import           Data.Representable
import           Data.Store                      (Store)
import           Data.String
import qualified Data.Text                       as T
import           Data.Typeable
import           GHC.Generics                    (Generic)
import qualified Network.URL                     as URL
import qualified System.Directory                as Dir (createDirectoryIfMissing)
import qualified System.FilePath                 as Path


-- | Each location bit can be a simple chunk of string, or a variable name
-- waiting to be spliced in.
data StringOrVariable
  = SoV_String FilePath  -- ^ A raw filepath part, to be used as is
  | SoV_Variable LocVariable -- ^ A variable name
  deriving (Eq, Generic, ToJSON, FromJSON, Store)

instance Show StringOrVariable where
  show (SoV_String s)                = s
  show (SoV_Variable (LocVariable v)) = "{" ++ v ++ "}"

locBitContent :: Lens' StringOrVariable String
locBitContent f (SoV_String p) = SoV_String <$> f p
locBitContent f (SoV_Variable (LocVariable v)) = SoV_Variable . LocVariable <$> f v

-- | A newtype so that we can redefine the Show instance
newtype StringWithVars = StringWithVars [StringOrVariable]
  deriving (Generic, Store)

instance Semigroup StringWithVars where
  StringWithVars l1 <> StringWithVars l2 = StringWithVars $ concatSoV_Strings $ l1++l2
instance Monoid StringWithVars where
  mempty = StringWithVars []

instance Show StringWithVars where
  show (StringWithVars l) = concatMap show l

-- | Get all the variable names still in the loc string and possibly replace
-- them.
locStringVariables :: Traversal' StringWithVars StringOrVariable
locStringVariables f (StringWithVars bits) = StringWithVars . concatSoV_Strings <$> traverse f' bits
  where f' c@SoV_String{}  = pure c
        f' c@SoV_Variable{} = f c

-- | Ensures 2 consecutive chunks are concatenated together
concatSoV_Strings :: [StringOrVariable] -> [StringOrVariable]
concatSoV_Strings (SoV_String p1 : SoV_String p2 : rest) =
  concatSoV_Strings (SoV_String (p1++p2) : rest)
concatSoV_Strings (x : rest) = x : concatSoV_Strings rest
concatSoV_Strings [] = []

data PathWithExtension a = PathWithExtension { _pathWithoutExt :: a, _pathExtension :: String }
  deriving (Eq, Ord, Generic, ToJSON, FromJSON, Functor, Foldable, Traversable, Binary, Store)

instance (Monad m, ContentHashable m a) => ContentHashable m (PathWithExtension a)

makeLenses ''PathWithExtension

firstNonEmptyExt :: String -> String -> String
firstNonEmptyExt "" b = b
firstNonEmptyExt a _  = a

instance (Semigroup a) => Semigroup (PathWithExtension a) where
  -- Concats the filepaths /without considering extension/ and then chooses one
  -- non-empty extension, right-biased.
  PathWithExtension p e <> PathWithExtension p' e' =
    PathWithExtension (p<>p') $ firstNonEmptyExt e' e
instance (Monoid a) => Monoid (PathWithExtension a) where
  mempty = PathWithExtension mempty ""

-- | Turns the 'PathWithExtension' to/from a simple string to be used as is.
pathWithExtensionAsRawFilePath :: (IsLocString a) => Iso' (PathWithExtension a) FilePath
pathWithExtensionAsRawFilePath = iso to_ from_
  where
    to_ (PathWithExtension p e) = case e of
      "" -> p'
      _  -> p'++"."++e
      where p' = p ^. locStringAsRawString
    from_ fp = let (p,e) = splitExtension' fp
               in PathWithExtension (p ^. from locStringAsRawString) e

instance (IsLocString a) => IsString (PathWithExtension a) where
  fromString p = p ^. from pathWithExtensionAsRawFilePath

instance (IsLocString a) => Show (PathWithExtension a) where
  show p = fmap (view locStringAsRawString) p ^. pathWithExtensionAsRawFilePath

data QParam a = QParam a a
  deriving (Eq, Ord, Generic, Functor, Foldable, Traversable, Binary, Store)

instance (Monad m, ContentHashable m a) => ContentHashable m (QParam a)

instance (IsLocString a) => Show (QParam a) where
  show = show . view (from asQParam)

asQParam :: (IsLocString a) => Iso' (String,String) (QParam a)
asQParam = iso to_ from_
  where
    to_ (x,y) = QParam (x ^. from locStringAsRawString) (y ^. from locStringAsRawString)
    from_ (QParam x y) = (x ^. locStringAsRawString, y ^. locStringAsRawString)

-- | Location's main type. A value of type 'URL' denotes a file or a
-- folder that may be local or hosted remotely
data URL a
  = LocalFile { filePath :: PathWithExtension a }
  | RemoteFile { rfProtocol    :: String
               , rfServerName  :: String
               , rfPortNumber  :: Maybe Integer
               , rfPathWithExtension :: PathWithExtension a
               , rfLocParams   :: [QParam a] }
  deriving ( Eq, Ord, Generic
           , Functor, Foldable, Traversable, Binary, Store )

instance (Monad m, Typeable a, ContentHashable m a) => ContentHashable m (URL a)

instance (IsLocString a) => Show (URL a) where
  show LocalFile{ filePath } = show filePath
  show RemoteFile{ rfProtocol, rfServerName, rfPathWithExtension, rfPortNumber, rfLocParams } =
    rfProtocol ++ "://" ++ rfServerName ++ port ++ "/" ++ show rfPathWithExtension ++ qs
    where
      port = case rfPortNumber of
        Nothing -> ""
        Just p  -> ":" <> show p
      qs = case rfLocParams of
        [] -> ""
        _  -> "?" ++ URL.exportParams (map (view (from asQParam)) rfLocParams)

urlPathWithExtension :: Lens' (URL a) (PathWithExtension a)
urlPathWithExtension f (LocalFile fp)                  = LocalFile <$> f fp
urlPathWithExtension f RemoteFile{rfPathWithExtension=fp,..} =
  (\fp' -> RemoteFile{rfPathWithExtension=fp',..}) <$> f fp

-- | A 'URL' that might contain some named holes, called variables, that we
-- have first to replace by a value before we can get a definite physical
-- location.
type LocWithVars = URL StringWithVars

-- | A 'URL' that can directly be accessed as is.
type Loc = URL String

type LocalFilePath = PathWithExtension String

-- | Creates a 'Loc' from a simple litteral string
localFile :: FilePath -> Loc
localFile s = LocalFile $ s ^. from pathWithExtensionAsRawFilePath

-- | Creates a 'LocWithVars' that will only contain a chunk, no variables
locWithVarsFromLoc :: (Functor f) => f String -> f StringWithVars
locWithVarsFromLoc = fmap (StringWithVars . (:[]) . SoV_String)

-- | A map that can be used to splice variables in a 'LocWithVars'
type LocVariableMap = HM.HashMap LocVariable String

-- | Splices in the variables present in the hashmap
spliceLocVariables :: (Functor f) => LocVariableMap -> f StringWithVars -> f StringWithVars
spliceLocVariables vars = fmap $ over locStringVariables $ \v -> case v of
  SoV_Variable vname ->
    case HM.lookup vname vars of
      Just val -> SoV_String val
      Nothing  -> v
  _ -> error "spliceLocVariables: Should not happen"

-- | Yields @Left _@ if any of the given StringWithVars contains variables.
terminateLocWithVars :: (Traversable f) => f StringWithVars -> Either String (f String)
terminateLocWithVars = traverse terminateStringWithVars
  where
    terminateStringWithVars (StringWithVars [SoV_String s]) = Right s
    terminateStringWithVars locString = Left $
      "Variable(s) " ++ show (locString ^.. locStringVariables)
      ++ " in '" ++ show locString ++ "' haven't been given a value"

-- | Means that @a@ can represent file paths
class (Monoid a) => IsLocString a where
  locStringAsRawString :: Iso' a String
  parseLocString       :: String -> Either String a

parseLocStringAndExt :: (IsLocString a) => String -> Either String (PathWithExtension a)
parseLocStringAndExt s =
  PathWithExtension <$> parseLocString p <*> refuseVarRefs "extension" e
    where (p, e) = splitExtension' s

splitExtension' :: FilePath -> (FilePath, String)
splitExtension' fp = let (f,e) = Path.splitExtension fp in
  case e of '.':e' -> (f,e')
            _      -> (f,e)

instance IsLocString String where
  locStringAsRawString = id
  parseLocString = Right

parseStringWithVars :: String -> Either String StringWithVars
parseStringWithVars s = (StringWithVars . reverse . map (over locBitContent reverse) . filter isFull)
                   <$> foldM oneChar [] s
  where
    oneChar (SoV_Variable _ : _) '{' = Left "Cannot nest {...}"
    oneChar acc '{' = return $ SoV_Variable (LocVariable "") : acc
    oneChar (SoV_String _ : _) '}' = Left "'}' terminates nothing"
    oneChar acc '}' = return $ SoV_String "" : acc
    oneChar (hd : rest) c = return $ over locBitContent (c:) hd : rest
    oneChar [] c = return [SoV_String [c]]

    isFull (SoV_String "") = False
    isFull _               = True

-- | @refuseVarRefs p s == Right s@ if `s` contains no variables.
-- Otherwise, yields an error message.
refuseVarRefs :: String -> String -> Either String String
refuseVarRefs place s = do
  l <- parseStringWithVars s
  case l of
    (StringWithVars []) -> return ""
    (StringWithVars [SoV_String p]) -> return p
    _ -> Left $ "Variable references {...} are not allowed in the " ++ place ++ " part of a URL"

instance IsLocString StringWithVars where
  locStringAsRawString = iso show from_
    where from_ s = StringWithVars [SoV_String s]
  parseLocString = parseStringWithVars

-- | The main way to parse an 'URL'. Variables are not allowed in the protocol
-- and server parts.
parseURL :: (IsLocString a) => String -> Either String (URL a)
parseURL "." = Right $ LocalFile $ PathWithExtension ("." ^. from locStringAsRawString) ""
parseURL litteralPath = do
  url <- maybe (Left $ "parseURL: Invalid URL '" ++ litteralPath ++ "'") Right $
             URL.importURL litteralPath
  case URL.url_type url of
    URL.Absolute h ->
       RemoteFile <$> (refuseVarRefs "protocol" $ getProtocol $ URL.protocol h)
                  <*> (refuseVarRefs "server" $ URL.host h)
                  <*> (Right $ URL.port h)
                  <*> (parseLocStringAndExt $ URL.url_path url)
                  <*> (map (uncurry QParam) <$>
                         mapMOf (traversed.both) parseLocString (URL.url_params url))
    URL.HostRelative -> LocalFile <$> (parseLocStringAndExt $ "/" ++ URL.url_path url)
    URL.PathRelative -> LocalFile <$> (parseLocStringAndExt $ URL.url_path url)
  where getProtocol (URL.RawProt h)  = map toLower h
        getProtocol (URL.HTTP False) = "http"
        getProtocol (URL.HTTP True)  = "https"
        getProtocol (URL.FTP False)  = "ftp"
        getProtocol (URL.FTP True)   = "ftps"

instance (IsLocString a) => IsString (URL a) where
  fromString s = case parseURL s of
    Right l -> l
    Left e  -> error e

instance (IsLocString a) => Representable (PathWithExtension a) where
  toTextRepr = T.pack . show
  fromTextRepr x = case parseLocStringAndExt $ T.unpack x of
    Left _   -> empty
    Right x' -> pure x'

instance (IsLocString a) => Representable (URL a) where
  toTextRepr = T.pack . show
  fromTextRepr x = case parseURL $ T.unpack x of
    Left _   -> empty
    Right x' -> pure x'

instance (IsLocString a) => FromJSON (URL a) where
  parseJSON (String j) = fromTextRepr j
  parseJSON _          = fail "URL must be read from a JSON String"

instance (IsLocString a) => ToJSON (URL a) where
  toJSON = String . toTextRepr

-- | The equivalent of </> from `filepath` package on 'PathWithExtension's
appendToPathWithExtensionAsSubdir :: (IsLocString a) => PathWithExtension a -> String -> PathWithExtension a
fp `appendToPathWithExtensionAsSubdir` s = view (from pathWithExtensionAsRawFilePath) $
    (fp^.pathWithExtensionAsRawFilePath) Path.</> s

-- | Appends a path to a location. The Loc is considered to be a folder, so its
-- possible extension will be /ignored/.
(</>) :: (IsLocString a) => URL a -> String -> URL a
f </> p = f & over urlPathWithExtension (`appendToPathWithExtensionAsSubdir` p)
infixl 4 </>

-- | Alias for '</>'
(<//>) :: (IsLocString a) => URL a -> String -> URL a
(<//>) = (</>)
infixl 4 <//>

-- | Replaces a Loc extension
(-<.>) :: Loc -> String -> Loc
f -<.> ext = f & urlPathWithExtension . pathExtension .~ ext
infixl 3 -<.>

-- | Initialises a directory from a Loc to it, so that we can safely write in it
-- afterwards. For a local filesystem, this means creating it.
initDir :: Loc -> IO ()
initDir f@LocalFile{} =
  Dir.createDirectoryIfMissing True $ f ^. urlPathWithExtension . pathWithoutExt
initDir _ = pure ()

-- | Analog to 'Path.takeDirectory' for generalized locations
takeDirectory :: Loc -> Loc
takeDirectory = over (urlPathWithExtension . pathWithoutExt) Path.takeDirectory . dropExtension

-- | Analog of 'Path.dropExtension'
dropExtension :: URL a -> URL a
dropExtension f = f & urlPathWithExtension . pathExtension .~ ""

-- | The class of all locations that can be mapped to VirtualFiles in a
-- configuration file.
class (Traversable f
      -- Just ensure that `forall a. (IsLocString a) => (FromJSON (f a),
      -- ToJSON (f a))`:
      ,FromJSON (f String), FromJSON (f StringWithVars)
      ,ToJSON (f String), ToJSON (f StringWithVars)
      -- `forall a. (IsLocString a) => (Show (f a))`:
      ,Show (f String), Show (f StringWithVars)) => TypedLocation f where

  -- TODO: Find a way to replace get/setLocType by a Lens. This displeased
  -- GeneralizedNewtypeDeriving when making LocationAccessor and trying to
  -- automatically derived instances of TypedLocation
  getLocType :: f a -> String

  -- | Access the file type part of a location
  --
  -- For locations that encode types as extensions, this would access the
  -- extension. But for others (like HTTP urls), locType would probably need to
  -- translate it first to a mime type, or some other implementation-specific
  -- way to to represent resource types
  setLocType :: f a -> (String -> String) -> f a

  -- | Use the location as a directory and append a "subdir" to it. Depending on
  -- the implementation this "subdir" relationship can only be semantic (the
  -- result doesn't have to physically be a subdirectory of the input)
  --
  -- Note: this isn't a path, the subdir shouldn't contain any slashes
  addSubdirToLoc :: (IsLocString a) => f a -> String -> f a

  -- | Apply a mapping shortcut (represented as a partial file path, with its
  -- extension) to the location. For now, non URL-based locations should send an
  -- error
  --
  -- Note: contrary to 'addSubdirToLoc', the 'PathWithExtension' MAY contain
  -- slashes
  useLocAsPrefix :: (IsLocString a) => f a -> PathWithExtension a -> f a

instance TypedLocation URL where
  setLocType l f = l & over (urlPathWithExtension . pathExtension) f
  getLocType = view (urlPathWithExtension . pathExtension)
  addSubdirToLoc = (</>)
  useLocAsPrefix l p = l & over urlPathWithExtension (<> p)

-- | Sets the file type of a location
overrideLocType :: (TypedLocation f) => f a -> String -> f a
overrideLocType loc newExt = setLocType loc (newExt `firstNonEmptyExt`)

-- | Sets the file type of a location unless it already has one
setLocTypeIfMissing :: (TypedLocation f) => f a -> String -> f a
setLocTypeIfMissing loc newExt = setLocType loc (`firstNonEmptyExt` newExt)