;lib: csv: refactor: clarify, get rid of some IO/ExceptT

Rule parsing doesn't need IO (since we are doing expandIncludes as a
pre-parsing step).
This commit is contained in:
Simon Michael 2019-09-14 02:04:00 -07:00
parent 3c05662ce2
commit f92b2fe6ef

View File

@ -35,11 +35,12 @@ import "base-compat-batteries" Prelude.Compat hiding (fail)
import qualified "base-compat-batteries" Control.Monad.Fail.Compat as Fail (fail) import qualified "base-compat-batteries" Control.Monad.Fail.Compat as Fail (fail)
import Control.Exception (IOException, handle, throw) import Control.Exception (IOException, handle, throw)
import Control.Monad (liftM, unless, when) import Control.Monad (liftM, unless, when)
import Control.Monad.Except (ExceptT(ExceptT) , runExceptT, throwError) import Control.Monad.Except (ExceptT, throwError)
import Control.Monad.IO.Class (liftIO) import Control.Monad.IO.Class (liftIO)
import Control.Monad.State.Strict (StateT, get, modify', evalStateT) import Control.Monad.State.Strict (StateT, get, modify', evalStateT)
import Control.Monad.Trans.Class (lift) import Control.Monad.Trans.Class (lift)
import Data.Char (toLower, isDigit, isSpace, ord) import Data.Char (toLower, isDigit, isSpace, ord)
import Data.Bifunctor (first)
import "base-compat-batteries" Data.List.Compat import "base-compat-batteries" Data.List.Compat
import Data.Maybe import Data.Maybe
import Data.Ord import Data.Ord
@ -93,7 +94,7 @@ parse iopts f t = do
let rulesfile = mrules_file_ iopts let rulesfile = mrules_file_ iopts
let separator = separator_ iopts let separator = separator_ iopts
r <- liftIO $ readJournalFromCsv separator rulesfile f t r <- liftIO $ readJournalFromCsv separator rulesfile f t
case r of Left e -> throwError e case r of Left e -> throwError e
Right j -> return $ journalNumberAndTieTransactions j Right j -> return $ journalNumberAndTieTransactions j
-- XXX does not use parseAndFinaliseJournal like the other readers -- XXX does not use parseAndFinaliseJournal like the other readers
@ -111,32 +112,35 @@ parse iopts f t = do
readJournalFromCsv :: Char -> Maybe FilePath -> FilePath -> Text -> IO (Either String Journal) readJournalFromCsv :: Char -> Maybe FilePath -> FilePath -> Text -> IO (Either String Journal)
readJournalFromCsv _ Nothing "-" _ = return $ Left "please use --rules-file when reading CSV from stdin" readJournalFromCsv _ Nothing "-" _ = return $ Left "please use --rules-file when reading CSV from stdin"
readJournalFromCsv separator mrulesfile csvfile csvdata = readJournalFromCsv separator mrulesfile csvfile csvdata =
handle (\e -> return $ Left $ show (e :: IOException)) $ do handle (\(e::IOException) -> return $ Left $ show e) $ do
-- make and throw an IO exception.. which we catch and convert to an Either above ?
let throwerr = throw . userError let throwerr = throw . userError
-- parse rules -- parse the csv rules
let rulesfile = fromMaybe (rulesFileFor csvfile) mrulesfile let rulesfile = fromMaybe (rulesFileFor csvfile) mrulesfile
rulesfileexists <- doesFileExist rulesfile rulesfileexists <- doesFileExist rulesfile
rulestext <- rulestext <-
if rulesfileexists if rulesfileexists
then do then do
dbg1IO "using conversion rules file" rulesfile dbg1IO "using conversion rules file" rulesfile
liftIO $ (readFilePortably rulesfile >>= expandIncludes (takeDirectory rulesfile)) readFilePortably rulesfile >>= expandIncludes (takeDirectory rulesfile)
else return $ defaultRulesText rulesfile else
rules <- liftIO (runExceptT $ parseAndValidateCsvRules rulesfile rulestext) >>= either throwerr return return $ defaultRulesText rulesfile
rules <- either throwerr return $ parseAndValidateCsvRules rulesfile rulestext
dbg2IO "rules" rules dbg2IO "rules" rules
-- apply skip directive -- parse the skip directive's value, if any
let skip = maybe 0 oneorerror $ getDirective "skip" rules let skiplines = case getDirective "skip" rules of
where Nothing -> 0
oneorerror "" = 1 Just "" -> 1
oneorerror s = readDef (throwerr $ "could not parse skip value: " ++ show s) s Just s -> readDef (throwerr $ "could not parse skip value: " ++ show s) s
-- parse csv -- parse csv
-- parsec seems to fail if you pass it "-" here XXX try again with megaparsec -- parsec seems to fail if you pass it "-" here TODO: try again with megaparsec
let parsecfilename = if csvfile == "-" then "(stdin)" else csvfile let parsecfilename = if csvfile == "-" then "(stdin)" else csvfile
records <- (either throwerr id . records <- (either throwerr id .
dbg2 "validateCsv" . validateCsv skip . dbg2 "validateCsv" . validateCsv skiplines .
dbg2 "parseCsv") dbg2 "parseCsv")
`fmap` parseCsv separator parsecfilename csvdata `fmap` parseCsv separator parsecfilename csvdata
dbg1IO "first 3 csv records" $ take 3 records dbg1IO "first 3 csv records" $ take 3 records
@ -402,16 +406,19 @@ getDirective directivename = lookup directivename . rdirectives
instance ShowErrorComponent String where instance ShowErrorComponent String where
showErrorComponent = id showErrorComponent = id
-- | An error-throwing action that parses this file's content -- Not used by hledger; just for lib users,
-- | An pure-exception-throwing IO action that parses this file's content
-- as CSV conversion rules, interpolating any included files first, -- as CSV conversion rules, interpolating any included files first,
-- and runs some extra validation checks. -- and runs some extra validation checks.
parseRulesFile :: FilePath -> ExceptT String IO CsvRules parseRulesFile :: FilePath -> ExceptT String IO CsvRules
parseRulesFile f = parseRulesFile f =
liftIO (readFilePortably f >>= expandIncludes (takeDirectory f)) >>= parseAndValidateCsvRules f liftIO (readFilePortably f >>= expandIncludes (takeDirectory f))
>>= either throwError return . parseAndValidateCsvRules f
-- | Inline all files referenced by include directives in this hledger CSV rules text, recursively. -- | Inline all files referenced by include directives in this hledger CSV rules text, recursively.
-- Included file paths may be relative to the directory of the provided file path. -- Included file paths may be relative to the directory of the provided file path.
-- This is a cheap hack to avoid rewriting the CSV rules parser. -- This is done as a pre-parse step to simplify the CSV rules parser.
expandIncludes :: FilePath -> Text -> IO Text
expandIncludes dir content = mapM (expandLine dir) (T.lines content) >>= return . T.unlines expandIncludes dir content = mapM (expandLine dir) (T.lines content) >>= return . T.unlines
where where
expandLine dir line = expandLine dir line =
@ -422,22 +429,17 @@ expandIncludes dir content = mapM (expandLine dir) (T.lines content) >>= return
dir' = takeDirectory f' dir' = takeDirectory f'
_ -> return line _ -> return line
-- | An error-throwing action that parses this text as CSV conversion rules -- | An error-throwing IO action that parses this text as CSV conversion rules
-- and runs some extra validation checks. The file path is for error messages. -- and runs some extra validation checks. The file path is used in error messages.
parseAndValidateCsvRules :: FilePath -> T.Text -> ExceptT String IO CsvRules parseAndValidateCsvRules :: FilePath -> T.Text -> Either String CsvRules
parseAndValidateCsvRules rulesfile s = do parseAndValidateCsvRules rulesfile s =
let rules = parseCsvRules rulesfile s case parseCsvRules rulesfile s of
case rules of Left err -> Left $ customErrorBundlePretty err
Left e -> ExceptT $ return $ Left $ customErrorBundlePretty e Right rules -> first makeFancyParseError $ validateRules rules
Right r -> do
r_ <- liftIO $ runExceptT $ validateRules r
ExceptT $ case r_ of
Left s -> return $ Left $ parseErrorPretty $ makeParseError s
Right r -> return $ Right r
where where
makeParseError :: String -> ParseError T.Text String makeFancyParseError :: String -> String
makeParseError s = FancyError 0 (S.singleton $ ErrorFail s) makeFancyParseError s =
parseErrorPretty (FancyError 0 (S.singleton $ ErrorFail s) :: ParseError Text String)
-- | Parse this text as CSV conversion rules. The file path is for error messages. -- | Parse this text as CSV conversion rules. The file path is for error messages.
parseCsvRules :: FilePath -> T.Text -> Either (ParseErrorBundle T.Text CustomErr) CsvRules parseCsvRules :: FilePath -> T.Text -> Either (ParseErrorBundle T.Text CustomErr) CsvRules
@ -446,18 +448,18 @@ parseCsvRules rulesfile s =
runParser (evalStateT rulesp defrules) rulesfile s runParser (evalStateT rulesp defrules) rulesfile s
-- | Return the validated rules, or an error. -- | Return the validated rules, or an error.
validateRules :: CsvRules -> ExceptT String IO CsvRules validateRules :: CsvRules -> Either String CsvRules
validateRules rules = do validateRules rules = do
unless (isAssigned "date") $ ExceptT $ return $ Left "Please specify (at top level) the date field. Eg: date %1\n" unless (isAssigned "date") $ Left "Please specify (at top level) the date field. Eg: date %1\n"
unless ((amount && not (amountin || amountout)) || unless ((amount && not (amountin || amountout)) ||
(not amount && (amountin && amountout)) || (not amount && (amountin && amountout)) ||
balance) balance)
$ ExceptT $ return $ Left $ unlines [ $ Left $ unlines [
"Please specify (as a top level CSV rule) either the amount field," "Please specify (as a top level CSV rule) either the amount field,"
,"both the amount-in and amount-out fields, or the balance field. Eg:" ,"both the amount-in and amount-out fields, or the balance field. Eg:"
,"amount %2\n" ,"amount %2\n"
] ]
ExceptT $ return $ Right rules Right rules
where where
amount = isAssigned "amount" amount = isAssigned "amount"
amountin = isAssigned "amount-in" amountin = isAssigned "amount-in"