packages feed

spacecookie-1.1.0.0: src/Network/Gopher/Util/Gophermap.hs

{-|
Module      : Network.Gopher.Util.Gophermap
Stability   : experimental
Portability : POSIX

This module implements a parser for gophermap files.

Example usage:

@
import Network.Gopher.Util.Gophermap
import qualified Data.ByteString as B
import Data.Attoparsec.ByteString

main = do
  file <- B.readFile "gophermap"
  print $ parseOnly parseGophermap file
@


-}

{-# LANGUAGE OverloadedStrings #-}
module Network.Gopher.Util.Gophermap (
    parseGophermap
  , GophermapEntry (..)
  , GophermapFilePath (..)
  , Gophermap
  , gophermapToDirectoryResponse
  ) where

import Prelude hiding (take, takeWhile)

import Network.Gopher.Types

import Control.Applicative ((<|>))
import Data.Attoparsec.ByteString
import Data.Attoparsec.ByteString.Char8 (isDigit_w8)
import Data.ByteString (ByteString (), pack, unpack, isPrefixOf)
import Data.ByteString.Short (fromShort)
import Data.Char (chr)
import Data.Maybe (fromMaybe)
import Data.Word (Word8 ())
import System.OsPath.Posix (PosixPath, (</>), isAbsolute, normalise)
import qualified System.OsString.Posix as Posix
import System.OsString.Internal.Types (PosixString (..))
import Text.Read (readEither)

-- | Given a directory and a Gophermap contained within it,
--   return the corresponding gopher menu response.
gophermapToDirectoryResponse :: PosixPath -> Gophermap -> GopherResponse
gophermapToDirectoryResponse dir entries =
  MenuResponse (map (gophermapEntryToMenuItem dir) entries)

gophermapEntryToMenuItem :: PosixPath -> GophermapEntry -> GopherMenuItem
gophermapEntryToMenuItem dir (GophermapEntry ft desc path host port) =
  Item ft desc (fromMaybe desc (realPath <$> path)) host port
  where realPath p =
          case p of
            GophermapAbsolute p' -> pathToBS p'
            -- TODO: `..` should be resolved textually for linking to the
            -- parent directory (if possible)
            GophermapRelative p' -> pathToBS $ dir </> p'
            GophermapUrl u       -> u
        pathToBS :: PosixString -> ByteString
        pathToBS = fromShort . getPosixString

fileTypeChars :: [Char]
fileTypeChars = "0123456789+TgIih"

-- | Wrapper around 'RawFilePath' to indicate whether it is
--   relative or absolute.
data GophermapFilePath
  = GophermapAbsolute PosixPath -- ^ Absolute path starting with @/@
  | GophermapRelative PosixPath -- ^ Relative path
  | GophermapUrl ByteString     -- ^ URL to another protocol starting with @URL:@
  deriving (Show, Eq)

-- | Take selector 'ByteString' from gophermap and
--   determine its 'GophermapFilePath' type.
--   Relative and absolute paths are 'normalised',
--   URLs passed on as is.
--
--   * Selectors that start with @"URL:"@ are considered
--     an external URL and left as-is.
--   * Absolute paths are identified by 'isAbsolute'.
--   * Everything else is considered a relative path.
--
--   Paths are 'normalise'-d, but not subject to any other
--   processing.
makeGophermapFilePath :: ByteString -> GophermapFilePath
makeGophermapFilePath b
  | "URL:" `isPrefixOf` b = GophermapUrl b
  | isAbsolute normalisedPath = GophermapAbsolute normalisedPath
  | otherwise = GophermapRelative normalisedPath
  where
    normalisedPath = normalise $ Posix.fromBytestring b

-- | A gophermap entry makes all values of a gopher menu item optional except for file type and description. When converting to a 'GopherMenuItem', appropriate default values are used.
data GophermapEntry = GophermapEntry
  GopherFileType ByteString
  (Maybe GophermapFilePath) (Maybe ByteString) (Maybe Integer) -- ^ file type, description, path, server name, port number
  deriving (Show, Eq)

type Gophermap = [GophermapEntry]

-- | Attoparsec 'Parser' for the gophermap file format
parseGophermap :: Parser Gophermap
parseGophermap = many1 parseGophermapLine <* endOfInput

gopherFileTypeChar :: Parser Word8
gopherFileTypeChar = satisfy (inClass fileTypeChars)

parseGophermapLine :: Parser GophermapEntry
parseGophermapLine = emptyGophermapline
  <|> regularGophermapline
  <|> infoGophermapline

infoGophermapline :: Parser GophermapEntry
infoGophermapline = do
  text <- takeWhile1 (notInClass "\t\r\n")
  endOfLineOrInput
  return $ GophermapEntry InfoLine
    text
    Nothing
    Nothing
    Nothing

regularGophermapline :: Parser GophermapEntry
regularGophermapline = do
  fileTypeChar <- gopherFileTypeChar
  text <- itemValue
  _ <- satisfy (inClass "\t")
  pathString <- option Nothing $ Just <$> itemValue
  host <- optionalValue
  port <- optional portValue
  endOfLineOrInput
  return $ GophermapEntry (charToFileType fileTypeChar)
    text
    (makeGophermapFilePath <$> pathString)
    host
    port

emptyGophermapline :: Parser GophermapEntry
emptyGophermapline = do
  endOfLine'
  return emptyInfoLine
    where emptyInfoLine = GophermapEntry InfoLine (pack []) Nothing Nothing Nothing

portValue :: Parser Integer
portValue = do
  digits <- takeWhile1 isDigit_w8
  -- we know digits is just ASCII characters ([0-9])
  case readEither (map (chr . fromIntegral) (unpack digits)) of
    Left e -> fail e
    Right p -> pure p

optionalValue :: Parser (Maybe ByteString)
optionalValue = optional itemValue

optional :: Parser a -> Parser (Maybe a)
optional parser = option Nothing $ do
  _ <- satisfy (inClass "\t")
  Just <$> parser

itemValue :: Parser ByteString
itemValue = takeWhile1 (notInClass "\t\r\n")

endOfLine' :: Parser ()
endOfLine' = (word8 10 >> return ()) <|> (string "\r\n" >> return ())

endOfLineOrInput :: Parser ()
endOfLineOrInput = endOfInput <|> endOfLine'