packages feed

webapp-0.6.0: src/Web/App/Path.hs

{-|
Module      : Web.App.Path
Copyright   : (c) Nathaniel Symer, 2015
License     : MIT
Maintainer  : nate@symer.io
Stability   : experimental
Portability : POSIX

Path matching/parsing functionality
-}

{-# LANGUAGE OverloadedStrings #-}

module Web.App.Path
(
  -- * Types
  Path(..),
  -- * Path Constructors
  literal,
  captured,
  regex,
  -- * Path Operations
  isRoot,
  pathMatches,
  pathCaptures,
  -- * Path Components Operations
  splitPathComps,
  joinPathComps,
  isValidPathComps
)
where

import qualified Data.Array as A
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import Text.Regex.PCRE
import Data.List
import Data.String
import Data.Char (isAlphaNum)
import Data.Maybe (isJust)
import Data.Bool (bool)
import Control.Monad (join)

{- TYPES -}

-- TODO: CapturedPath needs to store indeces of captures as an optimization

-- |Describes a matchable path.
data Path = LiteralPath [Text] -- ^ as-is
          | CapturedPath [Text] -- ^ path with named wildcards starting with a colon
          | RegexPath Regex -- ^ path with a regex

instance IsString Path where
  fromString = join getCtor . splitPathComps . T.pack
    where
      hasCapture = isJust . find (T.isPrefixOf ":")
      getCtor = bool LiteralPath CapturedPath . hasCapture

{- PATH CONSTRUCTORS -}

-- |Construct a literal 'Path'.
literal :: Text -> Path
literal = LiteralPath . splitPathComps

-- |Construct a captured 'Path'.
captured :: Text -> Path
captured = CapturedPath . splitPathComps

-- TODO: configure PCRE to use single line mode
-- |Construct a regex 'Path'.
regex :: Text -> Path
regex = RegexPath . makeRegexOpts comp exec . T.encodeUtf8
  where comp = defaultCompOpt
        exec = defaultExecOpt

{- PATH OPERATIONS -}

-- | Returns @True@ if given path is the root.
isRoot :: Path -> Bool
isRoot = flip pathMatches (splitPathComps "/")

-- | Returns @True@ if the given 'Path' matches the given path components.
pathMatches :: Path -- ^ path
            -> [Text] -- ^ pathComps
            -> Bool
pathMatches (RegexPath ex) pin = matchTest ex $ T.encodeUtf8 $ joinPathComps $ delete "/" pin
pathMatches (LiteralPath lpin) pin = lpin == delete "/" pin
pathMatches (CapturedPath cpin) pin
  | (length cpin) /= (length (delete "/" pin)) = False
  | otherwise = all (== True) $ zipWith capEq cpin (delete "/" pin)
    where capEq c p = bool (c == p) True $ T.isPrefixOf ":" c

-- | Returns path captures by comparing @path@ to @pathComps@.
pathCaptures :: Path -- ^ path
             -> [Text] -- ^ pathComps
             -> [(Text, Text)]
pathCaptures (LiteralPath _) _ = []
pathCaptures (RegexPath r) pin = maybe [(T.pack "fuck", T.pack "you")] (readMatches pstr) (matchOnce r pstr)
  where
    pstr = T.encodeUtf8 $ joinPathComps pin
    readMatches bs arr = [(T.pack $ show i, T.decodeUtf8 $ extract (arr A.! i) bs) | i <- A.range $ A.bounds arr]
pathCaptures (CapturedPath cap) pin = f [] cap pin
  where
    f acc [] [] = reverse acc
    f _ [] _ = []
    f _ _ [] = []
    f acc (c:cs) (p:ps) = g $ T.uncons c
      where g (Just (':', xs)) = f ((xs, p):acc) cs ps
            g _ = bool [] (f acc cs ps) $ p == c

{- PATH COMPONENTS OPERATIONS -}

-- | Split a 'Text' into a path components.
splitPathComps :: Text -> [Text]
splitPathComps = filter (not . T.null) . T.split (== '/') . T.takeWhile (/= '?')

-- | Join path components into a 'Text'ual path.
joinPathComps :: [Text] -> Text
joinPathComps = mconcat . (:) "/" . intersperse "/"

-- | Determine if some path components contain only valid characters.
isValidPathComps :: [Text] -> Bool
isValidPathComps = all $ \v -> not (T.null v) && T.all isPathChar v
  where isPathChar c = isAlphaNum c || (c `elem` ("-.~!$&'()*+,;=:@%" :: String))