packages feed

NGLess-1.5.0: NGLess/Interpretation/Write.hs

{- Copyright 2013-2022 NGLess Authors
 - License: MIT
 -}

{-# LANGUAGE FlexibleContexts, CPP #-}

module Interpretation.Write
    ( executeWrite
    , moveOrCopyCompress
    , WriteOptions(..)
#ifdef IS_BUILDING_TEST
    , _formatFQOname
#endif
    ) where



import qualified Data.ByteString as B
import qualified Data.ByteString.Char8 as B8
import qualified Data.Text as T
import qualified Data.Vector as V
import qualified Conduit as C
import qualified Data.Conduit.List as CL
import qualified Data.Conduit.Binary as CB
import qualified Data.Conduit.Combinators as CC
import qualified Data.Conduit.Combinators as C
import           Data.Conduit.Algorithms.Async (conduitPossiblyCompressedFile)
import qualified Data.Conduit.Algorithms.Async as CAsync
import           Data.Conduit ((.|))
import           System.Directory (copyFile)
import           Data.Default (Default(..))
import           Data.Maybe
import           Data.String.Utils (replace, endswith)
import           Control.Monad.IO.Unlift (MonadUnliftIO)
import           Control.Monad.Except
import           Control.Monad.Catch (MonadMask)
import           System.IO (Handle, stdout)
import           Data.List (isInfixOf)
import           Control.Concurrent.Async (concurrently_)

import Data.FastQ
import Language
import Configuration
import FileOrStream
import FileManagement (makeNGLTempFile, inferCompression, Compression(..))
import NGLess
import Output
import NGLess.NGLEnvironment
import Utils.Samtools (convertSamToBam, convertBamToSam)
import Utils.Conduit
import Utils.Utils (withOutputFile, fmapMaybeM, moveOrCopy)

{- A few notes:
    There is a transform pass which adds the argument __can_move to write() calls.
    If canMove is True, then we can move the input instead of copying as it
    will no longer be used in the script.

    Decisions on whether to use compression are based on the filenames.

    The filepath "/dev/stdout" is special cased to print to stdout
-}

data WriteOptions = WriteOptions
                { woOFile :: FilePath
                , woFormat :: Maybe T.Text
                , woFormatFlags :: Maybe T.Text
                , woCanMove :: Bool
                , woVerbose :: Bool
                , woComment :: Maybe T.Text
                , woAutoComment :: [AutoComment]
                , woHash :: T.Text
                , woCompressLevel :: Maybe Int
                } deriving (Eq)

instance Default WriteOptions where
    def = WriteOptions
            { woOFile= ""
            , woFormat = Nothing
            , woFormatFlags = Nothing
            , woCanMove = False
            , woVerbose = False
            , woComment = Nothing
            , woAutoComment = []
            , woHash = ""
            , woCompressLevel = Nothing
            }

ostream :: (MonadUnliftIO m, C.MonadResource m) => FilePath -> Maybe Int -> Handle -> C.ConduitT B.ByteString C.Void m ()
ostream fp comp = case inferCompression fp of
    NoCompression -> CB.sinkHandle
    GzipCompression -> maybe CAsync.asyncGzipTo CAsync.asyncGzipTo' comp
    BZ2Compression -> CAsync.asyncBzip2To
    XZCompression -> maybe CAsync.asyncXzTo CAsync.asyncXzTo' comp
    ZStdCompression -> CAsync.asyncZstdTo (fromMaybe 3 comp)

withOutputFile' :: (MonadUnliftIO m, MonadMask m) => FilePath -> (Handle -> m a) -> m a
withOutputFile' "/dev/stdout" = \inner -> inner stdout
withOutputFile' fname = withOutputFile fname

parseWriteOptions :: KwArgsValues -> NGLessIO WriteOptions
parseWriteOptions args = do
    sub <- nConfSubsample <$> nglConfiguration
    let subpostfix = if sub then ".subsampled" else ""
    ofile <- case lookup "ofile" args of
        Just (NGOFilename p) -> return (p ++ subpostfix)
        Just (NGOString p) -> return (T.unpack p ++ subpostfix)
        _ -> throwShouldNotOccur "getOFile cannot decode file path"
    format <- fmapMaybeM (symbolOrTypeError "format argument to write() function") (lookup "format" args)
    canMove <- lookupBoolOrScriptErrorDef (return False) "internal write arg" "__can_move" args
    verbose <- lookupBoolOrScriptErrorDef (return False) "write arg" "verbose" args
    comment <- fmapMaybeM (stringOrTypeError "comment argument to write() function") (lookup "comment" args)
    autoComments <- case lookup "auto_comments" args of
                        Nothing -> return []
                        Just (NGOList cs) -> mapM (\s -> do
                                                        let errmsg = "auto_comments argument in write() call"
                                                        symbolOrTypeError errmsg s >>=
                                                            decodeSymbolOrError errmsg
                                                                [("date", AutoDate)
                                                                ,("script", AutoScript)
                                                                ,("hash", AutoResultHash)]) cs
                        _ -> throwScriptError "auto_comments argument to write() call must be a list of symbols"
    hash <- lookupStringOrScriptError "hidden __hash argument to write() function" "__hash" args
    formatFlags <- case lookup "format_flags" args of
                        Nothing -> return Nothing
                        Just (NGOSymbol flag) -> return $ Just flag
                        Just other -> throwScriptError $ "format_flags argument to write(): illegal argument ("++show other++")"
    compressLevel <- case lookup "compress_level" args of
                        Nothing -> return Nothing
                        Just (NGOInteger level) -> return . Just $ fromEnum level
                        Just other -> throwScriptError $ "compress_level argument to write(): illegal argument ("++show other++")"
    return $! WriteOptions
                { woOFile = ofile
                , woFormat = format
                , woFormatFlags = formatFlags
                , woCanMove = canMove
                , woVerbose = verbose
                , woComment = comment
                , woAutoComment = autoComments
                , woHash = hash
                , woCompressLevel = compressLevel
                }


moveOrCopyCompress :: WriteOptions -> FilePath -> NGLessIO ()
moveOrCopyCompress opts ifile = liftIO =<< moveOrCopyCompress' opts ifile

moveOrCopyCompress' :: WriteOptions -> FilePath -> NGLessIO (IO ())
moveOrCopyCompress' opts ifile
        | ofile == "/dev/stdout" = return (C.runConduitRes $ conduitPossiblyCompressedFile ifile .| C.stdout)
        | ofile == ifile = return (return ()) -- trivial case. Can happen.
#ifdef WINDOWS
        | ocompression == BZ2Compression = throwNotImplementedError "Compression of bzip2 files is not supported on Windows"
        | icompression == BZ2Compression = throwNotImplementedError "Decompression of bzip2 files is not supported on Windows"
#endif
        | icompression == ocompression = moveIfAllowed
        | otherwise = convertCompression
    where
        ofile = woOFile opts
        moveIfAllowed :: NGLessIO (IO ())
        moveIfAllowed = do
                createdFiles <- ngleTemporaryFilesCreated <$> nglEnvironment
                if woCanMove opts && ifile `elem` createdFiles
                    then return (moveOrCopy ifile ofile)
                    else return (copyFile ifile ofile)

        icompression = inferCompression ifile
        ocompression = inferCompression ofile

        convertCompression = return $
            withOutputFile' ofile $ \hout ->
                C.runConduitRes (conduitPossiblyCompressedFile ifile .| ostream ofile (woCompressLevel opts) hout)


removeEnd :: String -> String -> String
removeEnd base suffix = take (length base - length suffix) base

_formatFQOname :: MonadError NGError m => FilePath -> FilePath -> m FilePath
_formatFQOname base insert
    | endswith ".subsampled" base = _formatFQOname (take (length base - length (".subsampled" :: String)) base) (insert ++ ".subsampled")
    | "{index}" `isInfixOf` base = return $ replace "{index}" insert base
    | endswith ".fq" base = return $ removeEnd base ".fq" ++ "." ++ insert ++ ".fq"
    | endswith ".fq.gz" base = return $ removeEnd base ".fq.gz" ++ "." ++ insert ++ ".fq.gz"
    | endswith ".fq.bz2" base = return $ removeEnd base ".fq.bz2" ++ "." ++ insert ++ ".fq.bz2"
    | otherwise = throwScriptError ("Cannot handle filename " ++ base ++ " (expected extension .fq/.fq.gz/.fq.bz2).")



executeWrite :: NGLessObject -> [(T.Text, NGLessObject)] -> NGLessIO NGLessObject
executeWrite (NGOList el) args = do
    templateFP <- woOFile <$> parseWriteOptions args
    let args' = filter (\(a,_) -> (a /= "ofile")) args
        fps = map ((\fname -> replace "{index}" fname templateFP) . show) [1..length el]
    zipWithM_ (\e fp -> executeWrite e (("ofile", NGOFilename fp):args')) el fps
    return (NGOFilename templateFP)

executeWrite (NGOReadSet _ rs) args = do
    opts <- parseWriteOptions args
    let ofile = woOFile opts
        moveOrCopyCompressFQs :: [FastQFilePath] -> FilePath -> NGLessIO (IO ())
        moveOrCopyCompressFQs [] _ = return (return ())
        moveOrCopyCompressFQs [FastQFilePath _ f] ofname = moveOrCopyCompress' (opts {woOFile = ofname}) f
        moveOrCopyCompressFQs multiple ofname = do
            let inputs = fqpathFilePath <$> multiple
            fp' <- makeNGLTempFile (head inputs) "concat" "tmp" $ \h ->
                C.runConduit
                    (mapM_ conduitPossiblyCompressedFile inputs .| C.sinkHandle h)
            moveOrCopyCompress' (opts {woCanMove = True, woOFile = ofname}) fp'
    if woFormatFlags opts == Just "interleaved"
        then
            withOutputFile' ofile $ \hout ->
                C.runConduitRes $
                    interleaveFQs rs .| ostream ofile (woCompressLevel opts) hout
        else case rs of
            ReadSet [] singles ->
                liftIO =<< moveOrCopyCompressFQs singles ofile
            ReadSet pairs [] -> do
                fname1 <- _formatFQOname ofile "pair.1"
                fname2 <- _formatFQOname ofile "pair.2"
                cp1 <- moveOrCopyCompressFQs (fst <$> pairs) fname1
                cp2 <- moveOrCopyCompressFQs (snd <$> pairs) fname2
                liftIO $ cp1 `concurrently_` cp2
            ReadSet pairs singletons -> do
                fname1 <- _formatFQOname ofile "pair.1"
                fname2 <- _formatFQOname ofile "pair.2"
                fname3 <- _formatFQOname ofile "singles"
                cp1 <- moveOrCopyCompressFQs (fst <$> pairs) fname1
                cp2 <- moveOrCopyCompressFQs (snd <$> pairs) fname2
                cp3 <- moveOrCopyCompressFQs singletons fname3
                liftIO $ cp1 `concurrently_` cp2 `concurrently_` cp3
    return (NGOFilename ofile)

executeWrite el@(NGOMappedReadSet _ iout  _) args = do
    opts <- parseWriteOptions args
    fp <- asFile iout
    let guessFormat :: String -> NGLessIO T.Text
        guessFormat "/dev/stdout" = return "sam"
        guessFormat ofile
            | endswith ".sam" ofile = return "sam"
            | endswith ".sam.gz" ofile = return "sam"
            | endswith ".sam.bz2" ofile = return "sam"
            | endswith ".sam.zst" ofile = return "sam"
            | endswith ".sam.zstd" ofile = return "sam"
            | endswith ".bam" ofile = return "bam"
            | otherwise = do
                outputListLno' WarningOutput ["Cannot determine format of MappedReadSet output based on filename ('", ofile, "'). Defaulting to BAM."]
                return "bam"
    orig <- maybe (guessFormat (woOFile opts)) return (woFormat opts) >>= \case
        "sam"
            | endswith ".bam" fp -> convertBamToSam fp
            | otherwise -> return fp
        "bam"
            | endswith ".bam" fp -> return fp -- We already have a BAM, so just copy it
            | otherwise -> convertSamToBam fp
        s -> throwScriptError ("write does not accept format {" ++ T.unpack s ++ "} with input type " ++ show el)
    moveOrCopyCompress opts orig
    return (NGOFilename $ woOFile opts)

executeWrite (NGOCounts iout) args = do
    opts <- parseWriteOptions args
    outputListLno' InfoOutput ["Writing counts to: ", woOFile opts]
    comment <- buildComment (woComment opts) (woAutoComment opts) (woHash opts)
    case fromMaybe "tsv" (woFormat opts) of
        "tsv" -> do
            fp <- asFile iout
            case comment of
                [] -> moveOrCopyCompress opts fp
                _ -> C.runConduit $
                        (commentC "# " comment >> CB.sourceFile fp)
                        .| CB.sinkFileCautious (woOFile opts)
        "csv" -> do
            let (fp,istream) = asStream iout
            comma <- makeNGLTempFile fp "wcomma" "csv" $ \ohand ->
                C.runConduit $
                    ((commentC "# " comment .| linesVC 1024) >> istream)
                        .| CL.map (V.map tabToComma)
                        .| CC.concat
                        .| byteLineSinkHandle ohand
            moveOrCopyCompress (opts { woCanMove = True}) comma
        f -> throwScriptError ("Invalid format in write: {"++T.unpack f++"}.\n\tWhen writing counts, only accepted values are {tsv} (TAB separated values; default) or {csv} (COMMA separated values).")
    return (NGOFilename $ woOFile opts)
  where
    tabToComma :: ByteLine -> ByteLine
    tabToComma (ByteLine line) = ByteLine $ B8.map (\case { '\t' -> ','; c -> c }) line

executeWrite (NGOFilename fp) args = do
    opts <- parseWriteOptions args
    moveOrCopyCompress opts fp
    return (NGOFilename $ woOFile opts)

executeWrite (NGOSequenceSet fp) args = do
    opts <- parseWriteOptions args
    moveOrCopyCompress opts fp
    return $ NGOSequenceSet (woOFile opts)

executeWrite v _ = throwShouldNotOccur ("Error: executeWrite of " ++ show v ++ " not implemented yet.")