packages feed

chessIO-0.7.0.0: src/Game/Chess/Internal/ECO.hs

{-# LANGUAGE GADTs           #-}
{-# LANGUAGE RankNTypes      #-}
{-# LANGUAGE TemplateHaskell #-}
module Game.Chess.Internal.ECO where

import           Control.DeepSeq
import           Control.Exception          (Exception (displayException),
                                             throwIO)
import           Control.Monad
import           Control.Monad.IO.Class
import           Data.Bifunctor
import           Data.Binary
import           Data.ByteString.Char8      (ByteString)
import qualified Data.ByteString.Char8      as BS
import qualified Data.ByteString.Lazy.Char8 as Lazy
import           Data.Char
import           Data.Data
import           Data.Either                (fromRight)
import           Data.FileEmbed             (embedFile)
import           Data.Foldable              (fold)
import           Data.Functor
import           Data.HashMap.Strict        (HashMap)
import qualified Data.HashMap.Strict        as HashMap
import           Data.Hashable              (Hashable)
import           Data.Maybe
import           Data.MonoTraversable       (MonoFoldable (ofoldl'))
import           Data.Ord
import           Data.Ratio
import           Data.Text                  (Text)
import qualified Data.Text                  as T
import           Data.Tree                  (foldTree)
import           Data.Typeable              (Typeable)
import           Data.Vector.Binary
import           Data.Vector.Instances
import qualified Data.Vector.Unboxed        as Unboxed
import           Data.Void
import           Data.Word                  (Word8)
import           GHC.Generics               (Generic)
import           Game.Chess
import           Game.Chess.PGN
import           Game.Chess.SAN
import           Instances.TH.Lift
import           Language.Haskell.TH.Syntax (Lift, Q, TExp, liftTyped)
import           Prelude                    hiding (lookup)
import qualified Prelude
import           System.IO
import           Text.Megaparsec
import           Text.Megaparsec.Byte
import qualified Text.Megaparsec.Byte.Lexer as L

-- | A Chess Opening
data Opening = CO {
  coCode      :: !Text
, coName      :: !Text
, coVariation :: !(Maybe Text)
, coPlies     :: !(Unboxed.Vector Ply)
} deriving (Eq, Generic, Lift, Show)

instance Binary Opening
instance Hashable Opening
instance NFData Opening

type FileReader = forall m. MonadIO m => FilePath -> m (Either String [Opening])

-- | Parse an ECO database in .pgn format
ecoPgn :: FileReader
ecoPgn fp = fmap fromPGN' <$> readPGNFile fp

-- | Parse an ECO database in .eco format
scidEco :: FileReader
scidEco fp = first errorBundlePretty . parse scid' fp <$> liftIO (BS.readFile fp)

-- | Encyclopedia of Chess Openings
newtype ECO = ECO { toHashMap :: HashMap Position Opening }
              deriving (Eq, NFData, Hashable, Semigroup, Monoid)

instance Binary ECO where
  put = put . toList
  get = fromList <$> get

embedECO :: FileReader -> FilePath -> Q (TExp ECO)
embedECO load fp = (fmap.fmap) liftTyped (load fp) >>= \case
  Right xs -> [|| fromList $$(xs) ||]
  Left err -> fail err

toList :: ECO -> [Opening]
toList = map snd . HashMap.toList . toHashMap

fromList :: [Opening] -> ECO
fromList = ECO . HashMap.fromList . fmap (\co -> (pos co, co)) where
  pos = ofoldl' unsafeDoPly startpos . coPlies

-- | Convert a PGN database to ECO assuming the ECO, Opening and Variation tags are
-- being used to identify chess openings.
fromPGN :: PGN -> ECO
fromPGN = fromList . fromPGN'

fromPGN' :: PGN -> [Opening]
fromPGN' (PGN games) = map mkCO games where
  mkCO (tags, (_, forest)) = CO { .. } where
    coCode = fromMaybe "" $ Prelude.lookup "ECO" tags
    coName = fromMaybe "" $ Prelude.lookup "Opening" tags
    coVariation = Prelude.lookup "Variation" tags
    coPlies = Unboxed.fromList . head . concatMap (foldTree g) $ forest where
      g a [] = [[pgnPly a]]
      g a xs = (pgnPly a :) <$> fold xs

opening :: Parser Opening
opening = CO <$> lexeme code <*> lexeme var <*> pure Nothing <*> lexeme (plies startpos)

code :: Parser Text
code = p <?> "code" where
  p = f <$> alphaNumChar <*> many digitChar <*> optional alphaNumChar
  f x xs y = let s = x : xs in T.pack . fmap (chr . fromEnum) $ case y of
    Nothing -> s
    Just y' -> s ++ [y']

var :: Parser Text
var = p <?> "string" where
  p = fmap (T.pack . fmap (chr . fromEnum)) $ single quoteChar *> many ch <* single quoteChar
  ch = single backslashChar *> (  single backslashChar $> backslashChar
                              <|> single quoteChar $> quoteChar
                               )
    <|> anySingleBut quoteChar

plies :: Position -> Parser (Unboxed.Vector Ply)
plies = fmap Unboxed.fromList . go where
  go p = eol <|> line where
    eol = lexeme (string "*") $> []
    line = ply >>= \pl -> (pl :) <$> go (unsafeDoPly p pl)
    ply = validateMoveNumber p *> lexeme (relaxedSAN p)
  validateMoveNumber p =
    optional (lexeme $ L.decimal <* space <* many (single periodChar)) >>= \case
      Just n | moveNumber p /= n ->
        fail $ "Invalid move number: " <> show n <> " /= " <> show (moveNumber p)
      _ -> pure ()

-- | A parser for opening databases in SCID .eco format
scid :: Parser ECO
scid = fromList <$> scid'

scid' :: Parser [Opening]
scid' = spaceConsumer *> many opening <* eof

readECOPGNFile :: MonadIO m => FilePath -> m (Either String ECO)
readECOPGNFile = (fmap . fmap) fromList . ecoPgn

readSCIDECOFile :: MonadIO m => FilePath -> m (Either String ECO)
readSCIDECOFile = (fmap . fmap) fromList . scidEco

-- | Retrieve the opening for a particular position
lookup :: Position -> ECO -> Maybe Opening
lookup pos = HashMap.lookup pos . toHashMap

type Parser = Parsec Void ByteString

spaceConsumer :: Parser ()
spaceConsumer = L.space
  space1 (L.skipLineComment "#") (L.skipBlockComment "{" "}")

lexeme :: Parser a -> Parser a
lexeme = L.lexeme spaceConsumer

periodChar, quoteChar, backslashChar :: Word8
periodChar    = fromIntegral $ ord '.'
quoteChar     = fromIntegral $ ord '"'
backslashChar = fromIntegral $ ord '\\'