1 {-# LANGUAGE DeriveDataTypeable #-}
2 {-# LANGUAGE FlexibleContexts #-}
3 {-# LANGUAGE NamedFieldPuns #-}
4 {-# LANGUAGE ScopedTypeVariables #-}
5 module Hcompta.Format.Ledger.Read where
7 import Control.Applicative ((<*), (<$>))
8 import qualified Control.Exception as Exn
9 import Control.Monad (guard)
10 -- import Control.Monad.Error
11 import qualified Data.Char
13 import qualified Data.Decimal
14 import qualified Data.List
15 -- import Data.List.Split (wordsBy)
16 import qualified Data.Map
18 import qualified Data.Time.Calendar as Time
19 import qualified Data.Time.Clock as Time
20 import qualified Data.Time.LocalTime as Time
21 import Data.Typeable ()
22 import Safe (headDef, lastDef)
23 import qualified Text.Parsec as P
24 import Text.Parsec (Stream, ParsecT, (<|>), (<?>))
27 import qualified Hcompta.Model as Model
28 import qualified Hcompta.Model.Account as Account
29 import Hcompta.Model.Account (Account)
30 import qualified Hcompta.Model.Amount as Amount
31 import Hcompta.Model.Amount (Amount, Conversion, Style, Unit)
32 import qualified Hcompta.Model.Amount.Conversion as Conversion
33 import qualified Hcompta.Model.Amount.Quantity as Quantity
34 import qualified Hcompta.Model.Amount.Style as Style
35 import qualified Hcompta.Model.Amount.Unit as Unit
36 import qualified Hcompta.Model.Date as Date
37 import Hcompta.Format.Ledger.Journal as Journal
38 import qualified Hcompta.Lib.Regex as Regex
39 import Hcompta.Lib.Regex (Regex, (=~))
43 { account_prefix :: !Account
44 , context_aliases_exact :: !(Data.Map.Map Account Account)
45 , context_aliases_joker :: ![(Account.Joker, Account)]
46 , context_aliases_regex :: ![(Regex, Account)]
47 , unit_and_style :: !(Maybe (Amount.Unit, Amount.Style))
56 , context_aliases_exact = Data.Map.empty
57 , context_aliases_joker = []
58 , context_aliases_regex = []
59 , unit_and_style = Nothing
60 , journal = Journal.nil
61 , year = (\(year, _ , _) -> year) $
62 Time.toGregorian $ Time.utctDay $
63 Journal.last_read_time Journal.nil
70 -- | Like 'Text.Parsec.choice' but with 'Text.Parsec.try' on each case.
71 choice_try :: Stream s m t => [ParsecT s st m a] -> ParsecT s st m a
72 choice_try = P.choice . Data.List.map P.try
76 -- | Return the 'Integer' obtained by multiplying the given digits
77 -- with the power of the given base respective to their rank.
80 -> [Char] -- ^ Digits (MUST be recognised by 'Data.Char.digitToInt').
82 integer_of_digits base =
83 Data.List.foldl (\x d ->
84 base*x + toInteger (Data.Char.digitToInt d)) 0
86 decimal :: Stream [Char] m Char => ParsecT [Char] st m Integer
87 decimal = integer 10 P.digit
88 hexadecimal :: Stream [Char] m Char => ParsecT [Char] st m Integer
89 hexadecimal = P.oneOf "xX" >> integer 16 P.hexDigit
90 octal :: Stream [Char] m Char => ParsecT [Char] st m Integer
91 octal = P.oneOf "oO" >> integer 8 P.octDigit
93 -- | Parse an 'Integer'.
94 integer :: Stream [Char] m Char
95 => Integer -> ParsecT [Char] st m Char
96 -> ParsecT [Char] st m Integer
97 integer base digit = do
98 digits <- P.many1 digit
99 let n = integer_of_digits base digits
102 -- | Parse either '-' into 'negate', or '+' or '' into 'id'.
103 sign :: (Stream [Char] m Char, Num i) => ParsecT [Char] st m (i -> i)
105 (P.char '-' >> return negate) <|>
106 (P.char '+' >> return id) <|>
111 -- | Return 'True' if and only if the given 'Char' is an horizontal space.
112 is_space_horizontal :: Char -> Bool
113 is_space_horizontal c = c /= '\n' && c /= '\r' && Data.Char.isSpace c
115 space :: Stream [Char] m Char => ParsecT [Char] st m Char
116 space = P.satisfy is_space_horizontal
118 -- * Parsing 'Account'.
120 -- | Parse an 'Account'.
121 account :: Stream [Char] m Char => ParsecT [Char] st m Account
123 P.notFollowedBy $ P.satisfy is_space_horizontal
124 P.sepBy1 account_name account_name_separator
126 -- | Parse an Account.'Account.Name'.
127 account_name :: Stream [Char] m Char => ParsecT [Char] st m Account.Name
130 P.satisfy is_space_horizontal
131 <* (P.lookAhead $ P.satisfy (not . Data.Char.isSpace))
132 <|> (P.notFollowedBy account_name_separator >> P.anyChar)
134 -- | Parse the Account.'Account.Name' separator: ':'.
135 account_name_separator :: Stream [Char] m Char => ParsecT [Char] st m Char
136 account_name_separator = P.char ':'
138 -- | Parse an Account.'Account.Joker_Name'.
139 account_joker_name :: Stream [Char] m Char => ParsecT [Char] st m Account.Joker_Name
140 account_joker_name = do
141 n <- P.option Nothing $ (Just <$> account_name)
143 Nothing -> account_name_separator >> (return $ Account.Joker_Any)
144 Just n -> return $ Account.Joker_Name n
146 -- | Parse an Account.'Account.Joker'.
147 account_joker :: Stream [Char] m Char => ParsecT [Char] st m Account.Joker
149 P.notFollowedBy $ P.satisfy is_space_horizontal
150 P.sepBy1 account_joker_name account_name_separator
152 -- | Parse a 'Regex'.
153 account_regex :: Stream [Char] m Char => ParsecT [Char] st m Regex
155 re <- P.many1 $ P.satisfy (not . is_space_horizontal)
158 -- | Parse an Account.'Account.Filter'.
159 account_pattern :: Stream [Char] m Char => ParsecT [Char] st m Account.Pattern
162 [ Account.Pattern_Exact <$> (P.char '=' >> account)
163 , Account.Pattern_Joker <$> (P.char '*' >> account_joker)
164 , Account.Pattern_Regex <$> (P.option '~' (P.char '~') >> account_regex)
167 -- * Parsing 'Amount'.
169 -- | Parse an 'Amount'.
170 amount :: Stream [Char] m Char => ParsecT [Char] st m Amount
174 P.option Nothing $ do
176 s <- P.many $ P.satisfy is_space_horizontal
177 return $ Just $ (u, not $ null s)
178 (quantity_, style) <- do
185 , grouping_fractional
188 [ quantity '_' ',' '_' <* (P.notFollowedBy $ P.oneOf ",._")
189 , quantity '_' '.' '_' <* (P.notFollowedBy $ P.oneOf ",._")
190 , quantity ',' '.' '_' <* (P.notFollowedBy $ P.oneOf ",._")
191 , quantity '.' ',' '_' <* (P.notFollowedBy $ P.oneOf ",._")
193 let int = Data.List.concat integral
194 let frac_flat = Data.List.concat fractional
195 let frac = reverse $ Data.List.dropWhile (== '0') $ reverse $ frac_flat
196 let place = length frac
198 let mantissa = integer_of_digits 10 $ int ++ frac
200 ( Data.Decimal.Decimal
204 { Style.fractioning = fractioning
205 , Style.grouping_integral = grouping_integral
206 , Style.grouping_fractional = grouping_fractional
207 , Style.precision = fromIntegral $ length frac_flat
210 (unit_, side, spaced) <-
213 return (u, Just Style.Side_Left, Just s)
215 P.option (Unit.nil, Nothing, Nothing) $ do
216 s <- P.many $ P.satisfy is_space_horizontal
218 return $ (u, Just Style.Side_Right, Just $ not $ null s)
221 { Amount.conversion = Conversion.nil -- TODO
222 , Amount.quantity = left_signing $ quantity_
223 , Amount.style = style
224 { Style.unit_side = side
225 , Style.unit_spaced = spaced
227 , Amount.unit = unit_
232 { integral :: [String]
233 , fractional :: [String]
234 , fractioning :: Maybe Style.Fractioning
235 , grouping_integral :: Maybe Style.Grouping
236 , grouping_fractional :: Maybe Style.Grouping
239 -- | Parse a 'Quantity'.
241 :: Stream [Char] m Char
242 => Char -- ^ Integral grouping separator.
243 -> Char -- ^ Fractioning separator.
244 -> Char -- ^ Fractional grouping separator.
245 -> ParsecT [Char] st m Quantity
246 quantity int_group_sep frac_sep frac_group_sep = do
247 (integral, grouping_integral) <- do
250 [] -> return ([], Nothing)
252 t <- P.many $ P.char int_group_sep >> P.many1 P.digit
254 return (digits, grouping_of_digits int_group_sep digits)
255 (fractional, fractioning, grouping_fractional) <-
258 _ -> P.option ([], Nothing, Nothing)) $ do
259 fractioning <- P.char frac_sep
261 t <- P.many $ P.char frac_group_sep >> P.many1 P.digit
263 return (digits, Just fractioning
264 , grouping_of_digits frac_group_sep $ reverse digits)
271 , grouping_fractional
274 grouping_of_digits :: Char -> [String] -> Maybe Style.Grouping
275 grouping_of_digits group_sep digits =
280 Style.Grouping group_sep $
281 canonicalize_grouping $
283 canonicalize_grouping :: [Int] -> [Int]
284 canonicalize_grouping groups =
285 Data.List.foldl -- NOTE: remove duplicates at begining and reverse.
286 (\acc l0 -> case acc of
287 l1:_ -> if l0 == l1 then acc else l0:acc
289 case groups of -- NOTE: keep only longer at begining.
290 l0:l1:t -> if l0 > l1 then groups else l1:t
293 -- | Parse an 'Unit'.
294 unit :: Stream [Char] m Char => ParsecT [Char] st m Unit
296 (quoted <|> unquoted) <?> "unit"
298 unquoted :: Stream [Char] m Char => ParsecT [Char] st m Unit
302 case Data.Char.generalCategory c of
303 Data.Char.CurrencySymbol -> True
304 Data.Char.LowercaseLetter -> True
305 Data.Char.ModifierLetter -> True
306 Data.Char.OtherLetter -> True
307 Data.Char.TitlecaseLetter -> True
308 Data.Char.UppercaseLetter -> True
310 quoted :: Stream [Char] m Char => ParsecT [Char] st m Unit
312 P.between (P.char '"') (P.char '"') $
317 -- ParsecT [Char] JournalContext (ErrorT String IO) JournalUpdate
319 directive_alias :: Stream [Char] m Char => ParsecT [Char] Context m ()
322 P.many1 $ P.satisfy is_space_horizontal
323 pattern <- account_pattern
324 P.many $ P.satisfy is_space_horizontal
326 P.many $ P.satisfy is_space_horizontal
328 P.many $ P.satisfy is_space_horizontal
330 Account.Pattern_Exact acct -> P.modifyState $ \ctx -> ctx{context_aliases_exact=
331 Data.Map.insert acct repl $ context_aliases_exact ctx}
332 Account.Pattern_Joker jokr -> P.modifyState $ \ctx -> ctx{context_aliases_joker=
333 (jokr, repl):context_aliases_joker ctx}
334 Account.Pattern_Regex regx -> P.modifyState $ \ctx -> ctx{context_aliases_regex=
335 (regx, repl):context_aliases_regex ctx}
338 -- | Parse the year, month and day separator: '/' or '-'.
339 date_separator :: Stream [Char] m Char => ParsecT [Char] st m Char
340 date_separator = P.satisfy (\c -> c == '/' || c == '-')
342 -- | Parse the hour, minute and second separator: ':'.
343 hour_separator :: Stream [Char] m Char => ParsecT [Char] st m Char
344 hour_separator = P.char ':'
346 -- | Parse a date in YYYY/MM/DD format. Fewer digits are allowed.
347 -- The year may be omitted if a default year has already been set.
348 date :: Stream [Char] m t => ParsecT [Char] Context m Date
350 n0 <- P.many1 P.digit
351 date_sep <- date_separator
352 n1 <- P.count 2 P.digit <|> P.count 1 P.digit
353 n2 <- P.option Nothing $ (P.char date_sep >> (Just <$> (P.count 2 P.digit <|> P.count 1 P.digit)))
357 y <- liftM context_year getState
359 Just d -> return (n0, n1, d)
360 year <- integer_of_digits 10 y
361 month <- integer_of_digits 10 m
362 day <- integer_of_digits 10 d
363 guard $ month >= 1 && month <= 12
364 guard $ day >= 1 && day <= 31
366 P.many1 $ P.satisfy is_space_horizontal
368 h <- P.count 2 P.digit <|> P.count 1 P.digit
369 hour_sep <- hour_separator
370 mi <- P.count 2 P.digit <|> P.count 1 P.digit
371 s <- P.option Nothing $ (P.char hour_sep >> (Just <$> (P.count 2 P.digit <|> P.count 1 P.digit)))
372 year <- integer_of_digits 10 y
373 month <- integer_of_digits 10 m
374 day <- integer_of_digits 10 d
375 guard $ hour >= 1 && month <= 12
376 guard $ month >= 1 && month <= 12
377 guard $ day >= 1 && day <= 31
379 -- XXX reported error position is not too good
380 -- pos <- getPosition
381 datestr <- many1 $ choice_try [digit, datesepchar]
382 let sepchars = nub $ sort $ filter (`elem` datesepchars) datestr
383 when (length sepchars /= 1) $ fail $ "bad date, different separators used: " ++ datestr
384 let dateparts = wordsBy (`elem` datesepchars) datestr
385 currentyear <- getYear
387 case (dateparts,currentyear) of
388 ([m,d],Just y) -> return [show y,m,d]
389 ([_,_],Nothing) -> fail $ "partial date "++datestr++" found, but the current year is unknown"
390 ([y,m,d],_) -> return [y,m,d]
391 _ -> fail $ "bad date: " ++ datestr
392 let maybedate = fromGregorianValid (read y) (read m) (read d)
394 Nothing -> fail $ "bad date: " ++ datestr
395 Just date -> return date
396 <?> "full or partial date"
398 -- | Parse a date and time in YYYY/MM/DD HH:MM[:SS][+-ZZZZ] format.
399 -- Any timezone will be ignored; the time is treated as local time.
400 -- Fewer digits are allowed, except in the timezone.
401 -- The year may be omitted if a default year has already been set.
402 parseDateTime :: Stream [Char] m Char => ParsecT [Char] Context m LocalTime
408 guard $ h' >= 0 && h' <= 23
412 guard $ m' >= 0 && m' <= 59
413 s <- optionMaybe $ char ':' >> many1 digit
414 let s' = case s of Just sstr -> read sstr
416 guard $ s' >= 0 && s' <= 59
419 plusminus <- oneOf "-+"
424 return $ plusminus:d1:d2:d3:d4:""
425 -- ltz <- liftIO $ getCurrentTimeZone
426 -- let tz' = maybe ltz (fromMaybe ltz . parseTime defaultTimeLocale "%z") tz
427 -- return $ localTimeToUTC tz' $ LocalTime day $ TimeOfDay h' m' (fromIntegral s')
428 return $ LocalTime day $ TimeOfDay h' m' (fromIntegral s')
430 -- | Parse a 'Posting'.
431 posting :: Stream [Char] m Char => ParsecT [Char] Context m Posting
433 _ <- P.many1 $ P.satisfy is_space_horizontal
434 -- status <- parseStatus -- TODO
435 _ <- P.many $ P.satisfy is_space_horizontal
436 account <- modifiedaccountname
437 let (ptype, account') = (accountNamePostingType account, unbracket account)
438 amount <- spaceandamountormissing
439 massertion <- partialbalanceassertion
443 comment <- try followingcommentp <|> (newline >> return "")
444 let tags = tagsInComment comment
447 case dateValueFromTags tags of
448 Nothing -> return Nothing
450 case runParser (parseDate <* eof) ctx "" v of
451 Right d -> return $ Just d
452 Left err -> parserFail $ show err
454 case date2ValueFromTags tags of
455 Nothing -> return Nothing
457 case runParser (parseDate <* eof) ctx "" v of
458 Right d -> return $ Just d
459 Left err -> parserFail $ show err
462 , Posting.date2=date2
463 , Posting.status=status
464 , Posting.account=account'
465 , Posting.amount=amount
466 , Posting.comment=comment
470 , Posting.balanceassertion=massertion
474 -- | Parse an account name, then apply any parent account prefix and/or account aliases currently in effect.
475 account_with_context :: Stream [Char] m Char => ParsecT [Char] Context m Account
476 account_with_context = do
478 prefix <- liftM context_account_prefix P.getState
479 aliases <- getAccountAliases
480 return $ accountNameApplyAliases aliases $ Account.(++) prefix acct
486 account :: Stream [Char] m Char => ParsecT [Char] st m Account
488 P.notFollowedBy $ P.satisfy is_space
489 single_space = try (P.satisfy is_space <* P.notFollowedBy $ P.satisfy is_space)
490 a <- P.many1 (not_spaces <|> single_space)
491 let a' = striptrailingspace a
492 when (accountNameFromComponents (accountNameComponents a') /= a')
493 (fail $ "account name seems ill-formed: "++a')
496 single_space = try (P.satisfy is_space <* P.notFollowedBy $ P.satisfy is_space)
497 striptrailingspace s = if last s == ' ' then init s else s
499 parsePosting :: Stream [Char] m Char => ParsecT [Char] Context m Posting
502 status <- parseStatus
504 account <- modifiedaccountname
505 let (ptype, account') = (accountNamePostingType account, unbracket account)
506 amount <- spaceandamountormissing
507 massertion <- partialbalanceassertion
511 comment <- try followingcommentp <|> (newline >> return "")
512 let tags = tagsInComment comment
515 if isZeroMixedAmount amount
517 let coa_ = coaAdd coa (accountNameComponents account) tags
522 case dateValueFromTags tags of
523 Nothing -> return Nothing
525 case runParser (parseDate <* eof) ctx "" v of
526 Right d -> return $ Just d
527 Left err -> parserFail $ show err
529 case date2ValueFromTags tags of
530 Nothing -> return Nothing
532 case runParser (parseDate <* eof) ctx "" v of
533 Right d -> return $ Just d
534 Left err -> parserFail $ show err
537 , Posting.date2=date2
538 , Posting.status=status
539 , Posting.account=account'
540 , Posting.amount=amount
541 , Posting.comment=comment
545 , Posting.balanceassertion=massertion
551 reader = Reader format detect parse
556 detect :: FilePath -> String -> Bool
558 | file /= "-" = takeExtension file `elem` ['.':format, ".j"] -- from a file: yes if the extension is .journal or .j
559 -- from stdin: yes if we can see something that looks like a journal entry (digits in column 0 with the next line indented)
560 | otherwise = regexMatches "^[0-9]+.*\n[ \t]+" s
562 parse :: Maybe FilePath -> Bool -> FilePath -> String -> ErrorT String IO Journal
563 parse _ = parseJournal journal
566 :: ParsecT [Char] Context (ErrorT String IO) Context
567 -> Bool -> FilePath -> String -> ErrorT String IO Journal
568 parseJournal parser filePath fileData = do
569 currentUTC <- liftIO Time.getCurrentTime
570 currentTimeZone <- liftIO Time.getCurrentTimeZone
571 let currentLocalTime = Time.utcToLocalTime currentTimeZone currentUTC
572 let (currentLocalYear, _, _) = Time.toGregorian $ Time.localDay currentLocalTime
573 parserResult <- runParserT parser
574 contextNull{context_year=Just currentLocalYear}
577 Left error -> throwError $ show error
579 let journal = context_journal context
580 journalBalanceTransactions $
582 { journal_unit_styles=
583 , journal_file=filePath
584 , journal_includes=reverse $ journal_includes journal
585 -- , journal_historical_prices=reverse $ journal_historical_prices journal
586 , journal_last_read_time=currentUTC
587 , journal_transactions=reverse $ journal_transactions journal
588 -- , journal_transaction_modifiers=reverse $ journal_transaction_modifiers journal
589 -- , journal_transaction_periodics=reverse $ journal_transaction_periodics journal
592 -- | Fill in any missing amounts and check that all journal transactions
593 -- balance, or return an error message. This is done after parsing all
594 -- amounts and working out the canonical commodities, since balancing
595 -- depends on display precision. Reports only the first error encountered.
596 journalBalanceTransactions :: Journal -> Either String Journal
597 journalBalanceTransactions journal =
598 let transactions = journal_transactions journal
599 let unit_and_style = journal_unit_styles journal
600 case sequence $ map balance transactions of
601 Right ts' -> Right journal{journal_transactions=map txnTieKnot ts'}
603 where balance = balanceTransaction (Just unit_and_style)
605 -- | Convert all the journal's posting amounts (not price amounts) to
606 -- their canonical display settings. Ie, all amounts in a given
607 -- unit will use (a) the display settings of the first, and (b)
608 -- the greatest precision, of the posting amounts in that unit.
609 journalCanonicaliseAmounts :: Journal -> Journal
610 journalCanonicaliseAmounts j@Journal{journal_transactions=ts} =
613 j'' = j'{journal_transactions=map fixtransaction ts}
614 j' = j{context_unit_and_style = canonicalStyles $ dbgAt 8 "journalAmounts" $ journalAmounts j}
615 fixtransaction t@Transaction{tpostings=ps} = t{tpostings=map fixposting ps}
616 fixposting p@Posting{pamount=a} = p{pamount=fixmixedamount a}
617 fixmixedamount (Mixed as) = Mixed $ map fixamount as
618 fixamount a@Amount{aunit=c} = a{astyle=journalCommodityStyle j' c}
620 -- | Given a list of amounts in parse order, build a map from commodities
621 -- to canonical display styles for amounts in that unit.
622 canonicalStyles :: [Amount] -> M.Map Amount.Unit Amount.Style
623 canonicalStyles amts =
624 M.fromList commstyles
626 samecomm = \a1 a2 -> aunit a1 == aunit a2
627 commamts = [(aunit $ head as, as) | as <- groupBy samecomm $ sortBy (comparing aunit) amts]
628 commstyles = [(c, canonicalStyleFrom $ map astyle as) | (c,as) <- commamts]
630 -- | Get all this journal's component amounts, roughly in the order parsed.
631 journalAmounts :: Journal -> [Amount]
633 concatMap flatten . journalMixedAmounts
634 where flatten (Mixed as) = as
636 amountStyleFromCommodity :: Context -> Amount.Unit -> Amount.Style
637 amountStyleFromCommodity context unit =
638 Data.Map.findWithDefault
639 (context_unit_and_style context)
641 journal_unit_styles $
642 context_journal context
647 setYear :: Stream [Char] m Char => Integer -> ParsecT [Char] Context m ()
648 setYear y = modifyState (\ctx -> ctx{context_year=Just y})
650 getYear :: Stream [Char] m Char => ParsecT s Context m (Maybe Integer)
651 getYear = liftM context_year getState
653 setCoA :: Stream [Char] m Char => CoA -> ParsecT [Char] Context m ()
654 setCoA coa = modifyState (\ctx -> ctx{ctxCoA=coa})
656 getCoA :: Stream [Char] m Char => ParsecT [Char] Context m CoA
657 getCoA = liftM ctxCoA getState
659 setDefaultCommodityAndStyle :: Stream [Char] m Char => (Amount.Unit,Amount.Style) -> ParsecT [Char] Context m ()
660 setDefaultCommodityAndStyle cs = modifyState (\ctx -> ctx{context_unit_and_style=Just cs})
662 getDefaultCommodityAndStyle :: Stream [Char] m Char => ParsecT [Char] Context m (Maybe (Amount.Unit,Amount.Style))
663 getDefaultCommodityAndStyle = context_unit_and_style `fmap` getState
665 pushParentAccount :: Stream [Char] m Char => String -> ParsecT [Char] Context m ()
666 pushParentAccount parent = modifyState addParentAccount
667 where addParentAccount ctx0 = ctx0 { context_account_prefix = parent : context_account_prefix ctx0 }
669 popParentAccount :: Stream [Char] m Char => ParsecT [Char] Context m ()
670 popParentAccount = do
672 case context_account_prefix ctx0 of
673 [] -> unexpected "End of account block with no beginning"
674 (_:rest) -> setState $ ctx0 { context_account_prefix = rest }
676 getParentAccount :: Stream [Char] m Char => ParsecT [Char] Context m String
677 getParentAccount = liftM (concatAccountNames . reverse . context_account_prefix) getState
679 addAccountAlias :: Stream [Char] m Char => AccountAlias -> ParsecT [Char] Context m ()
680 addAccountAlias a = modifyState (\(ctx@Ctx{..}) -> ctx{context_aliases=a:context_aliases})
682 getAccountAliases :: Stream [Char] m Char => ParsecT [Char] Context m [AccountAlias]
683 getAccountAliases = liftM context_aliases getState
685 clearAccountAliases :: Stream [Char] m Char => ParsecT [Char] Context m ()
686 clearAccountAliases = modifyState (\(ctx@Ctx{..}) -> ctx{context_aliases=[]})
690 parseJournal :: ParsecT [Char] Context (ErrorT String IO) (JournalUpdate, Context)
692 journalUpdates <- many journalItem
694 finalContext <- getState
695 return $ (combineJournalUpdates journalUpdates, finalContext)
697 -- As all journal line types can be distinguished by the first
698 -- character, excepting transactions versus empty (blank or
699 -- comment-only) lines, can use choice w/o try
703 , liftM (return . addTransaction) parseTransaction
704 , liftM (return . addModifierTransaction) parseTransactionModifier
705 , liftM (return . addPeriodicTransaction) periodictransaction
706 , liftM (return . addHistoricalPrice) historicalpricedirective
707 , emptyorcommentlinep >> return (return id)
708 , multilinecommentp >> return (return id)
709 ] <?> "journal transaction or directive"
711 parseDirective :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
715 [ parseDirectiveInclude
716 , parseDirectiveAlias
717 , parseDirectiveEndAlias
718 , parseDirectiveAccount
721 , parseDirectiveEndTag
723 , parseDirectiveCommodity
724 , parseDirectiveCommodityConversion
725 , parseDirectiveIgnoredPriceCommodity
729 parseDirectiveInclude :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
730 parseDirectiveInclude = do
733 filename <- restofline
734 outerState <- getState
735 outerPos <- getPosition
736 let curdir = takeDirectory (sourceName outerPos)
737 let (u::ErrorT String IO (Journal -> Journal, Context)) = do
738 filepath <- expandPath curdir filename
739 txt <- readFileOrError outerPos filepath
740 let inIncluded = show outerPos ++ " in included file " ++ show filename ++ ":\n"
741 r <- runParserT parseJournal outerState filepath txt
743 Right (ju, ctx) -> do
744 u <- combineJournalUpdates [ return $ journalAddFile (filepath,txt)
746 ] `catchError` (throwError . (inIncluded ++))
748 Left err -> throwError $ inIncluded ++ show err
749 where readFileOrError pos fp =
750 ErrorT $ liftM Right (readFile' fp) `Exn.catch`
751 \e -> return $ Left $ printf "%s reading %s:\n%s" (show pos) fp (show (e::Exn.IOException))
752 r <- liftIO $ runErrorT u
754 Left err -> return $ throwError err
755 Right (ju, ctx) -> do
757 return $ ErrorT $ return $ Right ju
759 journalAddFile :: (FilePath,String) -> Journal -> Journal
760 journalAddFile f j@Journal{journal_files=fs} = j{journal_files=fs++[f]}
761 -- NOTE: first encountered file to left, to avoid a reverse
763 parseDirectiveAccount :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
764 parseDirectiveAccount = do
767 parent <- parseAccountName
769 pushParentAccount parent
770 -- return $ return id
771 return $ ErrorT $ return $ Right id
773 parseDirectiveEnd :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
774 parseDirectiveEnd = do
777 -- return (return id)
778 return $ ErrorT $ return $ Right id
780 parseDirectiveAlias :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
781 parseDirectiveAlias = do
784 orig <- many1 $ noneOf "="
787 addAccountAlias (accountNameWithoutPostingType $ strip orig
788 ,accountNameWithoutPostingType $ strip alias)
791 parseDirectiveEndAlias :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
792 parseDirectiveEndAlias = do
797 parseDirectiveTag :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
798 parseDirectiveTag = do
799 string "tag" <?> "tag directive"
805 parseDirectiveEndTag :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
806 parseDirectiveEndTag = do
807 (string "end tag" <|> string "pop") <?> "end tag or pop directive"
811 parseDirectiveYear :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
812 parseDirectiveYear = do
813 char 'Y' <?> "default year"
821 parseDirectiveCommodity :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
822 parseDirectiveCommodity = do
823 char 'D' <?> "default unit"
825 Amount{..} <- getDefaultCommodityAndStyle >>= parseAmount
826 setDefaultCommodityAndStyle (aunit, astyle)
830 parseDirectiveHistoricalPrice :: ParsecT [Char] Context (ErrorT String IO) HistoricalPrice
831 parseDirectiveHistoricalPrice = do
832 char 'P' <?> "historical price"
834 date <- try (do {LocalTime d _ <- parseDateTime; return d}) <|> parseDate -- a time is ignored
836 symbol <- parseCommodity
838 price <- getDefaultCommodityAndStyle >>= parseAmount
840 return $ HistoricalPrice date symbol price
842 parseDirectiveIgnoredPriceCommodity :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
843 parseDirectiveIgnoredPriceCommodity = do
844 char 'N' <?> "ignored-price unit"
850 parseDirectiveCommodityConversion :: ParsecT [Char] Context (ErrorT String IO) JournalUpdate
851 parseDirectiveCommodityConversion = do
852 char 'C' <?> "unit conversion"
854 default_cs <- getDefaultCommodityAndStyle
855 parseAmount default_cs
859 parseAmount default_cs
863 parseTransactionModifier :: ParsecT [Char] Context (ErrorT String IO) ModifierTransaction
864 parseTransactionModifier = do
865 char '=' <?> "modifier transaction"
867 valueexpr <- restofline
868 parsePostings <- parsePostings
869 return $ ModifierTransaction valueexpr parsePostings
871 parseTransactionPeriodic :: ParsecT [Char] Context (ErrorT String IO) PeriodicTransaction
872 parseTransactionPeriodic = do
873 char '~' <?> "periodic transaction"
875 periodexpr <- restofline
876 parsePostings <- parsePostings
877 return $ PeriodicTransaction periodexpr parsePostings
879 -- | Parse a (possibly unbalanced) transaction.
880 parseTransaction :: ParsecT [Char] Context (ErrorT String IO) Transaction
881 parseTransaction = do
882 -- ptrace "transaction"
883 sourcepos <- getPosition
884 date <- parseDate <?> "transaction"
885 edate <- optionMaybe (parseDate2 date) <?> "secondary date"
886 lookAhead (spacenonewline <|> newline) <?> "whitespace or newline"
887 status <- parseStatus <?> "cleared flag"
888 code <- parseCode <?> "transaction code"
889 description <- descriptionp >>= return . strip
890 comment <- try followingcommentp <|> (newline >> return "")
891 let tags = tagsInComment comment
892 parsePostings <- parsePostings
893 return $ txnTieKnot $ Transaction sourcepos date edate status code description comment tags parsePostings ""
895 descriptionp = many (noneOf ";\n")
897 -- | Parse a date in YYYY/MM/DD format. Fewer digits are allowed. The year
898 -- may be omitted if a default year has already been set.
899 parseDate :: Stream [Char] m t => ParsecT [Char] Context m Day
901 -- hacky: try to ensure precise errors for invalid dates
902 -- XXX reported error position is not too good
903 -- pos <- getPosition
904 datestr <- many1 $ choice' [digit, datesepchar]
905 let sepchars = nub $ sort $ filter (`elem` datesepchars) datestr
906 when (length sepchars /= 1) $ fail $ "bad date, different separators used: " ++ datestr
907 let dateparts = wordsBy (`elem` datesepchars) datestr
908 currentyear <- getYear
910 case (dateparts,currentyear) of
911 ([m,d],Just y) -> return [show y,m,d]
912 ([_,_],Nothing) -> fail $ "partial date "++datestr++" found, but the current year is unknown"
913 ([y,m,d],_) -> return [y,m,d]
914 _ -> fail $ "bad date: " ++ datestr
915 let maybedate = fromGregorianValid (read y) (read m) (read d)
917 Nothing -> fail $ "bad date: " ++ datestr
918 Just date -> return date
919 <?> "full or partial date"
921 -- | Parse a date and time in YYYY/MM/DD HH:MM[:SS][+-ZZZZ] format. Any
922 -- timezone will be ignored; the time is treated as local time. Fewer
923 -- digits are allowed, except in the timezone. The year may be omitted if
924 -- a default year has already been set.
925 parseDateTime :: Stream [Char] m Char => ParsecT [Char] Context m LocalTime
931 guard $ h' >= 0 && h' <= 23
935 guard $ m' >= 0 && m' <= 59
936 s <- optionMaybe $ char ':' >> many1 digit
937 let s' = case s of Just sstr -> read sstr
939 guard $ s' >= 0 && s' <= 59
942 plusminus <- oneOf "-+"
947 return $ plusminus:d1:d2:d3:d4:""
948 -- ltz <- liftIO $ getCurrentTimeZone
949 -- let tz' = maybe ltz (fromMaybe ltz . parseTime defaultTimeLocale "%z") tz
950 -- return $ localTimeToUTC tz' $ LocalTime day $ TimeOfDay h' m' (fromIntegral s')
951 return $ LocalTime day $ TimeOfDay h' m' (fromIntegral s')
953 parseDate2 :: Stream [Char] m Char => Day -> ParsecT [Char] Context m Day
954 parseDate2 primarydate = do
956 -- kludgy way to use primary date for default year
957 let withDefaultYear d p = do
959 let (y',_,_) = toGregorian d in setYear y'
961 when (isJust y) $ setYear $ fromJust y
963 edate <- withDefaultYear primarydate parseDate
966 parseStatus :: Stream [Char] m Char => ParsecT [Char] Context m Bool
967 parseStatus = try (do { many spacenonewline; (char '*' <|> char '!') <?> "status"; return True } ) <|> return False
969 parseCode :: Stream [Char] m Char => ParsecT [Char] Context m String
970 parseCode = try (do { many1 spacenonewline; char '(' <?> "parseCode"; code <- anyChar `manyTill` char ')'; return code } ) <|> return ""
972 -- Parse the following whitespace-beginning lines as parsePostings, posting tags, and/or comments.
973 parsePostings :: Stream [Char] m Char => ParsecT [Char] Context m [Posting]
974 parsePostings = many1 (try parsePosting) <?> "parsePostings"
976 parsePosting :: Stream [Char] m Char => ParsecT [Char] Context m Posting
979 status <- parseStatus
981 account <- modifiedaccountname
982 let (ptype, account') = (accountNamePostingType account, unbracket account)
983 amount <- spaceandamountormissing
984 massertion <- partialbalanceassertion
988 comment <- try followingcommentp <|> (newline >> return "")
989 let tags = tagsInComment comment
992 if isZeroMixedAmount amount
994 let coa_ = coaAdd coa (accountNameComponents account) tags
999 case dateValueFromTags tags of
1000 Nothing -> return Nothing
1002 case runParser (parseDate <* eof) ctx "" v of
1003 Right d -> return $ Just d
1004 Left err -> parserFail $ show err
1006 case date2ValueFromTags tags of
1007 Nothing -> return Nothing
1009 case runParser (parseDate <* eof) ctx "" v of
1010 Right d -> return $ Just d
1011 Left err -> parserFail $ show err
1014 , Posting.date2=date2
1015 , Posting.status=status
1016 , Posting.account=account'
1017 , Posting.amount=amount
1018 , Posting.comment=comment
1019 , Posting.type=ptype
1022 , Posting.balanceassertion=massertion
1026 -- | Parse an account name, then apply any parent account prefix and/or account aliases currently in effect.
1027 modifiedaccountname :: Stream [Char] m Char => ParsecT [Char] Context m AccountName
1028 modifiedaccountname = do
1029 a <- parseAccountName
1030 prefix <- getParentAccount
1031 let prefixed = prefix `joinAccountNames` a
1032 aliases <- getAccountAliases
1033 return $ accountNameApplyAliases aliases prefixed
1035 -- accountnamechar = notFollowedBy (oneOf "()[]") >> nonspace
1036 -- <?> "account name character (non-bracket, non-parenthesis, non-whitespace)"
1038 -- | Parse whitespace then an amount, with an optional left or right
1039 -- currency symbol and optional price, or return the special
1040 -- "missing" marker amount.
1041 spaceandamountormissing :: Stream [Char] m Char => ParsecT [Char] Context m MixedAmount
1042 spaceandamountormissing = do
1043 default_cs <- getDefaultCommodityAndStyle
1045 many1 spacenonewline
1046 (Mixed . (:[])) `fmap` parseAmount default_cs <|> return missingmixedamt
1047 ) <|> return missingmixedamt
1049 partialbalanceassertion :: Stream [Char] m t => ParsecT [Char] Context m (Maybe MixedAmount)
1050 partialbalanceassertion = do
1051 default_cs <- getDefaultCommodityAndStyle
1056 a <- parseAmount default_cs -- XXX should restrict to a simple amount
1057 return $ Just $ Mixed [a])
1060 -- balanceassertion :: Stream [Char] m Char => ParsecT [Char] Context m (Maybe MixedAmount)
1061 -- balanceassertion =
1062 -- default_cs <- getDefaultCommodityAndStyle
1064 -- many spacenonewline
1066 -- many spacenonewline
1067 -- a <- parseAmount default_cs -- XXX should restrict to a simple amount
1068 -- return $ Just $ Mixed [a])
1069 -- <|> return Nothing
1071 -- http://ledger-cli.org/3.0/doc/ledger3.html#Fixing-Lot-Prices
1072 fixedlotprice :: Stream [Char] m Char => ParsecT [Char] Context m (Maybe Amount)
1074 default_cs <- getDefaultCommodityAndStyle
1081 a <- parseAmount default_cs -- XXX should restrict to a simple amount
1089 multilinecommentp :: Stream [Char] m Char => ParsecT [Char] Context m ()
1090 multilinecommentp = do
1091 string "comment" >> newline
1094 go = try (string "end comment" >> newline >> return ())
1096 anyLine = anyChar `manyTill` newline
1098 emptyorcommentlinep :: Stream [Char] m Char => ParsecT [Char] Context m ()
1099 emptyorcommentlinep = do
1100 many spacenonewline >> (parseComment <|> (many spacenonewline >> newline >> return ""))
1103 followingcommentp :: Stream [Char] m Char => ParsecT [Char] Context m String
1105 -- ptrace "followingcommentp"
1106 do samelinecomment <- many spacenonewline >> (try semicoloncomment <|> (newline >> return ""))
1107 newlinecomments <- many (try (many1 spacenonewline >> semicoloncomment))
1108 return $ unlines $ samelinecomment:newlinecomments
1110 parseComment :: Stream [Char] m Char => ParsecT [Char] Context m String
1111 parseComment = commentStartingWith commentchars
1113 commentchars :: [Char]
1114 commentchars = "#;*"
1116 semicoloncomment :: Stream [Char] m Char => ParsecT [Char] Context m String
1117 semicoloncomment = commentStartingWith ";"
1119 commentStartingWith :: Stream [Char] m Char => String -> ParsecT [Char] Context m String
1120 commentStartingWith cs = do
1121 -- ptrace "commentStartingWith"
1124 l <- anyChar `manyTill` eolof
1128 tagsInComment :: String -> [Tag]
1129 tagsInComment c = concatMap tagsInCommentLine $ lines c'
1131 c' = ledgerDateSyntaxToTags c
1133 tagsInCommentLine :: String -> [Tag]
1134 tagsInCommentLine = catMaybes . map maybetag . map strip . splitAtElement ','
1136 maybetag s = case runParser (parseTag <* eof) contextNull "" s of
1141 -- ptrace "parseTag"
1147 -- ptrace "parseTagName"
1148 n <- many1 $ noneOf ": \t"
1153 -- ptrace "parseTagValue"
1154 v <- anyChar `manyTill` ((char ',' >> return ()) <|> eolof)
1155 return $ strip $ reverse $ dropWhile (==',') $ reverse $ strip v
1157 ledgerDateSyntaxToTags :: String -> String
1158 ledgerDateSyntaxToTags =
1159 regexReplaceBy "\\[[-.\\/0-9=]+\\]" replace
1161 replace ('[':s) | lastDef ' ' s == ']' = replace' $ init s
1164 replace' s | isdate s = datetag s
1165 replace' ('=':s) | isdate s = date2tag s
1166 replace' s | last s =='=' && isdate (init s) = datetag (init s)
1167 replace' s | length ds == 2 && isdate d1 && isdate d1 = datetag d1 ++ date2tag d2
1169 ds = splitAtElement '=' s
1174 isdate = isJust . parsedateM
1175 datetag s = "date:"++s++", "
1176 date2tag s = "date2:"++s++", "
1178 dateValueFromTags, date2ValueFromTags :: [Tag] -> Maybe String
1179 dateValueFromTags ts = maybe Nothing (Just . snd) $ find ((=="date") . fst) ts
1180 date2ValueFromTags ts = maybe Nothing (Just . snd) $ find ((=="date2") . fst) ts