module Hledger.Read.JournalReader (
reader,
genericSourcePos,
parseAndFinaliseJournal,
runJournalParser,
rjp,
runErroringJournalParser,
rejp,
getParentAccount,
journalp,
directivep,
defaultyeardirectivep,
marketpricedirectivep,
datetimep,
datep,
modifiedaccountnamep,
postingp,
statusp,
emptyorcommentlinep,
followingcommentp,
accountaliasp
,tests_Hledger_Read_JournalReader
)
where
import Prelude ()
import Prelude.Compat hiding (readFile)
import qualified Control.Exception as C
import Control.Monad
import Control.Monad.Except (ExceptT(..), runExceptT, throwError)
import Control.Monad.State.Strict
import qualified Data.Map.Strict as M
import Data.Monoid
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Calendar
import Data.Time.LocalTime
import Safe
import Test.HUnit
#ifdef TESTS
import Test.Framework
import Text.Megaparsec.Error
#endif
import Text.Megaparsec.Compat hiding (parse)
import Text.Printf
import System.FilePath
import Hledger.Data
import Hledger.Read.Common
import Hledger.Read.TimeclockReader (timeclockfilep)
import Hledger.Read.TimedotReader (timedotfilep)
import Hledger.Utils
reader :: Reader
reader = Reader
{rFormat = "journal"
,rExtensions = ["journal", "j", "hledger", "ledger"]
,rParser = parse
,rExperimental = False
}
parse :: Maybe FilePath -> Bool -> FilePath -> Text -> ExceptT String IO Journal
parse _ = parseAndFinaliseJournal journalp
journalp :: MonadIO m => ErroringJournalParser m ParsedJournal
journalp = do
many addJournalItemP
eof
get
addJournalItemP :: MonadIO m => ErroringJournalParser m ()
addJournalItemP =
choice [
directivep
, transactionp >>= modify' . addTransaction
, modifiertransactionp >>= modify' . addModifierTransaction
, periodictransactionp >>= modify' . addPeriodicTransaction
, marketpricedirectivep >>= modify' . addMarketPrice
, void emptyorcommentlinep
, void multilinecommentp
] <?> "transaction or directive"
directivep :: MonadIO m => ErroringJournalParser m ()
directivep = (do
optional $ char '!'
choiceInState [
includedirectivep
,aliasdirectivep
,endaliasesdirectivep
,accountdirectivep
,applyaccountdirectivep
,commoditydirectivep
,endapplyaccountdirectivep
,tagdirectivep
,endtagdirectivep
,defaultyeardirectivep
,defaultcommoditydirectivep
,commodityconversiondirectivep
,ignoredpricecommoditydirectivep
]
) <?> "directive"
includedirectivep :: MonadIO m => ErroringJournalParser m ()
includedirectivep = do
string "include"
lift (some spacenonewline)
filename <- lift restofline
parentpos <- getPosition
parentj <- get
let childj = newJournalWithParseStateFrom parentj
(ej :: Either String ParsedJournal) <-
liftIO $ runExceptT $ do
let curdir = takeDirectory (sourceName parentpos)
filepath <- expandPath curdir filename `orRethrowIOError` (show parentpos ++ " locating " ++ filename)
txt <- readFileAnyLineEnding filepath `orRethrowIOError` (show parentpos ++ " reading " ++ filepath)
(ej1::Either (ParseError Char MPErr) ParsedJournal) <-
runParserT
(evalStateT
(choiceInState
[journalp
,timeclockfilep
,timedotfilep
])
childj)
filepath txt
either
(throwError
. ((show parentpos ++ " in included file " ++ show filename ++ ":\n") ++)
. show)
(return . journalAddFile (filepath, txt))
ej1
case ej of
Left e -> throwError e
Right childj -> modify' (\parentj -> childj <> parentj)
newJournalWithParseStateFrom :: Journal -> Journal
newJournalWithParseStateFrom j = mempty{
jparsedefaultyear = jparsedefaultyear j
,jparsedefaultcommodity = jparsedefaultcommodity j
,jparseparentaccounts = jparseparentaccounts j
,jparsealiases = jparsealiases j
,jparsetimeclockentries = jparsetimeclockentries j
}
orRethrowIOError :: IO a -> String -> ExceptT String IO a
orRethrowIOError io msg =
ExceptT $
(Right <$> io)
`C.catch` \(e::C.IOException) -> return $ Left $ printf "%s:\n%s" msg (show e)
accountdirectivep :: JournalParser m ()
accountdirectivep = do
string "account"
lift (some spacenonewline)
acct <- lift accountnamep
newline
many indentedlinep
modify' (\j -> j{jaccounts = acct : jaccounts j})
indentedlinep :: JournalParser m String
indentedlinep = lift (some spacenonewline) >> (rstrip <$> lift restofline)
commoditydirectivep :: Monad m => ErroringJournalParser m ()
commoditydirectivep = try commoditydirectiveonelinep <|> commoditydirectivemultilinep
commoditydirectiveonelinep :: Monad m => JournalParser m ()
commoditydirectiveonelinep = do
string "commodity"
lift (some spacenonewline)
Amount{acommodity,astyle} <- amountp
lift (many spacenonewline)
_ <- followingcommentp <|> (lift eolof >> return "")
let comm = Commodity{csymbol=acommodity, cformat=Just astyle}
modify' (\j -> j{jcommodities=M.insert acommodity comm $ jcommodities j})
commoditydirectivemultilinep :: Monad m => ErroringJournalParser m ()
commoditydirectivemultilinep = do
string "commodity"
lift (some spacenonewline)
sym <- lift commoditysymbolp
_ <- followingcommentp <|> (lift eolof >> return "")
mformat <- lastMay <$> many (indented $ formatdirectivep sym)
let comm = Commodity{csymbol=sym, cformat=mformat}
modify' (\j -> j{jcommodities=M.insert sym comm $ jcommodities j})
where
indented = (lift (some spacenonewline) >>)
formatdirectivep :: Monad m => CommoditySymbol -> ErroringJournalParser m AmountStyle
formatdirectivep expectedsym = do
string "format"
lift (some spacenonewline)
pos <- getPosition
Amount{acommodity,astyle} <- amountp
_ <- followingcommentp <|> (lift eolof >> return "")
if acommodity==expectedsym
then return astyle
else parserErrorAt pos $
printf "commodity directive symbol \"%s\" and format directive symbol \"%s\" should be the same" expectedsym acommodity
applyaccountdirectivep :: JournalParser m ()
applyaccountdirectivep = do
string "apply" >> lift (some spacenonewline) >> string "account"
lift (some spacenonewline)
parent <- lift accountnamep
newline
pushParentAccount parent
endapplyaccountdirectivep :: JournalParser m ()
endapplyaccountdirectivep = do
string "end" >> lift (some spacenonewline) >> string "apply" >> lift (some spacenonewline) >> string "account"
popParentAccount
aliasdirectivep :: JournalParser m ()
aliasdirectivep = do
string "alias"
lift (some spacenonewline)
alias <- lift accountaliasp
addAccountAlias alias
accountaliasp :: TextParser m AccountAlias
accountaliasp = regexaliasp <|> basicaliasp
basicaliasp :: TextParser m AccountAlias
basicaliasp = do
old <- rstrip <$> (some $ noneOf ("=" :: [Char]))
char '='
many spacenonewline
new <- rstrip <$> anyChar `manyTill` eolof
return $ BasicAlias (T.pack old) (T.pack new)
regexaliasp :: TextParser m AccountAlias
regexaliasp = do
char '/'
re <- some $ noneOf ("/\n\r" :: [Char])
char '/'
many spacenonewline
char '='
many spacenonewline
repl <- rstrip <$> anyChar `manyTill` eolof
return $ RegexAlias re repl
endaliasesdirectivep :: JournalParser m ()
endaliasesdirectivep = do
string "end aliases"
clearAccountAliases
tagdirectivep :: JournalParser m ()
tagdirectivep = do
string "tag" <?> "tag directive"
lift (some spacenonewline)
_ <- lift $ some nonspace
lift restofline
return ()
endtagdirectivep :: JournalParser m ()
endtagdirectivep = do
(string "end tag" <|> string "pop") <?> "end tag or pop directive"
lift restofline
return ()
defaultyeardirectivep :: JournalParser m ()
defaultyeardirectivep = do
char 'Y' <?> "default year"
lift (many spacenonewline)
y <- some digitChar
let y' = read y
failIfInvalidYear y
setYear y'
defaultcommoditydirectivep :: Monad m => JournalParser m ()
defaultcommoditydirectivep = do
char 'D' <?> "default commodity"
lift (some spacenonewline)
Amount{..} <- amountp
lift restofline
setDefaultCommodityAndStyle (acommodity, astyle)
marketpricedirectivep :: Monad m => JournalParser m MarketPrice
marketpricedirectivep = do
char 'P' <?> "market price"
lift (many spacenonewline)
date <- try (do {LocalTime d _ <- datetimep; return d}) <|> datep
lift (some spacenonewline)
symbol <- lift commoditysymbolp
lift (many spacenonewline)
price <- amountp
lift restofline
return $ MarketPrice date symbol price
ignoredpricecommoditydirectivep :: JournalParser m ()
ignoredpricecommoditydirectivep = do
char 'N' <?> "ignored-price commodity"
lift (some spacenonewline)
lift commoditysymbolp
lift restofline
return ()
commodityconversiondirectivep :: Monad m => JournalParser m ()
commodityconversiondirectivep = do
char 'C' <?> "commodity conversion"
lift (some spacenonewline)
amountp
lift (many spacenonewline)
char '='
lift (many spacenonewline)
amountp
lift restofline
return ()
modifiertransactionp :: MonadIO m => ErroringJournalParser m ModifierTransaction
modifiertransactionp = do
char '=' <?> "modifier transaction"
lift (many spacenonewline)
valueexpr <- T.pack <$> lift restofline
postings <- postingsp Nothing
return $ ModifierTransaction valueexpr postings
periodictransactionp :: MonadIO m => ErroringJournalParser m PeriodicTransaction
periodictransactionp = do
char '~' <?> "periodic transaction"
lift (many spacenonewline)
periodexpr <- T.pack <$> lift restofline
postings <- postingsp Nothing
return $ PeriodicTransaction periodexpr postings
transactionp :: MonadIO m => ErroringJournalParser m Transaction
transactionp = do
pos <- getPosition
date <- datep <?> "transaction"
edate <- optional (secondarydatep date) <?> "secondary date"
lookAhead (lift spacenonewline <|> newline) <?> "whitespace or newline"
status <- lift statusp <?> "cleared status"
code <- T.pack <$> lift codep <?> "transaction code"
description <- T.pack . strip <$> descriptionp
comment <- try followingcommentp <|> (newline >> return "")
let tags = commentTags comment
postings <- postingsp (Just date)
pos' <- getPosition
let sourcepos = journalSourcePos pos pos'
return $ txnTieKnot $ Transaction 0 sourcepos date edate status code description comment tags postings ""
#ifdef TESTS
test_transactionp = do
let s `gives` t = do
let p = parseWithState mempty transactionp s
assertBool $ isRight p
let Right t2 = p
assertEqual (tdate t) (tdate t2)
assertEqual (tdate2 t) (tdate2 t2)
assertEqual (tstatus t) (tstatus t2)
assertEqual (tcode t) (tcode t2)
assertEqual (tdescription t) (tdescription t2)
assertEqual (tcomment t) (tcomment t2)
assertEqual (ttags t) (ttags t2)
assertEqual (tpreceding_comment_lines t) (tpreceding_comment_lines t2)
assertEqual (show $ tpostings t) (show $ tpostings t2)
unlines [
"2012/05/14=2012/05/15 (code) desc ; tcomment1",
" ; tcomment2",
" ; ttag1: val1",
" * a $1.00 ; pcomment1",
" ; pcomment2",
" ; ptag1: val1",
" ; ptag2: val2"
]
`gives`
nulltransaction{
tdate=parsedate "2012/05/14",
tdate2=Just $ parsedate "2012/05/15",
tstatus=Unmarked,
tcode="code",
tdescription="desc",
tcomment=" tcomment1\n tcomment2\n ttag1: val1\n",
ttags=[("ttag1","val1")],
tpostings=[
nullposting{
pstatus=Cleared,
paccount="a",
pamount=Mixed [usd 1],
pcomment=" pcomment1\n pcomment2\n ptag1: val1\n ptag2: val2\n",
ptype=RegularPosting,
ptags=[("ptag1","val1"),("ptag2","val2")],
ptransaction=Nothing
}
],
tpreceding_comment_lines=""
}
unlines [
"2015/1/1",
]
`gives`
nulltransaction{
tdate=parsedate "2015/01/01",
}
assertRight $ parseWithState mempty transactionp $ unlines
["2007/01/28 coopportunity"
," expenses:food:groceries $47.18"
," assets:checking $-47.18"
,""
]
assertLeft $ parseWithState mempty transactionp "2009/1/1\n"
assertLeft $ parseWithState mempty transactionp "2009/1/1 a\n"
let p = parseWithState mempty transactionp "2009/1/1 a ;comment\n b 1\n"
assertRight p
assertEqual "a" (let Right p' = p in tdescription p')
assertRight $ parseWithState mempty transactionp $ unlines
["2012/1/1"
," a 1"
," b"
," "
]
let p = parseWithState mempty transactionp $ unlines
["2009/1/1 x ; transaction comment"
," a 1 ; posting 1 comment"
," ; posting 1 comment 2"
," b"
," ; posting 2 comment"
]
assertRight p
assertEqual 2 (let Right t = p in length $ tpostings t)
#endif
postingsp :: MonadIO m => Maybe Day -> ErroringJournalParser m [Posting]
postingsp mdate = many (try $ postingp mdate) <?> "postings"
postingp :: MonadIO m => Maybe Day -> ErroringJournalParser m Posting
postingp mtdate = do
lift (some spacenonewline)
status <- lift statusp
lift (many spacenonewline)
account <- modifiedaccountnamep
let (ptype, account') = (accountNamePostingType account, textUnbracket account)
amount <- spaceandamountormissingp
massertion <- partialbalanceassertionp
_ <- fixedlotpricep
lift (many spacenonewline)
(comment,tags,mdate,mdate2) <-
try (followingcommentandtagsp mtdate) <|> (newline >> return ("",[],Nothing,Nothing))
return posting
{ pdate=mdate
, pdate2=mdate2
, pstatus=status
, paccount=account'
, pamount=amount
, pcomment=comment
, ptype=ptype
, ptags=tags
, pbalanceassertion=massertion
}
#ifdef TESTS
test_postingp = do
let s `gives` ep = do
let parse = parseWithState mempty (postingp Nothing) s
assertBool
$ isRight parse
let Right ap = parse
same f = assertEqual (f ep) (f ap)
same pdate
same pstatus
same paccount
same pamount
same pcomment
same ptype
same ptags
same ptransaction
" expenses:food:dining $10.00 ; a: a a \n ; b: b b \n" `gives`
posting{paccount="expenses:food:dining", pamount=Mixed [usd 10], pcomment=" a: a a \n b: b b \n", ptags=[("a","a a"), ("b","b b")]}
" a 1 ; [2012/11/28]\n" `gives`
("a" `post` num 1){pcomment=" [2012/11/28]\n"
,ptags=[("date","2012/11/28")]
,pdate=parsedateM "2012/11/28"}
" a 1 ; a:a, [=2012/11/28]\n" `gives`
("a" `post` num 1){pcomment=" a:a, [=2012/11/28]\n"
,ptags=[("a","a"), ("date2","2012/11/28")]
,pdate=Nothing}
" a 1 ; a:a\n ; [2012/11/28=2012/11/29],b:b\n" `gives`
("a" `post` num 1){pcomment=" a:a\n [2012/11/28=2012/11/29],b:b\n"
,ptags=[("a","a"), ("date","2012/11/28"), ("date2","2012/11/29"), ("b","b")]
,pdate=parsedateM "2012/11/28"}
assertBool
(isRight $ parseWithState mempty (postingp Nothing) " a 1 \"DE123\"\n")
assertBool (isRight $ parseWithState mempty (postingp Nothing) " a 1 \"DE123\" =$1 { =2.2 EUR} \n")
#endif
tests_Hledger_Read_JournalReader = TestList $ concat [
[
"showParsedMarketPrice" ~: do
let mp = parseWithState mempty marketpricedirectivep "P 2017/01/30 BTC $922.83\n"
mpString = (fmap . fmap) showMarketPrice mp
mpString `is` (Just (Right "P 2017/01/30 BTC $922.83"))
]
]