chessIO-0.8.0.0: src/Game/Chess/PGN.hs
{-# LANGUAGE GADTs #-}
{-# LANGUAGE TemplateHaskell #-}
{-|
Module : Game.Chess.PGN
Description : Portable Game Notation
Copyright : (c) Mario Lang, 2021
License : BSD3
Maintainer : mlang@blind.guru
Stability : experimental
A PGN file consists of a list of games.
Each game consists of a tag list, the outcome, and a forest of rosetrees.
-}
module Game.Chess.PGN (
PGN(..)
, Game(..), cgTags, cgOutcome, cgForest
, Outcome(..), _Win, _Draw, _Undecided
, Annotated(..), annPrefixNAG, annPly, annSuffixNAG
, readPGNFile, gameFromForest, pgnForest
-- * A PGN parser
, pgn
-- * Prettyprinting
, hPutPGN, pgnDoc, RAVOrder, breadthFirst, depthFirst, gameDoc
, weightedForest
) where
import Control.Lens (makeLenses, makePrisms,
makeWrapped)
import Control.Monad (void)
import Control.Monad.IO.Class (MonadIO (..))
import Data.Bifunctor (Bifunctor (first))
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as BS
import Data.Char (chr, ord)
import Data.Foldable (for_)
import Data.Functor (($>))
import Data.Hashable (Hashable(..))
import Data.List (partition, sortOn)
import Data.Maybe (fromJust, isNothing)
import Data.Ord (Down (Down))
import Data.Ratio ((%))
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding as T (decodeUtf8)
import Data.Text.Prettyprint.Doc (Doc,
FusionDepth (Shallow),
Pretty (pretty),
brackets, dquotes,
fillSep, fuse, line,
parens, vsep, (<+>))
import Data.Text.Prettyprint.Doc.Render.Text (hPutDoc)
import Data.Tree (Tree (..), foldTree)
import Data.Void (Void)
import Data.Word (Word8)
import GHC.Generics (Generic)
import Game.Chess.Internal (Color (..), Ply,
Position (color, moveNumber),
fromFEN, startpos,
unsafeDoPly)
import Game.Chess.SAN (relaxedSAN, unsafeToSAN)
import Language.Haskell.TH.Syntax (Lift)
import System.IO (Handle, hPutStrLn)
import Text.Megaparsec (MonadParsec (eof),
Parsec, anySingleBut,
errorBundlePretty, many,
match, oneOf, optional,
parse, single, (<?>),
(<|>))
import Text.Megaparsec.Byte (alphaNumChar, space,
space1, string)
import qualified Text.Megaparsec.Byte.Lexer as L
data Annotated a = Ann {
_annPrefixNAG :: ![Int]
, _annPly :: !a
, _annSuffixNAG :: ![Int]
} deriving (Eq, Functor, Generic, Lift, Show)
instance Applicative Annotated where
pure a = Ann [] a []
Ann pn1 f sn1 <*> Ann pn2 a sn2 = Ann (pn1 <> pn2) (f a) (sn1 <> sn2)
makeLenses ''Annotated
instance Hashable a => Hashable (Annotated a)
data Outcome = Win Color
| Draw
| Undecided
deriving (Eq, Generic, Lift, Show)
instance Hashable Outcome
makePrisms ''Outcome
data Game = CG {
_cgTags :: ![(Text, Text)]
, _cgForest :: ![Tree (Annotated Ply)]
, _cgOutcome :: !Outcome
} deriving (Eq, Generic, Show)
instance Hashable Game where
hashWithSalt s CG { .. } = s
`hashWithSalt` _cgTags
`hashWithSalt` foldTree (hashWithSalt . hash) <$> _cgForest
`hashWithSalt` _cgOutcome
makeLenses ''Game
newtype PGN = PGN [Game] deriving (Eq, Monoid, Semigroup)
makeWrapped ''PGN
gameFromForest :: [(Text, Text)] -> [Tree Ply] -> Outcome -> Game
gameFromForest tags forest _cgOutcome = CG { .. } where
_cgTags = ("Result", r):tags
_cgForest = (fmap . fmap) pure forest
r = case _cgOutcome of
Win White -> "1-0"
Win Black -> "0-1"
Draw -> "1/2-1/2"
Undecided -> "*"
pgnForest :: PGN -> [Tree Ply]
pgnForest (PGN gs) = merge $ concatMap ((fmap . fmap) _annPly . _cgForest) gs where
merge :: Eq a => [Tree a] -> [Tree a]
merge = foldl mergeTree [] where
merge' l r = l { subForest = foldl mergeTree (subForest l) (subForest r) }
mergeTree [] y = [y]
mergeTree (x:xs) y
| rootLabel x == rootLabel y = x `merge'` y : xs
| otherwise = x : xs `mergeTree` y
instance Ord Outcome where
Win _ `compare` Win _ = EQ
Win _ `compare` _ = GT
_ `compare` Win _ = LT
Draw `compare` Draw = EQ
Draw `compare` _ = GT
_ `compare` Draw = LT
Undecided `compare` Undecided = EQ
instance Pretty Outcome where
pretty (Win White) = "1-0"
pretty (Win Black) = "0-1"
pretty Draw = "1/2-1/2"
pretty Undecided = "*"
readPGNFile :: MonadIO m => FilePath -> m (Either String PGN)
readPGNFile fp = liftIO $
first errorBundlePretty . parse pgn fp . stripBOM <$> BS.readFile fp
bom :: ByteString
bom = "\xEF\xBB\xBF"
stripBOM :: ByteString -> ByteString
stripBOM bs
| bom `BS.isPrefixOf` bs = BS.drop (BS.length bom) bs
| otherwise = bs
hPutPGN :: Handle -> RAVOrder (Doc ann) -> PGN -> IO ()
hPutPGN h ro (PGN games) = for_ games $ \g -> do
hPutDoc h $ gameDoc ro g
hPutStrLn h ""
type Parser = Parsec Void ByteString
spaceConsumer :: Parser ()
spaceConsumer = L.space
space1 (L.skipLineComment ";") (L.skipBlockComment "{" "}")
lexeme :: Parser a -> Parser a
lexeme = L.lexeme spaceConsumer
eog :: Parser Outcome
eog = lexeme $ string "1-0" $> Win White
<|> string "0-1" $> Win Black
<|> string "1/2-1/2" $> Draw
<|> string "*" $> Undecided
sym :: Parser Text
sym = lexeme . fmap (T.decodeUtf8 . fst) . match $ do
void alphaNumChar
many $ alphaNumChar <|> oneOf [35,43,45,58,61,95]
periodChar, quoteChar, backslashChar, dollarChar :: Word8
periodChar = fromIntegral $ ord '.'
quoteChar = fromIntegral $ ord '"'
backslashChar = fromIntegral $ ord '\\'
dollarChar = fromIntegral $ ord '$'
lbracketP, rbracketP, lparenP, rparenP :: Parser ()
lbracketP = void . lexeme . single . fromIntegral $ ord '['
rbracketP = void . lexeme . single . fromIntegral $ ord ']'
lparenP = void . lexeme . single . fromIntegral $ ord '('
rparenP = void . lexeme . single . fromIntegral $ ord ')'
nag :: Parser Int
nag = lexeme $ single dollarChar *> L.decimal
<|> string "!!" $> 3
<|> string "??" $> 4
<|> string "!?" $> 5
<|> string "?!" $> 6
<|> string "!" $> 1
<|> string "?" $> 2
tagPair :: Parser (Text, Text)
tagPair = lexeme $ do
lbracketP
k <- sym
v <- str
rbracketP
pure (k, v)
tagList :: Parser [(Text, Text)]
tagList = many tagPair
movetext :: Position -> Parser (Outcome, [Tree (Annotated Ply)])
movetext pos = (,[]) <$> eog <|> main pos where
main p = ply p >>= \(m, n) -> fmap n <$> movetext (unsafeDoPly p m)
var p = ply p >>= \(m, n) -> n <$> (rparenP $> [] <|> var (unsafeDoPly p m))
ply p = do
pnags <- many nag
validateMoveNumber p
m <- lexeme $ relaxedSAN p
snags <- many nag
rav <- concat <$> many (lparenP *> var p)
pure (m, \xs -> Node (Ann pnags m snags) xs:rav)
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 ()
pgn :: Parser PGN
pgn = spaceConsumer *> fmap PGN (many game) <* spaceConsumer <* eof
game :: Parser Game
game = do
_cgTags <- tagList
pos <- case lookup "FEN" _cgTags of
Nothing -> pure startpos
Just fen -> case fromFEN (T.unpack fen) of
Just p -> pure p
Nothing -> fail "Invalid FEN"
(_cgOutcome, _cgForest) <- movetext pos
pure $ CG { .. }
str :: Parser Text
str = 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
type RAVOrder a = ([Tree (Annotated Ply)] -> a) -> [Tree (Annotated Ply)] -> [a]
breadthFirst, depthFirst :: RAVOrder a
breadthFirst _ [] = []
breadthFirst f ts = pure $ f ts
depthFirst f = fmap $ f . pure
pgnDoc :: RAVOrder (Doc ann) -> PGN -> Doc ann
pgnDoc ro (PGN games) = vsep $ gameDoc ro <$> games
gameDoc :: RAVOrder (Doc ann) -> Game -> Doc ann
gameDoc ro CG { .. }
| null _cgTags = moveDoc ro pos (_cgOutcome, _cgForest)
| otherwise = tagsDoc _cgTags <> line <> line <> moveDoc ro pos (_cgOutcome, _cgForest)
where
pos | Just fen <- lookup "FEN" _cgTags = fromJust $ fromFEN (T.unpack fen)
| otherwise = startpos
tagsDoc :: [(Text, Text)] -> Doc ann
tagsDoc = fuse Shallow . vsep . fmap tagpair where
tagpair (k, esc -> v) = brackets $ pretty k <+> dquotes (pretty v)
esc = T.concatMap e where
e '\\' = T.pack "\\\\"
e '"' = T.pack "\\\""
e c = T.singleton c
moveDoc :: RAVOrder (Doc ann) -> Position -> (Outcome, [Tree (Annotated Ply)])
-> Doc ann
moveDoc ro p (o,f) = fillSep (go p True f <> [pretty o]) <> line where
go _ _ [] = []
go pos pmn (t:ts)
| color pos == White || pmn
= pnag <> (mn:san:snag) <> rav <> go pos' (not . null $ rav) (subForest t)
| otherwise
= pnag <> (san:snag) <> rav <> go pos' (not . null $ rav) (subForest t)
where
pl = _annPly . rootLabel $ t
san = pretty $ unsafeToSAN pos pl
pos' = unsafeDoPly pos pl
pnag = prettynag <$> _annPrefixNAG (rootLabel t)
mn = pretty (moveNumber pos) <> if color pos == White then "." else "..."
rav = ro (parens . fillSep . go pos True) ts
snag = prettynag <$> _annSuffixNAG (rootLabel t)
prettynag n = "$" <> pretty n
weightedForest :: PGN -> [Tree (Rational, Ply)]
weightedForest (PGN games) = merge . concatMap rate $ filter ok games where
ok CG { .. } = isNothing (lookup "FEN" _cgTags) && _cgOutcome /= Undecided
rate CG { .. } = f startpos <$> trunk _cgForest where
w c = case _cgOutcome of
Win c' | c == c' -> 1
| otherwise -> -1
Draw -> 1 % 2
Undecided -> 0
f pos (Node a ts') = Node (w (color pos), _annPly a) $
f (unsafeDoPly pos (_annPly a)) <$> ts'
trunk [] = []
trunk (x:_) = [x { subForest = trunk (subForest x)}]
merge [] = []
merge ((Node a ts) : xs) =
sortOn (Down . fst . rootLabel)
$ Node (w, snd a) (merge $ ts ++ concatMap subForest good) : merge bad
where
(good, bad) = partition (eq a . rootLabel) xs where eq x y = snd x == snd y
w = fst a + sum (fst . rootLabel <$> good)