1 {-# LANGUAGE DeriveDataTypeable #-}
2 {-# LANGUAGE FlexibleContexts #-}
3 {-# LANGUAGE NamedFieldPuns #-}
4 {-# LANGUAGE OverloadedStrings #-}
5 {-# LANGUAGE ScopedTypeVariables #-}
6 {-# LANGUAGE TupleSections #-}
7 module Hcompta.Format.Ledger.Read where
9 -- import Control.Applicative ((<$>), (<*>), (<*))
10 import qualified Control.Exception as Exception
11 import Control.Arrow ((***))
12 import Control.Monad (guard, join, liftM)
13 import Control.Monad.IO.Class (liftIO)
14 import Control.Monad.Trans.Except (ExceptT(..), throwE)
15 import Control.Monad.Trans.Class (lift)
16 import qualified Data.Char
17 import qualified Data.Either
18 import qualified Data.List
19 import Data.List.NonEmpty (NonEmpty(..))
20 import qualified Data.Map.Strict as Data.Map
21 import Data.Maybe (fromMaybe)
22 import Data.String (fromString)
23 import qualified Data.Time.Calendar as Time
24 import qualified Data.Time.Clock as Time
25 import qualified Data.Time.LocalTime as Time
26 import Data.Typeable ()
27 import qualified Text.Parsec as R hiding
39 import Text.Parsec (Stream, ParsecT, (<|>), (<?>))
40 import qualified Text.Parsec.Pos as R
41 import qualified Data.Text.IO as Text.IO (readFile)
42 import qualified Data.Text as Text
43 import qualified System.FilePath.Posix as Path
45 import qualified Hcompta.Balance as Balance
46 import qualified Hcompta.Account as Account
47 import Hcompta.Account (Account)
48 import qualified Hcompta.Amount as Amount
49 import qualified Hcompta.Amount.Style as Style
50 import qualified Hcompta.Amount.Read as Amount.Read
51 import qualified Hcompta.Amount.Unit as Unit
52 import qualified Hcompta.Date as Date
53 import Hcompta.Date (Date)
54 import qualified Hcompta.Date.Read as Date.Read
55 import qualified Hcompta.Format.Ledger as Ledger
56 import Hcompta.Format.Ledger
59 , Posting(..), Posting_Type(..)
60 , Tag, Tag_Name, Tag_Value, Tag_by_Name
63 import qualified Hcompta.Lib.Regex as Regex
64 import Hcompta.Lib.Regex (Regex)
65 import qualified Hcompta.Lib.Parsec as R
66 import qualified Hcompta.Lib.Path as Path
70 { context_account_prefix :: !(Maybe Account)
71 , context_aliases_exact :: !(Data.Map.Map Account Account)
72 , context_aliases_joker :: ![(Account.Joker, Account)]
73 , context_aliases_regex :: ![(Regex, Account)]
74 , context_date :: !Date
75 , context_unit_and_style :: !(Maybe (Amount.Unit, Amount.Style))
76 , context_journal :: !Journal
77 , context_year :: !Date.Year
80 nil_Context :: Context
83 { context_account_prefix = Nothing
84 , context_aliases_exact = Data.Map.empty
85 , context_aliases_joker = []
86 , context_aliases_regex = []
87 , context_date = Date.nil
88 , context_unit_and_style = Nothing
89 , context_journal = Ledger.journal
90 , context_year = (\(year, _ , _) -> year) $
91 Time.toGregorian $ Time.utctDay $
92 journal_last_read_time Ledger.journal
96 = Error_date Date.Read.Error
97 | Error_transaction_not_equilibrated Transaction [Balance.Unit_Sum (Balance.Posting_Amount Ledger.Posting)]
98 | Error_virtual_transaction_not_equilibrated Transaction [Balance.Unit_Sum (Balance.Posting_Amount Ledger.Posting)]
99 | Error_reading_file FilePath Exception.IOException
100 | Error_including_file FilePath [R.Error Error]
105 account_name_sep :: Char
106 account_name_sep = ':'
108 -- | Read an 'Account'.
109 account :: Stream s m Char => ParsecT s u m Account
111 R.notFollowedBy $ R.space_horizontal
112 Account.from_List <$> do
113 R.many1_separated account_name $ R.char account_name_sep
115 -- | Read an Account.'Account.Name'.
116 account_name :: Stream s m Char => ParsecT s u m Account.Name
119 R.many1 $ R.try account_name_char
121 account_name_char :: Stream s m Char => ParsecT s u m Char
122 account_name_char = do
125 _ | c == comment_begin -> R.parserZero
126 _ | c == account_name_sep -> R.parserZero
127 _ | R.is_space_horizontal c -> do
128 _ <- R.notFollowedBy $ R.space_horizontal
129 return c <* (R.lookAhead $ R.try $
130 ( R.try (R.char account_name_sep)
131 <|> account_name_char
133 _ | not (Data.Char.isSpace c) -> return c
136 -- | Read an Account.'Account.Joker_Name'.
137 account_joker_name :: Stream s m Char => ParsecT s u m Account.Joker_Name
138 account_joker_name = do
139 n <- R.option Nothing $ (Just <$> account_name)
141 Nothing -> R.char account_name_sep >> (return $ Account.Joker_Any)
142 Just n' -> return $ Account.Joker_Name n'
144 -- | Read an Account.'Account.Joker'.
145 account_joker :: Stream s m Char => ParsecT s u m Account.Joker
147 R.notFollowedBy $ R.space_horizontal
148 R.many1_separated account_joker_name $ R.char account_name_sep
151 account_regex :: Stream s m Char => ParsecT s u m Regex
153 re <- R.many1 $ R.satisfy (not . R.is_space_horizontal)
156 -- | Read an Account.'Account.Filter'.
157 account_pattern :: Stream s m Char => ParsecT s u m Account.Pattern
160 [ Account.Pattern_Exact <$> (R.char '=' >> account)
161 , Account.Pattern_Joker <$> (R.char '*' >> account_joker)
162 , Account.Pattern_Regex <$> (R.option '~' (R.char '~') >> account_regex)
167 directive_alias :: Stream s m Char => ParsecT s Context m ()
169 _ <- R.string "alias"
170 R.skipMany1 $ R.space_horizontal
171 pattern <- account_pattern
172 R.skipMany $ R.space_horizontal
174 R.skipMany $ R.space_horizontal
176 R.skipMany $ R.space_horizontal
178 Account.Pattern_Exact acct -> R.modifyState $ \ctx -> ctx{context_aliases_exact=
179 Data.Map.insert acct repl $ context_aliases_exact ctx}
180 Account.Pattern_Joker jokr -> R.modifyState $ \ctx -> ctx{context_aliases_joker=
181 (jokr, repl):context_aliases_joker ctx}
182 Account.Pattern_Regex regx -> R.modifyState $ \ctx -> ctx{context_aliases_regex=
183 (regx, repl):context_aliases_regex ctx}
189 comment_begin :: Char
192 comment :: Stream s m Char => ParsecT s u m Comment
194 _ <- R.char comment_begin
196 R.manyTill R.anyChar (R.lookAhead R.new_line <|> R.eof)
199 comments :: Stream s m Char => ParsecT s u m [Comment]
203 R.many1_separated comment (R.new_line >> R.skipMany R.space_horizontal)
209 tag_value_sep :: Char
216 tag :: Stream s m Char => ParsecT s u m Tag
219 _ <- R.char tag_value_sep
224 tag_name :: Stream s m Char => ParsecT s u m Tag_Name
227 R.many1 $ R.satisfy (\c -> c /= tag_value_sep && not (Data.Char.isSpace c))
229 tag_value :: Stream s m Char => ParsecT s u m Tag_Value
232 R.manyTill R.anyChar $ do
234 R.try (R.char tag_sep >> R.many R.space_horizontal >> tag_name >> R.char tag_value_sep >> return ())
238 tags :: Stream s m Char => ParsecT s u m Tag_by_Name
240 Ledger.tag_by_Name <$> do
241 R.many_separated tag $ do
243 R.skipMany $ R.space_horizontal
246 not_tag :: Stream s m Char => ParsecT s u m ()
248 R.skipMany $ R.try $ do
249 R.skipMany $ R.satisfy
250 (\c -> c /= tag_value_sep
251 && not (Data.Char.isSpace c))
257 :: (Stream s (R.Error_State Error m) Char, Monad m)
258 => ParsecT s Context (R.Error_State Error m) (Posting, Posting_Type)
261 sourcepos <- R.getPosition
262 R.skipMany1 $ R.space_horizontal
264 R.skipMany $ R.space_horizontal
266 let (type_, account_) = posting_type acct
270 _ <- R.count 2 R.space_horizontal
271 R.skipMany $ R.space_horizontal
273 if u == Unit.nil then id
275 Data.Map.adjust (\a ->
276 a{ Amount.style = s{Style.precision = Style.precision $ Amount.style a}
279 (context_unit_and_style ctx) .
280 Amount.from_List <$> do
281 R.many_separated Amount.Read.amount $ do
282 R.skipMany $ R.space_horizontal
283 _ <- R.char amount_sep
284 R.skipMany $ R.space_horizontal
286 , return Data.Map.empty
288 R.skipMany $ R.space_horizontal
289 -- TODO: balance assertion
291 comments_ <- comments
292 let tags_ = tags_of_comments comments_
294 case Data.Map.lookup "date" tags_ of
297 let date2s = Data.Map.lookup "date2" tags_ -- NOTE: support hledger's date2
299 (flip mapM) (dates ++ fromMaybe [] date2s) $ \s ->
300 R.runParserT_with_Error_fail "tag date" id
301 (Date.Read.date Error_date (Just $ context_year ctx) <* R.eof) ()
303 >>= \dates_ -> case (dates, date2s) of -- NOTE: put hledger's date2 at least in second position
305 return $ context_date ctx:dates_
308 { posting_account=account_
309 , posting_amounts=amounts_
310 , posting_comments=comments_
311 , posting_dates=dates_
312 , posting_sourcepos=sourcepos
313 , posting_status=status_
321 tags_of_comments :: [Comment] -> Tag_by_Name
323 Data.Map.unionsWith (++)
325 ( Data.Either.either (const Data.Map.empty) id
326 . R.runParser (not_tag >> tags <* R.eof) () "" )
328 status :: Stream s m Char => ParsecT s u m Ledger.Status
331 R.skipMany $ R.space_horizontal
332 _ <- (R.char '*' <|> R.char '!')
337 -- | Return the 'Posting_Type' and stripped 'Account' of the given 'Account'.
338 posting_type :: Account -> (Posting_Type, Account)
340 fromMaybe (Posting_Type_Regular, acct) $ do
343 case Text.stripPrefix virtual_begin name of
346 Text.stripSuffix virtual_end name'
347 >>= return . Text.strip
348 guard $ not $ Text.null name''
349 Just (Posting_Type_Virtual, name'':|[])
352 Text.stripPrefix virtual_balanced_begin name
353 >>= Text.stripSuffix virtual_balanced_end
354 >>= return . Text.strip
355 guard $ not $ Text.null name'
356 Just (Posting_Type_Virtual_Balanced, name':|[])
357 first_name:|acct' -> do
358 let rev_acct' = Data.List.reverse acct'
359 let last_name = Data.List.head rev_acct'
360 case Text.stripPrefix virtual_begin first_name
361 >>= return . Text.stripStart of
362 Just first_name' -> do
364 Text.stripSuffix virtual_end last_name
365 >>= return . Text.stripEnd
366 guard $ not $ Text.null first_name'
367 guard $ not $ Text.null last_name'
369 ( Posting_Type_Virtual
371 Data.List.reverse (last_name':Data.List.tail rev_acct')
375 Text.stripPrefix virtual_balanced_begin first_name
376 >>= return . Text.stripStart
378 Text.stripSuffix virtual_balanced_end last_name
379 >>= return . Text.stripEnd
380 guard $ not $ Text.null first_name'
381 guard $ not $ Text.null last_name'
383 ( Posting_Type_Virtual_Balanced
385 Data.List.reverse (last_name':Data.List.tail rev_acct')
388 virtual_begin = Text.singleton posting_type_virtual_begin
389 virtual_end = Text.singleton posting_type_virtual_end
390 virtual_balanced_begin = Text.singleton posting_type_virtual_balanced_begin
391 virtual_balanced_end = Text.singleton posting_type_virtual_balanced_end
393 posting_type_virtual_begin :: Char
394 posting_type_virtual_begin = '('
395 posting_type_virtual_balanced_begin :: Char
396 posting_type_virtual_balanced_begin = '['
397 posting_type_virtual_end :: Char
398 posting_type_virtual_end = ')'
399 posting_type_virtual_balanced_end :: Char
400 posting_type_virtual_balanced_end = ']'
402 -- * Read 'Transaction'
405 :: (Stream s (R.Error_State Error m) Char, Monad m)
406 => ParsecT s Context (R.Error_State Error m) Transaction
409 transaction_sourcepos <- R.getPosition
410 transaction_comments_before <-
414 _ -> return x <* R.new_line
415 date_ <- Date.Read.date Error_date (Just $ context_year ctx)
417 R.option [] $ R.try $ do
418 R.skipMany $ R.space_horizontal
420 R.skipMany $ R.space_horizontal
422 (Date.Read.date Error_date (Just $ context_year ctx)) $
424 R.many $ R.space_horizontal
426 >> (R.many $ R.space_horizontal)
427 let transaction_dates = (date_, dates_)
428 R.skipMany $ R.space_horizontal
429 transaction_status <- status
430 transaction_code <- R.option "" $ R.try code
431 R.skipMany $ R.space_horizontal
432 transaction_description <- description
433 R.skipMany $ R.space_horizontal
434 transaction_comments_after <- comments
435 let transaction_tags =
436 Data.Map.unionWith (++)
437 (tags_of_comments transaction_comments_before)
438 (tags_of_comments transaction_comments_after)
440 (postings_unchecked, postings_not_regular) <-
441 ((Ledger.posting_by_Account . Data.List.map fst) *** id) .
442 Data.List.partition ((Posting_Type_Regular ==) . snd) <$>
443 R.many1_separated posting R.new_line
444 let (transaction_virtual_postings, balanced_virtual_postings_unchecked) =
445 join (***) (Ledger.posting_by_Account . Data.List.map fst) $
446 Data.List.partition ((Posting_Type_Virtual ==) . snd)
451 , transaction_comments_before
452 , transaction_comments_after
454 , transaction_description
455 , transaction_postings=postings_unchecked
456 , transaction_virtual_postings
457 , transaction_balanced_virtual_postings=balanced_virtual_postings_unchecked
458 , transaction_sourcepos
462 transaction_postings <-
463 case Balance.infer_equilibrium postings_unchecked of
464 (_, Left ko) -> R.fail_with "transaction infer_equilibrium"
465 (Error_transaction_not_equilibrated tr_unchecked ko)
466 (_bal, Right ok) -> return ok
467 transaction_balanced_virtual_postings <-
468 case Balance.infer_equilibrium balanced_virtual_postings_unchecked of
469 (_, Left ko) -> R.fail_with "transaction infer_equilibrium"
470 (Error_virtual_transaction_not_equilibrated tr_unchecked ko)
471 (_bal, Right ok) -> return ok
474 { transaction_postings
475 , transaction_balanced_virtual_postings
482 code :: Stream s m Char => ParsecT s Context m Ledger.Code
485 R.skipMany $ R.space_horizontal
486 R.between (R.char '(') (R.char ')') $
487 R.many $ R.satisfy (\c -> c /= ')' && not (R.is_space_horizontal c))
490 description :: Stream s m Char => ParsecT s u m Ledger.Description
493 R.many $ R.try description_char
496 description_char :: Stream s m Char => ParsecT s u m Char
497 description_char = do
500 _ | c == comment_begin -> R.parserZero
501 _ | R.is_space_horizontal c -> return c <* (R.lookAhead $ R.try $ description_char)
502 _ | not (Data.Char.isSpace c) -> return c
507 default_year :: Stream s m Char => ParsecT s Context m ()
509 year <- R.integer_of_digits 10 <$> R.many1 R.digit
510 R.skipMany R.space_horizontal >> R.new_line
511 context_ <- R.getState
512 R.setState context_{context_year=year}
515 default_unit_and_style :: Stream s m Char => ParsecT s Context m ()
516 default_unit_and_style = (do
517 amount_ <- Amount.Read.amount
518 R.skipMany R.space_horizontal >> R.new_line
519 context_ <- R.getState
520 R.setState context_{context_unit_and_style =
522 ( Amount.unit amount_
523 , Amount.style amount_ )}
524 ) <?> "default unit and style"
527 :: Stream s (R.Error_State Error IO) Char
528 => ParsecT s Context (R.Error_State Error IO) ()
530 sourcepos <- R.getPosition
531 filename <- R.manyTill R.anyChar (R.lookAhead R.new_line <|> R.eof)
532 context_ <- R.getState
533 let journal_ = context_journal context_
534 let cwd = Path.takeDirectory (R.sourceName sourcepos)
535 file_path <- liftIO $ Path.abs cwd filename
537 join $ liftIO $ Exception.catch
538 (liftM return $ readFile file_path)
539 (return . R.fail_with "include reading" . Error_reading_file file_path)
540 (journal_included, context_included) <- do
542 R.runParserT_with_Error (R.and_state $ journal_rec file_path)
543 context_{context_journal = Ledger.journal}
546 Right ok -> return ok
547 Left ko -> R.fail_with "include parsing" (Error_including_file file_path ko)
549 context_included{context_journal=
550 journal_{journal_includes=
551 journal_included{journal_file=file_path}
552 : journal_includes journal_}}
558 :: Stream s (R.Error_State Error IO) Char
560 -> ParsecT s Context (R.Error_State Error IO) Journal
562 currentLocalTime <- liftIO $
564 <$> Time.getCurrentTimeZone
565 <*> Time.getCurrentTime
566 let (currentLocalYear, _, _) = Time.toGregorian $ Time.localDay currentLocalTime
567 context_ <- R.getState
568 R.setState $ context_{context_year=currentLocalYear}
573 :: Stream s (R.Error_State Error IO) Char
575 -> ParsecT s Context (R.Error_State Error IO) Journal
576 journal_rec file_ = do
577 last_read_time <- lift $ liftIO Time.getCurrentTime
580 [ R.skipMany1 R.space
582 [ R.string "Y" >> return default_year
583 , R.string "D" >> return default_unit_and_style
584 , R.string "!include" >> return include
586 >>= \r -> R.skipMany1 R.space_horizontal >> r)
589 context_' <- R.getState
590 let j = context_journal context_'
591 R.setState $ context_'{context_journal=
592 j{journal_transactions=
593 Data.Map.insertWith (flip (++))
594 -- NOTE: flip-ing preserves order but slows down
595 -- when many transactions have the very same date.
596 (fst $ transaction_dates t) [t]
597 (journal_transactions j)}}
598 R.new_line <|> R.eof))
599 , R.try (comment >> return ())
602 journal_ <- context_journal <$> R.getState
605 { journal_file = file_
606 , journal_last_read_time=last_read_time
607 , journal_includes = reverse $ journal_includes journal_
610 -- ** Read 'Journal' from a file
612 file :: FilePath -> ExceptT [R.Error Error] IO Journal
616 (liftM Right $ Text.IO.readFile path) $
617 \ko -> return $ Left $
618 [ R.Error_Custom (R.initialPos path) $ Error_reading_file path ko ]
619 >>= liftIO . R.runParserT_with_Error (journal path) nil_Context path
621 Left ko -> throwE $ ko
622 Right ok -> ExceptT $ return $ Right ok