-- {-# OPTIONS_GHC -F -pgmF htfpp #-} {-# LANGUAGE CPP, RecordWildCards, NoMonoLocalBinds, ScopedTypeVariables #-} {-# LANGUAGE FlexibleContexts #-} {-| A reader for hledger's journal file format (). hledger's journal format is a compatible subset of c++ ledger's (), so this reader should handle many ledger files as well. Example: @ 2012\/3\/24 gift expenses:gifts $10 assets:cash @ -} module Hledger.Read.JournalReader ( -- * Reader reader, -- * Parsers used elsewhere parseAndFinaliseJournal, genericSourcePos, getParentAccount, journalp, directivep, defaultyeardirectivep, marketpricedirectivep, datetimep, codep, accountnamep, modifiedaccountnamep, postingp, amountp, amountp', mamountp', numberp, statusp, emptyorcommentlinep, followingcommentp, accountaliasp -- * Tests ,tests_Hledger_Read_JournalReader #ifdef TESTS -- disabled by default, HTF not available on windows ,htf_thisModulesTests ,htf_Hledger_Read_JournalReader_importedTests #endif ) where import Prelude () import Prelude.Compat hiding (readFile) import qualified Control.Exception as C import Control.Monad.Compat import Control.Monad.Except (ExceptT(..), liftIO, runExceptT, throwError, catchError) import Data.Char (isNumber) import Data.List.Compat import Data.List.Split (wordsBy) import Data.Maybe import Data.Time.Calendar import Data.Time.LocalTime import Safe (headDef, lastDef) import Test.HUnit #ifdef TESTS import Test.Framework import Text.Parsec.Error #endif import Text.Parsec hiding (parse) import Text.Printf import System.FilePath import System.Time (getClockTime) import Hledger.Data import Hledger.Utils -- standard reader exports reader :: Reader reader = Reader format detect parse format :: String format = "journal" -- | Does the given file path and data look like it might be hledger's journal format ? detect :: FilePath -> String -> Bool detect f s | f /= "-" = takeExtension f `elem` ['.':format, ".j"] -- from a file: yes if the extension is .journal or .j -- from stdin: yes if we can see something that looks like a journal entry (digits in column 0 with the next line indented) | otherwise = regexMatches "^[0-9]+.*\n[ \t]+" s -- | Parse and post-process a "Journal" from hledger's journal file -- format, or give an error. parse :: Maybe FilePath -> Bool -> FilePath -> String -> ExceptT String IO Journal parse _ = parseAndFinaliseJournal journalp -- parsing utils genericSourcePos :: SourcePos -> GenericSourcePos genericSourcePos p = GenericSourcePos (sourceName p) (sourceLine p) (sourceColumn p) -- | Flatten a list of JournalUpdate's (journal-transforming -- monadic actions which can do IO or raise an exception) into a -- single equivalent action. combineJournalUpdates :: [JournalUpdate] -> JournalUpdate combineJournalUpdates us = foldl' (flip (.)) id <$> sequence us -- XXX may be contributing to excessive stack use -- cf http://neilmitchell.blogspot.co.uk/2015/09/detecting-space-leaks.html -- $ ./devprof +RTS -K576K -xc -- *** Exception (reporting due to +RTS -xc): (THUNK_STATIC), stack trace: -- Hledger.Read.JournalReader.combineJournalUpdates.\, -- called from Hledger.Read.JournalReader.combineJournalUpdates, -- called from Hledger.Read.JournalReader.fixedlotprice, -- called from Hledger.Read.JournalReader.partialbalanceassertion, -- called from Hledger.Read.JournalReader.getDefaultCommodityAndStyle, -- called from Hledger.Read.JournalReader.priceamount, -- called from Hledger.Read.JournalReader.nosymbolamount, -- called from Hledger.Read.JournalReader.numberp, -- called from Hledger.Read.JournalReader.rightsymbolamount, -- called from Hledger.Read.JournalReader.simplecommoditysymbol, -- called from Hledger.Read.JournalReader.quotedcommoditysymbol, -- called from Hledger.Read.JournalReader.commoditysymbol, -- called from Hledger.Read.JournalReader.signp, -- called from Hledger.Read.JournalReader.leftsymbolamount, -- called from Hledger.Read.JournalReader.amountp, -- called from Hledger.Read.JournalReader.spaceandamountormissing, -- called from Hledger.Read.JournalReader.accountnamep.singlespace, -- called from Hledger.Utils.Parse.nonspace, -- called from Hledger.Read.JournalReader.accountnamep, -- called from Hledger.Read.JournalReader.getAccountAliases, -- called from Hledger.Read.JournalReader.getParentAccount, -- called from Hledger.Read.JournalReader.modifiedaccountnamep, -- called from Hledger.Read.JournalReader.postingp, -- called from Hledger.Read.JournalReader.postings, -- called from Hledger.Read.JournalReader.commentStartingWith, -- called from Hledger.Read.JournalReader.semicoloncomment, -- called from Hledger.Read.JournalReader.followingcommentp, -- called from Hledger.Read.JournalReader.descriptionp, -- called from Hledger.Read.JournalReader.codep, -- called from Hledger.Read.JournalReader.statusp, -- called from Hledger.Utils.Parse.spacenonewline, -- called from Hledger.Read.JournalReader.secondarydatep, -- called from Hledger.Data.Dates.datesepchar, -- called from Hledger.Read.JournalReader.datep, -- called from Hledger.Read.JournalReader.transaction, -- called from Hledger.Utils.Parse.choice', -- called from Hledger.Read.JournalReader.directive, -- called from Hledger.Read.JournalReader.emptyorcommentlinep, -- called from Hledger.Read.JournalReader.multilinecommentp, -- called from Hledger.Read.JournalReader.journal.journalItem, -- called from Hledger.Read.JournalReader.journal, -- called from Hledger.Read.JournalReader.parseJournalWith, -- called from Hledger.Read.readJournal.tryReaders.firstSuccessOrBestError, -- called from Hledger.Read.readJournal.tryReaders, -- called from Hledger.Read.readJournal, -- called from Main.main, -- called from Main.CAF -- Stack space overflow: current size 33568 bytes. -- | Given a JournalUpdate-generating parsec parser, file path and data string, -- parse and post-process a Journal so that it's ready to use, or give an error. parseAndFinaliseJournal :: (ParsecT [Char] JournalContext (ExceptT String IO) (JournalUpdate,JournalContext)) -> Bool -> FilePath -> String -> ExceptT String IO Journal parseAndFinaliseJournal parser assrt f s = do tc <- liftIO getClockTime tl <- liftIO getCurrentLocalTime y <- liftIO getCurrentYear r <- runParserT parser nullctx{ctxYear=Just y} f s case r of Right (updates,ctx) -> do j <- ap updates (return nulljournal) case journalFinalise tc tl f s ctx assrt j of Right j' -> return j' Left estr -> throwError estr Left e -> throwError $ show e setYear :: Stream [Char] m Char => Integer -> ParsecT [Char] JournalContext m () setYear y = modifyState (\ctx -> ctx{ctxYear=Just y}) getYear :: Stream [Char] m Char => ParsecT s JournalContext m (Maybe Integer) getYear = liftM ctxYear getState setDefaultCommodityAndStyle :: Stream [Char] m Char => (Commodity,AmountStyle) -> ParsecT [Char] JournalContext m () setDefaultCommodityAndStyle cs = modifyState (\ctx -> ctx{ctxDefaultCommodityAndStyle=Just cs}) getDefaultCommodityAndStyle :: Stream [Char] m Char => ParsecT [Char] JournalContext m (Maybe (Commodity,AmountStyle)) getDefaultCommodityAndStyle = ctxDefaultCommodityAndStyle `fmap` getState pushParentAccount :: Stream [Char] m Char => String -> ParsecT [Char] JournalContext m () pushParentAccount parent = modifyState addParentAccount where addParentAccount ctx0 = ctx0 { ctxAccount = parent : ctxAccount ctx0 } popParentAccount :: Stream [Char] m Char => ParsecT [Char] JournalContext m () popParentAccount = do ctx0 <- getState case ctxAccount ctx0 of [] -> unexpected "End of account block with no beginning" (_:rest) -> setState $ ctx0 { ctxAccount = rest } getParentAccount :: Stream [Char] m Char => ParsecT [Char] JournalContext m String getParentAccount = liftM (concatAccountNames . reverse . ctxAccount) getState addAccountAlias :: Stream [Char] m Char => AccountAlias -> ParsecT [Char] JournalContext m () addAccountAlias a = modifyState (\(ctx@Ctx{..}) -> ctx{ctxAliases=a:ctxAliases}) getAccountAliases :: Stream [Char] m Char => ParsecT [Char] JournalContext m [AccountAlias] getAccountAliases = liftM ctxAliases getState clearAccountAliases :: Stream [Char] m Char => ParsecT [Char] JournalContext m () clearAccountAliases = modifyState (\(ctx@Ctx{..}) -> ctx{ctxAliases=[]}) getIndex :: Stream [Char] m Char => ParsecT s JournalContext m Integer getIndex = liftM ctxTransactionIndex getState setIndex :: Stream [Char] m Char => Integer -> ParsecT [Char] JournalContext m () setIndex i = modifyState (\ctx -> ctx{ctxTransactionIndex=i}) -- parsers -- | Top-level journal parser. Returns a single composite, I/O performing, -- error-raising "JournalUpdate" (and final "JournalContext") which can be -- applied to an empty journal to get the final result. journalp :: ParsecT [Char] JournalContext (ExceptT String IO) (JournalUpdate,JournalContext) journalp = do journalupdates <- many journalItem eof finalctx <- getState return $ (combineJournalUpdates journalupdates, finalctx) where -- As all journal line types can be distinguished by the first -- character, excepting transactions versus empty (blank or -- comment-only) lines, can use choice w/o try journalItem = choice [ directivep , liftM (return . addTransaction) transactionp , liftM (return . addModifierTransaction) modifiertransactionp , liftM (return . addPeriodicTransaction) periodictransactionp , liftM (return . addMarketPrice) marketpricedirectivep , emptyorcommentlinep >> return (return id) , multilinecommentp >> return (return id) ] "journal transaction or directive" -- cf http://ledger-cli.org/3.0/doc/ledger3.html#Command-Directives directivep :: ParsecT [Char] JournalContext (ExceptT String IO) JournalUpdate directivep = do optional $ char '!' choice' [ includedirectivep ,aliasdirectivep ,endaliasesdirectivep ,accountdirectivep ,enddirectivep ,tagdirectivep ,endtagdirectivep ,defaultyeardirectivep ,defaultcommoditydirectivep ,commodityconversiondirectivep ,ignoredpricecommoditydirectivep ] "directive" includedirectivep :: ParsecT [Char] JournalContext (ExceptT String IO) JournalUpdate includedirectivep = do string "include" many1 spacenonewline filename <- restofline outerState <- getState outerPos <- getPosition let curdir = takeDirectory (sourceName outerPos) let (u::ExceptT String IO (Journal -> Journal, JournalContext)) = do filepath <- expandPath curdir filename txt <- readFileOrError outerPos filepath let inIncluded = show outerPos ++ " in included file " ++ show filename ++ ":\n" r <- runParserT journalp outerState filepath txt case r of Right (ju, ctx) -> do u <- combineJournalUpdates [ return $ journalAddFile (filepath,txt) , ju ] `catchError` (throwError . (inIncluded ++)) return (u, ctx) Left err -> throwError $ inIncluded ++ show err where readFileOrError pos fp = ExceptT $ liftM Right (readFile' fp) `C.catch` \e -> return $ Left $ printf "%s reading %s:\n%s" (show pos) fp (show (e::C.IOException)) r <- liftIO $ runExceptT u case r of Left err -> return $ throwError err Right (ju, _finalparsectx) -> return $ ExceptT $ return $ Right ju journalAddFile :: (FilePath,String) -> Journal -> Journal journalAddFile f j@Journal{files=fs} = j{files=fs++[f]} -- NOTE: first encountered file to left, to avoid a reverse accountdirectivep :: ParsecT [Char] JournalContext (ExceptT String IO) JournalUpdate accountdirectivep = do string "account" many1 spacenonewline parent <- accountnamep newline pushParentAccount parent -- return $ return id return $ ExceptT $ return $ Right id enddirectivep :: ParsecT [Char] JournalContext (ExceptT String IO) JournalUpdate enddirectivep = do string "end" popParentAccount -- return (return id) return $ ExceptT $ return $ Right id aliasdirectivep :: ParsecT [Char] JournalContext (ExceptT String IO) JournalUpdate aliasdirectivep = do string "alias" many1 spacenonewline alias <- accountaliasp addAccountAlias alias return $ return id accountaliasp :: Stream [Char] m Char => ParsecT [Char] st m AccountAlias accountaliasp = regexaliasp <|> basicaliasp basicaliasp :: Stream [Char] m Char => ParsecT [Char] st m AccountAlias basicaliasp = do -- pdbg 0 "basicaliasp" old <- rstrip <$> (many1 $ noneOf "=") char '=' many spacenonewline new <- rstrip <$> anyChar `manyTill` eolof -- don't require a final newline, good for cli options return $ BasicAlias old new regexaliasp :: Stream [Char] m Char => ParsecT [Char] st m AccountAlias regexaliasp = do -- pdbg 0 "regexaliasp" char '/' re <- many1 $ noneOf "/\n\r" -- paranoid: don't try to read past line end char '/' many spacenonewline char '=' many spacenonewline repl <- rstrip <$> anyChar `manyTill` eolof return $ RegexAlias re repl endaliasesdirectivep :: ParsecT [Char] JournalContext (ExceptT String IO) JournalUpdate endaliasesdirectivep = do string "end aliases" clearAccountAliases return (return id) tagdirectivep :: ParsecT [Char] JournalContext (ExceptT String IO) JournalUpdate tagdirectivep = do string "tag" "tag directive" many1 spacenonewline _ <- many1 nonspace restofline return $ return id endtagdirectivep :: ParsecT [Char] JournalContext (ExceptT String IO) JournalUpdate endtagdirectivep = do (string "end tag" <|> string "pop") "end tag or pop directive" restofline return $ return id defaultyeardirectivep :: ParsecT [Char] JournalContext (ExceptT String IO) JournalUpdate defaultyeardirectivep = do char 'Y' "default year" many spacenonewline y <- many1 digit let y' = read y failIfInvalidYear y setYear y' return $ return id defaultcommoditydirectivep :: ParsecT [Char] JournalContext (ExceptT String IO) JournalUpdate defaultcommoditydirectivep = do char 'D' "default commodity" many1 spacenonewline Amount{..} <- amountp setDefaultCommodityAndStyle (acommodity, astyle) restofline return $ return id marketpricedirectivep :: ParsecT [Char] JournalContext (ExceptT String IO) MarketPrice marketpricedirectivep = do char 'P' "market price" many spacenonewline date <- try (do {LocalTime d _ <- datetimep; return d}) <|> datep -- a time is ignored many1 spacenonewline symbol <- commoditysymbolp many spacenonewline price <- amountp restofline return $ MarketPrice date symbol price ignoredpricecommoditydirectivep :: ParsecT [Char] JournalContext (ExceptT String IO) JournalUpdate ignoredpricecommoditydirectivep = do char 'N' "ignored-price commodity" many1 spacenonewline commoditysymbolp restofline return $ return id commodityconversiondirectivep :: ParsecT [Char] JournalContext (ExceptT String IO) JournalUpdate commodityconversiondirectivep = do char 'C' "commodity conversion" many1 spacenonewline amountp many spacenonewline char '=' many spacenonewline amountp restofline return $ return id modifiertransactionp :: ParsecT [Char] JournalContext (ExceptT String IO) ModifierTransaction modifiertransactionp = do char '=' "modifier transaction" many spacenonewline valueexpr <- restofline postings <- postingsp return $ ModifierTransaction valueexpr postings periodictransactionp :: ParsecT [Char] JournalContext (ExceptT String IO) PeriodicTransaction periodictransactionp = do char '~' "periodic transaction" many spacenonewline periodexpr <- restofline postings <- postingsp return $ PeriodicTransaction periodexpr postings -- | Parse a (possibly unbalanced) transaction. transactionp :: ParsecT [Char] JournalContext (ExceptT String IO) Transaction transactionp = do -- ptrace "transactionp" sourcepos <- genericSourcePos <$> getPosition date <- datep "transaction" edate <- optionMaybe (secondarydatep date) "secondary date" lookAhead (spacenonewline <|> newline) "whitespace or newline" status <- statusp "cleared status" code <- codep "transaction code" description <- descriptionp >>= return . strip comment <- try followingcommentp <|> (newline >> return "") let tags = tagsInComment comment postings <- postingsp i' <- (+1) <$> getIndex setIndex i' return $ txnTieKnot $ Transaction i' sourcepos date edate status code description comment tags postings "" descriptionp = many (noneOf ";\n") #ifdef TESTS test_transactionp = do let s `gives` t = do let p = parseWithCtx nullctx transactionp s assertBool $ isRight p let Right t2 = p -- same f = assertEqual (f t) (f t2) 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) -- "0000/01/01\n\n" `gives` nulltransaction 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=Uncleared, 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 $ parseWithCtx nullctx transactionp $ unlines ["2007/01/28 coopportunity" ," expenses:food:groceries $47.18" ," assets:checking $-47.18" ,"" ] -- transactionp should not parse just a date assertLeft $ parseWithCtx nullctx transactionp "2009/1/1\n" -- transactionp should not parse just a date and description assertLeft $ parseWithCtx nullctx transactionp "2009/1/1 a\n" -- transactionp should not parse a following comment as part of the description let p = parseWithCtx nullctx transactionp "2009/1/1 a ;comment\n b 1\n" assertRight p assertEqual "a" (let Right p' = p in tdescription p') -- parse transaction with following whitespace line assertRight $ parseWithCtx nullctx transactionp $ unlines ["2012/1/1" ," a 1" ," b" ," " ] let p = parseWithCtx nullctx 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 -- | Parse a date in YYYY/MM/DD format. -- Hyphen (-) and period (.) are also allowed as separators. -- The year may be omitted if a default year has been set. -- Leading zeroes may be omitted. datep :: Stream [Char] m t => ParsecT [Char] JournalContext m Day datep = do -- hacky: try to ensure precise errors for invalid dates -- XXX reported error position is not too good -- pos <- genericSourcePos <$> getPosition datestr <- do c <- digit cs <- many $ choice' [digit, datesepchar] return $ c:cs let sepchars = nub $ sort $ filter (`elem` datesepchars) datestr when (length sepchars /= 1) $ fail $ "bad date, different separators used: " ++ datestr let dateparts = wordsBy (`elem` datesepchars) datestr currentyear <- getYear [y,m,d] <- case (dateparts,currentyear) of ([m,d],Just y) -> return [show y,m,d] ([_,_],Nothing) -> fail $ "partial date "++datestr++" found, but the current year is unknown" ([y,m,d],_) -> return [y,m,d] _ -> fail $ "bad date: " ++ datestr let maybedate = fromGregorianValid (read y) (read m) (read d) case maybedate of Nothing -> fail $ "bad date: " ++ datestr Just date -> return date "full or partial date" -- | Parse a date and time in YYYY/MM/DD HH:MM[:SS][+-ZZZZ] format. -- Hyphen (-) and period (.) are also allowed as date separators. -- The year may be omitted if a default year has been set. -- Seconds are optional. -- The timezone is optional and ignored (the time is always interpreted as a local time). -- Leading zeroes may be omitted (except in a timezone). datetimep :: Stream [Char] m Char => ParsecT [Char] JournalContext m LocalTime datetimep = do day <- datep many1 spacenonewline h <- many1 digit let h' = read h guard $ h' >= 0 && h' <= 23 char ':' m <- many1 digit let m' = read m guard $ m' >= 0 && m' <= 59 s <- optionMaybe $ char ':' >> many1 digit let s' = case s of Just sstr -> read sstr Nothing -> 0 guard $ s' >= 0 && s' <= 59 {- tz <- -} optionMaybe $ do plusminus <- oneOf "-+" d1 <- digit d2 <- digit d3 <- digit d4 <- digit return $ plusminus:d1:d2:d3:d4:"" -- ltz <- liftIO $ getCurrentTimeZone -- let tz' = maybe ltz (fromMaybe ltz . parseTime defaultTimeLocale "%z") tz -- return $ localTimeToUTC tz' $ LocalTime day $ TimeOfDay h' m' (fromIntegral s') return $ LocalTime day $ TimeOfDay h' m' (fromIntegral s') secondarydatep :: Stream [Char] m Char => Day -> ParsecT [Char] JournalContext m Day secondarydatep primarydate = do char '=' -- kludgy way to use primary date for default year let withDefaultYear d p = do y <- getYear let (y',_,_) = toGregorian d in setYear y' r <- p when (isJust y) $ setYear $ fromJust y return r edate <- withDefaultYear primarydate datep return edate statusp :: Stream [Char] m Char => ParsecT [Char] JournalContext m ClearedStatus statusp = choice' [ many spacenonewline >> char '*' >> return Cleared , many spacenonewline >> char '!' >> return Pending , return Uncleared ] "cleared status" codep :: Stream [Char] m Char => ParsecT [Char] JournalContext m String codep = try (do { many1 spacenonewline; char '(' "codep"; code <- anyChar `manyTill` char ')'; return code } ) <|> return "" -- Parse the following whitespace-beginning lines as postings, posting tags, and/or comments. postingsp :: Stream [Char] m Char => ParsecT [Char] JournalContext m [Posting] postingsp = many (try postingp) "postings" -- linebeginningwithspaces :: Stream [Char] m Char => ParsecT [Char] JournalContext m String -- linebeginningwithspaces = do -- sp <- many1 spacenonewline -- c <- nonspace -- cs <- restofline -- return $ sp ++ (c:cs) ++ "\n" postingp :: Stream [Char] m Char => ParsecT [Char] JournalContext m Posting postingp = do many1 spacenonewline status <- statusp many spacenonewline account <- modifiedaccountnamep let (ptype, account') = (accountNamePostingType account, unbracket account) amount <- spaceandamountormissingp massertion <- partialbalanceassertionp _ <- fixedlotpricep many spacenonewline ctx <- getState comment <- try followingcommentp <|> (newline >> return "") let tags = tagsInComment comment -- oh boy date <- case dateValueFromTags tags of Nothing -> return Nothing Just v -> case runParser (datep <* eof) ctx "" v of Right d -> return $ Just d Left err -> parserFail $ show err date2 <- case date2ValueFromTags tags of Nothing -> return Nothing Just v -> case runParser (datep <* eof) ctx "" v of Right d -> return $ Just d Left err -> parserFail $ show err return posting { pdate=date , pdate2=date2 , 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 = parseWithCtx nullctx postingp s assertBool -- "postingp parser" $ 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 -- "postingp parses a quoted commodity with numbers" (isRight $ parseWithCtx nullctx postingp " a 1 \"DE123\"\n") -- ,"postingp parses balance assertions and fixed lot prices" ~: do assertBool (isRight $ parseWithCtx nullctx postingp " a 1 \"DE123\" =$1 { =2.2 EUR} \n") -- let parse = parseWithCtx nullctx postingp " a\n ;next-line comment\n" -- assertRight parse -- let Right p = parse -- assertEqual "next-line comment\n" (pcomment p) -- assertEqual (Just nullmixedamt) (pbalanceassertion p) #endif -- | Parse an account name, then apply any parent account prefix and/or account aliases currently in effect. modifiedaccountnamep :: Stream [Char] m Char => ParsecT [Char] JournalContext m AccountName modifiedaccountnamep = do parent <- getParentAccount aliases <- getAccountAliases a <- accountnamep return $ accountNameApplyAliases aliases $ -- XXX accountNameApplyAliasesMemo ? doesn't seem to make a difference joinAccountNames parent a -- | Parse an account name. Account names start with a non-space, may -- have single spaces inside them, and are terminated by two or more -- spaces (or end of input). Also they have one or more components of -- at least one character, separated by the account separator char. -- (This parser will also consume one following space, if present.) accountnamep :: Stream [Char] m Char => ParsecT [Char] st m AccountName accountnamep = do a <- do c <- nonspace cs <- striptrailingspace <$> many (nonspace <|> singlespace) return $ c:cs when (accountNameFromComponents (accountNameComponents a) /= a) (fail $ "account name seems ill-formed: "++a) return a where singlespace = try (do {spacenonewline; do {notFollowedBy spacenonewline; return ' '}}) striptrailingspace "" = "" striptrailingspace s = if last s == ' ' then init s else s -- accountnamechar = notFollowedBy (oneOf "()[]") >> nonspace -- "account name character (non-bracket, non-parenthesis, non-whitespace)" -- | Parse whitespace then an amount, with an optional left or right -- currency symbol and optional price, or return the special -- "missing" marker amount. spaceandamountormissingp :: Stream [Char] m Char => ParsecT [Char] JournalContext m MixedAmount spaceandamountormissingp = try (do many1 spacenonewline (Mixed . (:[])) `fmap` amountp <|> return missingmixedamt ) <|> return missingmixedamt #ifdef TESTS assertParseEqual' :: (Show a, Eq a) => (Either ParseError a) -> a -> Assertion assertParseEqual' parse expected = either (assertFailure.show) (`is'` expected) parse is' :: (Eq a, Show a) => a -> a -> Assertion a `is'` e = assertEqual e a test_spaceandamountormissingp = do assertParseEqual' (parseWithCtx nullctx spaceandamountormissingp " $47.18") (Mixed [usd 47.18]) assertParseEqual' (parseWithCtx nullctx spaceandamountormissingp "$47.18") missingmixedamt assertParseEqual' (parseWithCtx nullctx spaceandamountormissingp " ") missingmixedamt assertParseEqual' (parseWithCtx nullctx spaceandamountormissingp "") missingmixedamt #endif -- | Parse a single-commodity amount, with optional symbol on the left or -- right, optional unit or total price, and optional (ignored) -- ledger-style balance assertion or fixed lot price declaration. amountp :: Stream [Char] m t => ParsecT [Char] JournalContext m Amount amountp = try leftsymbolamountp <|> try rightsymbolamountp <|> nosymbolamountp #ifdef TESTS test_amountp = do assertParseEqual' (parseWithCtx nullctx amountp "$47.18") (usd 47.18) assertParseEqual' (parseWithCtx nullctx amountp "$1.") (usd 1 `withPrecision` 0) -- ,"amount with unit price" ~: do assertParseEqual' (parseWithCtx nullctx amountp "$10 @ €0.5") (usd 10 `withPrecision` 0 `at` (eur 0.5 `withPrecision` 1)) -- ,"amount with total price" ~: do assertParseEqual' (parseWithCtx nullctx amountp "$10 @@ €5") (usd 10 `withPrecision` 0 @@ (eur 5 `withPrecision` 0)) #endif -- | Parse an amount from a string, or get an error. amountp' :: String -> Amount amountp' s = case runParser (amountp <* eof) nullctx "" s of Right t -> t Left err -> error' $ show err -- | Parse a mixed amount from a string, or get an error. mamountp' :: String -> MixedAmount mamountp' = Mixed . (:[]) . amountp' signp :: Stream [Char] m t => ParsecT [Char] JournalContext m String signp = do sign <- optionMaybe $ oneOf "+-" return $ case sign of Just '-' -> "-" _ -> "" leftsymbolamountp :: Stream [Char] m t => ParsecT [Char] JournalContext m Amount leftsymbolamountp = do sign <- signp c <- commoditysymbolp sp <- many spacenonewline (q,prec,mdec,mgrps) <- numberp let s = amountstyle{ascommodityside=L, ascommodityspaced=not $ null sp, asprecision=prec, asdecimalpoint=mdec, asdigitgroups=mgrps} p <- priceamountp let applysign = if sign=="-" then negate else id return $ applysign $ Amount c q p s "left-symbol amount" rightsymbolamountp :: Stream [Char] m t => ParsecT [Char] JournalContext m Amount rightsymbolamountp = do (q,prec,mdec,mgrps) <- numberp sp <- many spacenonewline c <- commoditysymbolp p <- priceamountp let s = amountstyle{ascommodityside=R, ascommodityspaced=not $ null sp, asprecision=prec, asdecimalpoint=mdec, asdigitgroups=mgrps} return $ Amount c q p s "right-symbol amount" nosymbolamountp :: Stream [Char] m t => ParsecT [Char] JournalContext m Amount nosymbolamountp = do (q,prec,mdec,mgrps) <- numberp p <- priceamountp -- apply the most recently seen default commodity and style to this commodityless amount defcs <- getDefaultCommodityAndStyle let (c,s) = case defcs of Just (defc,defs) -> (defc, defs{asprecision=max (asprecision defs) prec}) Nothing -> ("", amountstyle{asprecision=prec, asdecimalpoint=mdec, asdigitgroups=mgrps}) return $ Amount c q p s "no-symbol amount" commoditysymbolp :: Stream [Char] m t => ParsecT [Char] JournalContext m String commoditysymbolp = (quotedcommoditysymbolp <|> simplecommoditysymbolp) "commodity symbol" quotedcommoditysymbolp :: Stream [Char] m t => ParsecT [Char] JournalContext m String quotedcommoditysymbolp = do char '"' s <- many1 $ noneOf ";\n\"" char '"' return s simplecommoditysymbolp :: Stream [Char] m t => ParsecT [Char] JournalContext m String simplecommoditysymbolp = many1 (noneOf nonsimplecommoditychars) priceamountp :: Stream [Char] m t => ParsecT [Char] JournalContext m Price priceamountp = try (do many spacenonewline char '@' try (do char '@' many spacenonewline a <- amountp -- XXX can parse more prices ad infinitum, shouldn't return $ TotalPrice a) <|> (do many spacenonewline a <- amountp -- XXX can parse more prices ad infinitum, shouldn't return $ UnitPrice a)) <|> return NoPrice partialbalanceassertionp :: Stream [Char] m t => ParsecT [Char] JournalContext m (Maybe MixedAmount) partialbalanceassertionp = try (do many spacenonewline char '=' many spacenonewline a <- amountp -- XXX should restrict to a simple amount return $ Just $ Mixed [a]) <|> return Nothing -- balanceassertion :: Stream [Char] m Char => ParsecT [Char] JournalContext m (Maybe MixedAmount) -- balanceassertion = -- try (do -- many spacenonewline -- string "==" -- many spacenonewline -- a <- amountp -- XXX should restrict to a simple amount -- return $ Just $ Mixed [a]) -- <|> return Nothing -- http://ledger-cli.org/3.0/doc/ledger3.html#Fixing-Lot-Prices fixedlotpricep :: Stream [Char] m Char => ParsecT [Char] JournalContext m (Maybe Amount) fixedlotpricep = try (do many spacenonewline char '{' many spacenonewline char '=' many spacenonewline a <- amountp -- XXX should restrict to a simple amount many spacenonewline char '}' return $ Just a) <|> return Nothing -- | Parse a string representation of a number for its value and display -- attributes. -- -- Some international number formats are accepted, eg either period or comma -- may be used for the decimal point, and the other of these may be used for -- separating digit groups in the integer part. See -- http://en.wikipedia.org/wiki/Decimal_separator for more examples. -- -- This returns: the parsed numeric value, the precision (number of digits -- seen following the decimal point), the decimal point character used if any, -- and the digit group style if any. -- numberp :: Stream [Char] m t => ParsecT [Char] JournalContext m (Quantity, Int, Maybe Char, Maybe DigitGroupStyle) numberp = do -- a number is an optional sign followed by a sequence of digits possibly -- interspersed with periods, commas, or both -- ptrace "numberp" sign <- signp parts <- many1 $ choice' [many1 digit, many1 $ char ',', many1 $ char '.'] dbg8 "numberp parsed" (sign,parts) `seq` return () -- check the number is well-formed and identify the decimal point and digit -- group separator characters used, if any let (numparts, puncparts) = partition numeric parts (ok, mdecimalpoint, mseparator) = case (numparts, puncparts) of ([],_) -> (False, Nothing, Nothing) -- no digits, not ok (_,[]) -> (True, Nothing, Nothing) -- digits with no punctuation, ok (_,[[d]]) -> (True, Just d, Nothing) -- just a single punctuation of length 1, assume it's a decimal point (_,[_]) -> (False, Nothing, Nothing) -- a single punctuation of some other length, not ok (_,_:_:_) -> -- two or more punctuations let (s:ss, d) = (init puncparts, last puncparts) -- the leftmost is a separator and the rightmost may be a decimal point in if (any ((/=1).length) puncparts -- adjacent punctuation chars, not ok || any (s/=) ss -- separator chars vary, not ok || head parts == s) -- number begins with a separator char, not ok then (False, Nothing, Nothing) else if s == d then (True, Nothing, Just $ head s) -- just one kind of punctuation - must be separators else (True, Just $ head d, Just $ head s) -- separator(s) and a decimal point when (not ok) (fail $ "number seems ill-formed: "++concat parts) -- get the digit group sizes and digit group style if any let (intparts',fracparts') = span ((/= mdecimalpoint) . Just . head) parts (intparts, fracpart) = (filter numeric intparts', filter numeric fracparts') groupsizes = reverse $ case map length intparts of (a:b:cs) | a < b -> b:cs gs -> gs mgrps = maybe Nothing (Just . (`DigitGroups` groupsizes)) $ mseparator -- put the parts back together without digit group separators, get the precision and parse the value let int = concat $ "":intparts frac = concat $ "":fracpart precision = length frac int' = if null int then "0" else int frac' = if null frac then "0" else frac quantity = read $ sign++int'++"."++frac' -- this read should never fail return $ dbg8 "numberp quantity,precision,mdecimalpoint,mgrps" (quantity,precision,mdecimalpoint,mgrps) "numberp" where numeric = isNumber . headDef '_' -- test_numberp = do -- let s `is` n = assertParseEqual (parseWithCtx nullctx numberp s) n -- assertFails = assertBool . isLeft . parseWithCtx nullctx numberp -- assertFails "" -- "0" `is` (0, 0, '.', ',', []) -- "1" `is` (1, 0, '.', ',', []) -- "1.1" `is` (1.1, 1, '.', ',', []) -- "1,000.1" `is` (1000.1, 1, '.', ',', [3]) -- "1.00.000,1" `is` (100000.1, 1, ',', '.', [3,2]) -- "1,000,000" `is` (1000000, 0, '.', ',', [3,3]) -- "1." `is` (1, 0, '.', ',', []) -- "1," `is` (1, 0, ',', '.', []) -- ".1" `is` (0.1, 1, '.', ',', []) -- ",1" `is` (0.1, 1, ',', '.', []) -- assertFails "1,000.000,1" -- assertFails "1.000,000.1" -- assertFails "1,000.000.1" -- assertFails "1,,1" -- assertFails "1..1" -- assertFails ".1," -- assertFails ",1." -- comment parsers multilinecommentp :: Stream [Char] m Char => ParsecT [Char] JournalContext m () multilinecommentp = do string "comment" >> many spacenonewline >> newline go where go = try (eof <|> (string "end comment" >> newline >> return ())) <|> (anyLine >> go) anyLine = anyChar `manyTill` newline emptyorcommentlinep :: Stream [Char] m Char => ParsecT [Char] JournalContext m () emptyorcommentlinep = do many spacenonewline >> (commentp <|> (many spacenonewline >> newline >> return "")) return () followingcommentp :: Stream [Char] m Char => ParsecT [Char] JournalContext m String followingcommentp = -- ptrace "followingcommentp" do samelinecomment <- many spacenonewline >> (try semicoloncommentp <|> (newline >> return "")) newlinecomments <- many (try (many1 spacenonewline >> semicoloncommentp)) return $ unlines $ samelinecomment:newlinecomments commentp :: Stream [Char] m Char => ParsecT [Char] JournalContext m String commentp = commentStartingWithp commentchars commentchars :: [Char] commentchars = "#;*" semicoloncommentp :: Stream [Char] m Char => ParsecT [Char] JournalContext m String semicoloncommentp = commentStartingWithp ";" commentStartingWithp :: Stream [Char] m Char => String -> ParsecT [Char] JournalContext m String commentStartingWithp cs = do -- ptrace "commentStartingWith" oneOf cs many spacenonewline l <- anyChar `manyTill` eolof optional newline return l tagsInComment :: String -> [Tag] tagsInComment c = concatMap tagsInCommentLine $ lines c' where c' = ledgerDateSyntaxToTags c tagsInCommentLine :: String -> [Tag] tagsInCommentLine = catMaybes . map maybetag . map strip . splitAtElement ',' where maybetag s = case runParser (tagp <* eof) nullctx "" s of Right t -> Just t Left _ -> Nothing tagp = do -- ptrace "tag" n <- tagnamep v <- tagvaluep return (n,v) tagnamep = do -- ptrace "tagname" n <- many1 $ noneOf ": \t" char ':' return n tagvaluep = do -- ptrace "tagvalue" v <- anyChar `manyTill` ((char ',' >> return ()) <|> eolof) return $ strip $ reverse $ dropWhile (==',') $ reverse $ strip v ledgerDateSyntaxToTags :: String -> String ledgerDateSyntaxToTags = regexReplaceBy "\\[[-.\\/0-9=]+\\]" replace where replace ('[':s) | lastDef ' ' s == ']' = replace' $ init s replace s = s replace' s | isdate s = datetag s replace' ('=':s) | isdate s = date2tag s replace' s | last s =='=' && isdate (init s) = datetag (init s) replace' s | length ds == 2 && isdate d1 && isdate d1 = datetag d1 ++ date2tag d2 where ds = splitAtElement '=' s d1 = headDef "" ds d2 = lastDef "" ds replace' s = s isdate = isJust . parsedateM datetag s = "date:"++s++", " date2tag s = "date2:"++s++", " #ifdef TESTS test_ledgerDateSyntaxToTags = do assertEqual "date2:2012/11/28, " $ ledgerDateSyntaxToTags "[=2012/11/28]" #endif dateValueFromTags, date2ValueFromTags :: [Tag] -> Maybe String dateValueFromTags ts = maybe Nothing (Just . snd) $ find ((=="date") . fst) ts date2ValueFromTags ts = maybe Nothing (Just . snd) $ find ((=="date2") . fst) ts tests_Hledger_Read_JournalReader = TestList $ concat [ -- test_numberp ] {- old hunit tests tests_Hledger_Read_JournalReader = TestList $ concat [ test_numberp, test_amountp, test_spaceandamountormissingp, test_tagcomment, test_inlinecomment, test_comments, test_ledgerDateSyntaxToTags, test_postingp, test_transactionp, [ "modifiertransactionp" ~: do assertParse (parseWithCtx nullctx modifiertransactionp "= (some value expr)\n some:postings 1\n") ,"periodictransactionp" ~: do assertParse (parseWithCtx nullctx periodictransactionp "~ (some period expr)\n some:postings 1\n") ,"directivep" ~: do assertParse (parseWithCtx nullctx directivep "!include /some/file.x\n") assertParse (parseWithCtx nullctx directivep "account some:account\n") assertParse (parseWithCtx nullctx (directivep >> directivep) "!account a\nend\n") ,"comment" ~: do assertParse (parseWithCtx nullctx comment "; some comment \n") assertParse (parseWithCtx nullctx comment " \t; x\n") assertParse (parseWithCtx nullctx comment "#x") ,"datep" ~: do assertParse (parseWithCtx nullctx datep "2011/1/1") assertParseFailure (parseWithCtx nullctx datep "1/1") assertParse (parseWithCtx nullctx{ctxYear=Just 2011} datep "1/1") ,"datetimep" ~: do let p = do {t <- datetimep; eof; return t} bad = assertParseFailure . parseWithCtx nullctx p good = assertParse . parseWithCtx nullctx p bad "2011/1/1" bad "2011/1/1 24:00:00" bad "2011/1/1 00:60:00" bad "2011/1/1 00:00:60" good "2011/1/1 00:00" good "2011/1/1 23:59:59" good "2011/1/1 3:5:7" -- timezone is parsed but ignored let startofday = LocalTime (fromGregorian 2011 1 1) (TimeOfDay 0 0 (fromIntegral 0)) assertParseEqual (parseWithCtx nullctx p "2011/1/1 00:00-0800") startofday assertParseEqual (parseWithCtx nullctx p "2011/1/1 00:00+1234") startofday ,"defaultyeardirectivep" ~: do assertParse (parseWithCtx nullctx defaultyeardirectivep "Y 2010\n") assertParse (parseWithCtx nullctx defaultyeardirectivep "Y 10001\n") ,"marketpricedirectivep" ~: assertParseEqual (parseWithCtx nullctx marketpricedirectivep "P 2004/05/01 XYZ $55.00\n") (MarketPrice (parsedate "2004/05/01") "XYZ" $ usd 55) ,"ignoredpricecommoditydirectivep" ~: do assertParse (parseWithCtx nullctx ignoredpricecommoditydirectivep "N $\n") ,"defaultcommoditydirectivep" ~: do assertParse (parseWithCtx nullctx defaultcommoditydirectivep "D $1,000.0\n") ,"commodityconversiondirectivep" ~: do assertParse (parseWithCtx nullctx commodityconversiondirectivep "C 1h = $50.00\n") ,"tagdirectivep" ~: do assertParse (parseWithCtx nullctx tagdirectivep "tag foo \n") ,"endtagdirectivep" ~: do assertParse (parseWithCtx nullctx endtagdirectivep "end tag \n") assertParse (parseWithCtx nullctx endtagdirectivep "pop \n") ,"accountnamep" ~: do assertBool "accountnamep parses a normal account name" (isRight $ parsewith accountnamep "a:b:c") assertBool "accountnamep rejects an empty inner component" (isLeft $ parsewith accountnamep "a::c") assertBool "accountnamep rejects an empty leading component" (isLeft $ parsewith accountnamep ":b:c") assertBool "accountnamep rejects an empty trailing component" (isLeft $ parsewith accountnamep "a:b:") ,"leftsymbolamountp" ~: do assertParseEqual (parseWithCtx nullctx leftsymbolamountp "$1") (usd 1 `withPrecision` 0) assertParseEqual (parseWithCtx nullctx leftsymbolamountp "$-1") (usd (-1) `withPrecision` 0) assertParseEqual (parseWithCtx nullctx leftsymbolamountp "-$1") (usd (-1) `withPrecision` 0) ,"amount" ~: do let -- | compare a parse result with an expected amount, showing the debug representation for clarity assertAmountParse parseresult amount = (either (const "parse error") showAmountDebug parseresult) ~?= (showAmountDebug amount) assertAmountParse (parseWithCtx nullctx amountp "1 @ $2") (num 1 `withPrecision` 0 `at` (usd 2 `withPrecision` 0)) ]] -}