sequence-formats-1.11.0.1: src/SequenceFormats/VCF.hs
{-# LANGUAGE OverloadedStrings #-}
{-| A module to help with parsing VCF files. The VCF format is defined here:
<https://en.wikipedia.org/wiki/Variant_Call_Format>
-}
module SequenceFormats.VCF (VCFheader(..),
VCFentry(..),
vcfHeaderParser,
readVCFfromStdIn,
readVCFfromFile,
getGenotypes,
getDosages,
isTransversionSnp,
vcfToFreqSumEntry,
isBiallelicSnp,
printVCFtoStdOut,
writeVCFfile) where
import SequenceFormats.FreqSum (FreqSumEntry (..))
import SequenceFormats.Utils (Chrom (..),
SeqFormatException (..),
consumeProducer,
deflateFinaliser,
gzipConsumer,
readFileProdCheckCompress,
word, writeFromPopper)
import Control.Applicative ((<|>))
import Control.Error (atErr)
import Control.Monad (forM, unless, void)
import Control.Monad.Catch (MonadThrow, throwM)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.State.Strict (runStateT)
import qualified Data.Attoparsec.ByteString.Char8 as A
import qualified Data.ByteString.Char8 as B
import Data.List (isSuffixOf)
import Data.Maybe (fromMaybe)
import qualified Data.Streaming.Zlib as Z
import Pipes (Consumer, Producer, (>->))
import Pipes.Attoparsec (parse)
import qualified Pipes.ByteString as PB
import qualified Pipes.Prelude as P
import Pipes.Safe (MonadSafe, register)
import qualified Pipes.Safe.Prelude as PS
import System.IO (IOMode (..))
-- |A datatype to represent the VCF Header. Most comments are simply parsed as entire lines, but the very last comment line, containing the sample names, is separated out
data VCFheader = VCFheader {
vcfHeaderComments :: [B.ByteString], -- ^A list of containing all comments starting with a single '#'
vcfSampleNames :: [B.ByteString] -- ^The list of sample names parsed from the last comment line
-- starting with '##'
} deriving (Show, Eq)
-- |A Datatype representing a single VCF entry.
data VCFentry = VCFentry {
vcfChrom :: Chrom, -- ^The chromosome
vcfPos :: Int, -- ^The position
vcfId :: Maybe B.ByteString, -- ^The SNP ID if non-missing
vcfRef :: B.ByteString, -- ^ The reference allele (supports also multi-character alleles for Indels)
vcfAlt :: [B.ByteString], -- ^The alternative alleles, each one possible of multiple characters
vcfQual :: Maybe Double, -- ^The quality value
vcfFilter :: Maybe B.ByteString, -- ^The Filter value, if non-missing.
vcfInfo :: [B.ByteString], -- ^A list of Info fields
vcfGenotypeInfo :: Maybe ([B.ByteString], [[B.ByteString]]) -- ^An optional tuple of format tags and genotype format fields for each sample.
} deriving (Show, Eq)
-- |reads a VCFheader and VCFentries from a text producer.
readVCFfromProd :: (MonadThrow m) =>
Producer B.ByteString m () -> m (VCFheader, Producer VCFentry m ())
readVCFfromProd prod = do
(res, rest) <- runStateT (parse vcfHeaderParser) prod
header <- case res of
Nothing -> throwM $ SeqFormatException "VCF file exhausted prematurely"
Just (Left e) -> throwM (SeqFormatException (show e))
Just (Right h) -> return h
return (header, consumeProducer vcfEntryParser rest)
-- |Reading a VCF from StdIn. Returns a VCFHeader and a Producer over VCFentries.
readVCFfromStdIn :: (MonadIO m, MonadThrow m) => m (VCFheader, Producer VCFentry m ())
readVCFfromStdIn = readVCFfromProd PB.stdin
-- |Reading a VCF from a file. Returns a VCFHeader and a Producer over VCFentries.
readVCFfromFile :: (MonadSafe m) => FilePath -> m (VCFheader, Producer VCFentry m ())
readVCFfromFile = readVCFfromProd . readFileProdCheckCompress
vcfHeaderParser :: A.Parser VCFheader
vcfHeaderParser = VCFheader <$> A.many1' doubleCommentLine <*> (headerLineWithSamples <|> headerLineNoSamples)
where
doubleCommentLine = do
c1 <- A.string "##"
s_ <- A.takeWhile1 (/='\n')
A.endOfLine
return $ c1 <> s_
headerLineWithSamples = do
void $ A.string "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\t"
sampleNames <- word `A.sepBy1'` A.char '\t'
A.endOfLine
return sampleNames
headerLineNoSamples = A.string "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\n" *> pure []
vcfEntryParser :: A.Parser VCFentry
vcfEntryParser = vcfEntryParserFull <|> vcfEntryParserTruncated
where
vcfEntryParserFull = VCFentry <$> (Chrom <$> word) <* sp <*> A.decimal <* sp <*> parseId <*
sp <*> word <* sp <*> parseAlternativeAlleles <* sp <*> parseQual <* sp <*> parseFilter <*
sp <*> parseInfoFields <* sp <*> parseFormatStringsAndGenotypes <* A.endOfLine
vcfEntryParserTruncated = VCFentry <$> (Chrom <$> word) <* sp <*> A.decimal <* sp <*> parseId <*
sp <*> word <* sp <*> parseAlternativeAlleles <* sp <*> parseQual <* sp <*> parseFilter <*
sp <*> parseInfoFields <*> pure Nothing <* A.endOfLine
sp = A.satisfy (\c -> c == '\t')
parseId = (parseDot *> pure Nothing) <|> (Just <$> word)
parseDot = A.char '.'
parseAlternativeAlleles = (parseDot *> pure []) <|> (parseAllele `A.sepBy1` A.char ',')
parseAllele = A.takeTill (\c -> c == ',' || c == '\t')
parseQual = (parseDot *> pure Nothing) <|> (Just <$> A.double)
parseFilter = (parseDot *> pure Nothing) <|> (Just <$> word)
parseInfoFields = (parseDot *> pure []) <|> (parseInfoField `A.sepBy1` A.char ';')
parseInfoField = A.takeTill (\c -> c == ';' || c == '\t')
parseFormatStringsAndGenotypes = (\f g -> Just (f, g)) <$> parseFormatStrings <* sp <*> parseGenotypeInfos
parseFormatStrings = parseFormatString `A.sepBy1` A.char ':'
parseFormatString = A.takeTill (\c -> c == ':' || c == '\t')
parseGenotypeInfos = parseGenotype `A.sepBy1` sp
parseGenotype = parseGenoField `A.sepBy1` A.char ':'
parseGenoField = A.takeTill (\c -> c == ':' || c == '\t' || c == '\n')
-- |returns True if the SNP is biallelic.
isBiallelicSnp :: B.ByteString -> [B.ByteString] -> Bool
isBiallelicSnp ref alt = validRef && validAlt
where
validRef = (ref `elem` ["A", "C", "G", "T"])
validAlt = case alt of
[alt'] -> alt' `elem` ["A", "C", "G", "T"]
_ -> False
-- |returns True if the SNp is a biallelic Transversion SNP (i.e. one of G/T, G/C, A/T, A/C)
isTransversionSnp :: B.ByteString -> [B.ByteString] -> Bool
isTransversionSnp ref alt =
case alt of
[alt'] -> isBiallelicSnp ref alt && (not $ isTransition ref alt')
_ -> False
where
isTransition r a = ((r == "A") && (a == "G")) || ((r == "G") && (a == "A")) ||
((r == "C") && (a == "T")) || ((r == "T") && (a == "C"))
-- |Extracts the genotype fields (for each sapmle) from a VCF entry
getGenotypes :: (MonadThrow m) => VCFentry -> m [B.ByteString]
getGenotypes vcfEntry = case vcfGenotypeInfo vcfEntry of
Nothing -> throwM $ SeqFormatException "No Genotypes in this VCF"
Just (formatField, genotypeFields) -> do
gtIndex <- case filter ((=="GT") . snd) . zip [0..] $ formatField of
[] -> throwM $ SeqFormatException "GT format field not found"
[i] -> return . fst $ i
_ -> throwM $ SeqFormatException "Multiple GT fields specified in VCF format field"
forM genotypeFields $ \indInfo ->
case atErr ("cannot find genotype from " ++ show indInfo) indInfo gtIndex of
Left e -> throwM . SeqFormatException $ e
Right g -> return g
-- |Extracts the dosages (the sum of non-reference alleles) and ploidies per sample
getDosages :: (MonadThrow m) => VCFentry -> m [Maybe (Int, Int)]
getDosages vcfEntry = do
genotypes <- getGenotypes vcfEntry
return $ do
gen <- genotypes
case B.splitWith (\c -> c == '|' || c == '/') gen of
["0"] -> return $ Just (0, 1)
["1"] -> return $ Just (1, 1)
["0", "0"] -> return $ Just (0, 2)
["0", "1"] -> return $ Just (1, 2)
["1", "0"] -> return $ Just (1, 2)
["1", "1"] -> return $ Just (2, 2)
_ -> return Nothing
-- |Converts a VCFentry to the simpler FreqSum format
vcfToFreqSumEntry :: (MonadThrow m) => VCFentry -> m FreqSumEntry
vcfToFreqSumEntry vcfEntry = do
unless (B.length (vcfRef vcfEntry) == 1) . throwM . SeqFormatException $
"multi-site reference allele at " ++ show vcfEntry
alt <- case vcfAlt vcfEntry of
[] -> return 'N'
(a:_) -> if B.length a /= 1
then
throwM . SeqFormatException $ "multi-site alternative allele at " ++ show vcfEntry
else
return $ B.head a
let ref = B.head (vcfRef vcfEntry)
dosages <- getDosages vcfEntry
return $ FreqSumEntry (vcfChrom vcfEntry) (vcfPos vcfEntry) (vcfId vcfEntry) Nothing ref alt dosages
printVCFtoStdOut :: (MonadIO m) => VCFheader -> Consumer VCFentry m ()
printVCFtoStdOut vcfh = do
liftIO . B.putStr . vcfHeaderToText $ vcfh
P.map vcfEntryToText >-> PB.stdout
vcfHeaderToText :: VCFheader -> B.ByteString
vcfHeaderToText (VCFheader comments names) =
let commentsBlock = B.intercalate "\n" comments
namesLine = case names of
[] -> "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO"
_ -> "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\t" <> (B.intercalate "\t" names)
in commentsBlock <> "\n" <> namesLine <> "\n"
vcfEntryToText :: VCFentry -> B.ByteString
vcfEntryToText e =
let baseFieldList = [
unChrom . vcfChrom $ e,
B.pack . show . vcfPos $ e,
fromMaybe "." . vcfId $ e,
vcfRef e,
if null (vcfAlt e) then "." else B.intercalate "," . vcfAlt $ e,
maybe "." (B.pack . show) . vcfQual $ e,
fromMaybe "." . vcfFilter $ e,
if null (vcfInfo e) then "." else B.intercalate ";" . vcfInfo $ e]
genotypeFieldList = case vcfGenotypeInfo e of
Nothing -> []
Just (f, gs) -> [B.intercalate ":" f] ++ map (B.intercalate ":") gs
in (<> "\n") . B.intercalate "\t" $ baseFieldList ++ genotypeFieldList
writeVCFfile :: (MonadSafe m) => FilePath -> VCFheader -> Consumer VCFentry m ()
writeVCFfile vcfFile vcfh = do
(_, vcfFileH) <- lift $ PS.openFile vcfFile WriteMode
vcfOutConsumer <- if ".gz" `isSuffixOf` vcfFile then do
def <- liftIO $ Z.initDeflate 6 (Z.WindowBits 31)
_ <- register (deflateFinaliser def vcfFileH)
pop <- liftIO (Z.feedDeflate def (vcfHeaderToText vcfh))
liftIO (writeFromPopper pop vcfFileH)
return $ gzipConsumer def vcfFileH
else do
liftIO $ B.hPut vcfFileH (vcfHeaderToText vcfh)
return $ PB.toHandle vcfFileH
P.map vcfEntryToText >-> vcfOutConsumer