1 {-# LANGUAGE DeriveDataTypeable #-}
2 {-# LANGUAGE FlexibleContexts #-}
3 {-# LANGUAGE LambdaCase #-}
4 {-# LANGUAGE NamedFieldPuns #-}
5 {-# LANGUAGE OverloadedStrings #-}
6 {-# LANGUAGE ScopedTypeVariables #-}
7 {-# LANGUAGE TupleSections #-}
8 module Hcompta.Format.Ledger.Read where
10 import Control.Applicative ((<*), (<$>))
11 import qualified Control.Exception as Exception
12 import Control.Monad (guard, (>=>), liftM)
13 import Control.Monad.IO.Class (liftIO)
14 import Control.Monad.Trans.Except (ExceptT(..), throwE)
15 import qualified Data.Char
16 import qualified Data.Decimal
17 import qualified Data.Either
18 import qualified Data.List
19 import qualified Data.Map.Strict as Data.Map
20 import Data.Maybe (fromMaybe)
21 import qualified Data.Time.Calendar as Time
22 import qualified Data.Time.Clock as Time
23 import qualified Data.Time.LocalTime as Time
24 import Data.Time.LocalTime (TimeZone(..))
25 import Data.Typeable ()
26 import qualified Text.Parsec as P
27 import Text.Parsec (Stream, ParsecT, (<|>), (<?>))
28 import qualified Data.Text.IO as Text.IO (readFile)
29 import qualified Data.Text as Text (pack)
30 import System.Directory (getHomeDirectory)
31 import qualified System.FilePath.Posix as Path
32 import System.FilePath ((</>))
34 import qualified Hcompta.Model.Account as Account
35 import Hcompta.Model.Account (Account)
36 import qualified Hcompta.Model.Amount as Amount
37 import Hcompta.Model.Amount (Amount)
38 import qualified Hcompta.Model.Amount.Style as Style
39 import qualified Hcompta.Model.Amount.Unit as Unit
40 import Hcompta.Model.Amount.Unit (Unit)
41 import qualified Hcompta.Model.Transaction as Transaction
42 import Hcompta.Model.Transaction (Transaction)
43 import qualified Hcompta.Model.Transaction.Posting as Posting
44 import Hcompta.Model.Transaction (Posting)
45 import qualified Hcompta.Model.Transaction.Tag as Tag
46 import Hcompta.Model.Transaction (Tag)
47 import qualified Hcompta.Model.Date as Date
48 import Hcompta.Model.Date (Date)
49 import Hcompta.Format.Ledger.Journal as Journal
50 import qualified Hcompta.Lib.Regex as Regex
51 import Hcompta.Lib.Regex (Regex)
55 { context_account_prefix :: !Account
56 , context_aliases_exact :: !(Data.Map.Map Account Account)
57 , context_aliases_joker :: ![(Account.Joker, Account)]
58 , context_aliases_regex :: ![(Regex, Account)]
59 , context_date :: !Date
60 , context_unit_and_style :: !(Maybe (Amount.Unit, Amount.Style))
61 , context_journal :: !Journal
62 , context_year :: !Date.Year
65 nil_Context :: Context
68 { context_account_prefix = []
69 , context_aliases_exact = Data.Map.empty
70 , context_aliases_joker = []
71 , context_aliases_regex = []
72 , context_date = Date.nil
73 , context_unit_and_style = Nothing
74 , context_journal = Journal.nil
75 , context_year = (\(year, _ , _) -> year) $
76 Time.toGregorian $ Time.utctDay $
77 Journal.last_read_time Journal.nil
84 -- | Like 'Text.Parsec.choice' but with 'Text.Parsec.try' on each case.
85 choice_try :: Stream s m t => [ParsecT s st m a] -> ParsecT s st m a
86 choice_try = Data.List.foldr (\a -> (<|>) (P.try a)) P.parserZero
87 -- choice_try = P.choice . Data.List.map P.try
89 -- | Like 'Text.Parsec.sepBy' but without parsing an ending separator.
95 many_separated p sep =
96 many1_separated p sep <|> return []
98 -- | Like 'Text.Parsec.sepBy1' but without parsing an ending separator.
103 -> ParsecT s st m [a]
104 many1_separated p sep = do
106 xs <- P.many (P.try (sep >> p))
108 -- (:) <$> p <*> P.many (P.try (sep >> p))
113 -> ParsecT s st m (a, st)
119 -- | Return an absolute 'FilePath', given the current working directory and a path.
121 -- * "~" as prefix is expanded to the process's user's home directory
122 -- * "-" as path is unchanged
123 -- * ~USER is not supported
124 path_abs :: FilePath -> FilePath -> IO FilePath
125 path_abs _ "-" = return "-"
128 (if Path.isRelative path
133 expand :: FilePath -> IO FilePath
135 if Path.isPathSeparator sep
136 then liftIO $ (</> p) <$> getHomeDirectory
137 else fail "~USERNAME in path is not supported"
142 -- | Return the 'Integer' obtained by multiplying the given digits
143 -- with the power of the given base respective to their rank.
145 :: Integer -- ^ Base.
146 -> [Char] -- ^ Digits (MUST be recognised by 'Data.Char.digitToInt').
148 integer_of_digits base =
149 Data.List.foldl (\x d ->
150 base*x + toInteger (Data.Char.digitToInt d)) 0
152 decimal :: Stream s m Char => ParsecT s st m Integer
153 decimal = integer 10 P.digit
154 hexadecimal :: Stream s m Char => ParsecT s st m Integer
155 hexadecimal = P.oneOf "xX" >> integer 16 P.hexDigit
156 octal :: Stream s m Char => ParsecT s st m Integer
157 octal = P.oneOf "oO" >> integer 8 P.octDigit
159 -- | Parse an 'Integer'.
160 integer :: Stream s m t
161 => Integer -> ParsecT s st m Char
162 -> ParsecT s st m Integer
163 integer base digit = do
164 digits <- P.many1 digit
165 let n = integer_of_digits base digits
168 -- | Parse either '-' into 'negate', or '+' or '' into 'id'.
169 sign :: (Stream s m Char, Num i) => ParsecT s st m (i -> i)
171 (P.char '-' >> return negate) <|>
172 (P.char '+' >> return id) <|>
177 -- | Return 'True' if and only if the given 'Char' is an horizontal space.
178 is_space_horizontal :: Char -> Bool
179 is_space_horizontal c = c /= '\n' && c /= '\r' && Data.Char.isSpace c
181 space_horizontal :: Stream s m Char => ParsecT s st m Char
182 {-# INLINEABLE space_horizontal #-}
183 space_horizontal = P.satisfy is_space_horizontal <?> "horizontal space"
185 newline :: Stream s m Char => ParsecT s st m ()
186 newline = ((P.try (P.string "\r\n") <|> P.string "\n") >> return ()) <?> "newline"
188 -- * Parsing 'Account'.
190 account_name_sep :: Char
191 account_name_sep = ':'
193 -- | Parse an 'Account'.
194 account :: Stream s m Char => ParsecT s st m Account
196 P.notFollowedBy $ space_horizontal
197 many1_separated account_name $ P.char account_name_sep
199 -- | Parse an Account.'Account.Name'.
200 account_name :: Stream s m Char => ParsecT s st m Account.Name
203 P.many1 $ P.try account_name_char
205 account_name_char :: Stream s m Char => ParsecT s st m Char
206 account_name_char = do
209 _ | c == comment_begin -> P.parserZero
210 _ | c == account_name_sep -> P.parserZero
211 _ | c == posting_type_virtual_end
212 || c == posting_type_virtual_balanced_end ->
213 return c <* (P.lookAhead $ account_name_char)
214 _ | is_space_horizontal c -> do
215 _ <- P.notFollowedBy $ space_horizontal
216 return c <* (P.lookAhead
217 ( P.try (P.char account_name_sep)
218 <|> account_name_char
220 _ | not (Data.Char.isSpace c) -> return c
223 -- | Parse an Account.'Account.Joker_Name'.
224 account_joker_name :: Stream s m Char => ParsecT s st m Account.Joker_Name
225 account_joker_name = do
226 n <- P.option Nothing $ (Just <$> account_name)
228 Nothing -> P.char account_name_sep >> (return $ Account.Joker_Any)
229 Just n' -> return $ Account.Joker_Name n'
231 -- | Parse an Account.'Account.Joker'.
232 account_joker :: Stream s m Char => ParsecT s st m Account.Joker
234 P.notFollowedBy $ space_horizontal
235 many1_separated account_joker_name $ P.char account_name_sep
237 -- | Parse a 'Regex'.
238 account_regex :: Stream s m Char => ParsecT s st m Regex
240 re <- P.many1 $ P.satisfy (not . is_space_horizontal)
243 -- | Parse an Account.'Account.Filter'.
244 account_pattern :: Stream s m Char => ParsecT s st m Account.Pattern
247 [ Account.Pattern_Exact <$> (P.char '=' >> account)
248 , Account.Pattern_Joker <$> (P.char '*' >> account_joker)
249 , Account.Pattern_Regex <$> (P.option '~' (P.char '~') >> account_regex)
252 -- * Parsing 'Amount'.
254 -- | Parse an 'Amount'.
255 amount :: Stream s m Char => ParsecT s st m Amount
259 P.option Nothing $ do
261 s <- P.many $ space_horizontal
262 return $ Just $ (u, not $ null s)
263 (quantity_, style) <- do
270 , grouping_fractional
273 [ quantity '_' ',' '_' <* (P.notFollowedBy $ P.oneOf ",._")
274 , quantity '_' '.' '_' <* (P.notFollowedBy $ P.oneOf ",._")
275 , quantity ',' '.' '_' <* (P.notFollowedBy $ P.oneOf ",._")
276 , quantity '.' ',' '_' <* (P.notFollowedBy $ P.oneOf ",._")
278 let int = Data.List.concat integral
279 let frac_flat = Data.List.concat fractional
280 let frac = reverse $ Data.List.dropWhile (== '0') $ reverse $ frac_flat
281 let place = length frac
283 let mantissa = integer_of_digits 10 $ int ++ frac
285 ( Data.Decimal.Decimal
289 { Style.fractioning = fractioning
290 , Style.grouping_integral = grouping_integral
291 , Style.grouping_fractional = grouping_fractional
292 , Style.precision = fromIntegral $ length frac_flat
295 (unit_, side, spaced) <-
298 return (u, Just Style.Side_Left, Just s)
300 P.option (Unit.nil, Nothing, Nothing) $ do
301 s <- P.many $ space_horizontal
303 return $ (u, Just Style.Side_Right, Just $ not $ null s)
306 { Amount.quantity = left_signing $ quantity_
307 , Amount.style = style
308 { Style.unit_side = side
309 , Style.unit_spaced = spaced
311 , Amount.unit = unit_
316 { integral :: [String]
317 , fractional :: [String]
318 , fractioning :: Maybe Style.Fractioning
319 , grouping_integral :: Maybe Style.Grouping
320 , grouping_fractional :: Maybe Style.Grouping
323 -- | Parse a 'Quantity'.
326 => Char -- ^ Integral grouping separator.
327 -> Char -- ^ Fractioning separator.
328 -> Char -- ^ Fractional grouping separator.
329 -> ParsecT s st m Quantity
330 quantity int_group_sep frac_sep frac_group_sep = do
331 (integral, grouping_integral) <- do
334 [] -> return ([], Nothing)
336 t <- P.many $ P.char int_group_sep >> P.many1 P.digit
338 return (digits, grouping_of_digits int_group_sep digits)
339 (fractional, fractioning, grouping_fractional) <-
342 _ -> P.option ([], Nothing, Nothing)) $ do
343 fractioning <- P.char frac_sep
345 t <- P.many $ P.char frac_group_sep >> P.many1 P.digit
347 return (digits, Just fractioning
348 , grouping_of_digits frac_group_sep $ reverse digits)
355 , grouping_fractional
358 grouping_of_digits :: Char -> [String] -> Maybe Style.Grouping
359 grouping_of_digits group_sep digits =
364 Style.Grouping group_sep $
365 canonicalize_grouping $
367 canonicalize_grouping :: [Int] -> [Int]
368 canonicalize_grouping groups =
369 Data.List.foldl -- NOTE: remove duplicates at beginning and reverse.
370 (\acc l0 -> case acc of
371 l1:_ -> if l0 == l1 then acc else l0:acc
373 case groups of -- NOTE: keep only longer at beginning.
374 l0:l1:t -> if l0 > l1 then groups else l1:t
377 -- | Parse an 'Unit'.
378 unit :: Stream s m Char => ParsecT s st m Unit
380 (quoted <|> unquoted) <?> "unit"
382 unquoted :: Stream s m Char => ParsecT s st m Unit
387 case Data.Char.generalCategory c of
388 Data.Char.CurrencySymbol -> True
389 Data.Char.LowercaseLetter -> True
390 Data.Char.ModifierLetter -> True
391 Data.Char.OtherLetter -> True
392 Data.Char.TitlecaseLetter -> True
393 Data.Char.UppercaseLetter -> True
395 quoted :: Stream s m Char => ParsecT s st m Unit
398 P.between (P.char '"') (P.char '"') $
404 directive_alias :: Stream s m Char => ParsecT s Context m ()
406 _ <- P.string "alias"
407 P.skipMany1 $ space_horizontal
408 pattern <- account_pattern
409 P.skipMany $ space_horizontal
411 P.skipMany $ space_horizontal
413 P.skipMany $ space_horizontal
415 Account.Pattern_Exact acct -> P.modifyState $ \ctx -> ctx{context_aliases_exact=
416 Data.Map.insert acct repl $ context_aliases_exact ctx}
417 Account.Pattern_Joker jokr -> P.modifyState $ \ctx -> ctx{context_aliases_joker=
418 (jokr, repl):context_aliases_joker ctx}
419 Account.Pattern_Regex regx -> P.modifyState $ \ctx -> ctx{context_aliases_regex=
420 (regx, repl):context_aliases_regex ctx}
423 -- | Parse the year, month and day separator: '/' or '-'.
424 date_separator :: Stream s m Char => ParsecT s st m Char
425 date_separator = P.satisfy (\c -> c == '/' || c == '-')
427 -- | Parse the hour, minute and second separator: ':'.
428 hour_separator :: Stream s m Char => ParsecT s st m Char
429 hour_separator = P.char ':'
433 -- | Parse a 'Date' in [YYYY/]MM/DD [HH:MM[:SS][TZ]] format.
434 date :: Stream s m Char => Maybe Integer -> ParsecT s st m Date
436 n0 <- P.many1 P.digit
437 date_sep <- date_separator
438 n1 <- P.try (P.count 2 P.digit) <|> P.count 1 P.digit
439 n2 <- P.option Nothing $ P.try $ do
441 Just <$> do P.try (P.count 2 P.digit) <|> P.count 1 P.digit
443 case (n2, def_year) of
444 (Nothing, Nothing) -> fail "year or day is missing"
445 (Nothing, Just year) -> return (year, n0, n1)
446 (Just d, _) -> return (integer_of_digits 10 n0, n1, d)
447 let month = fromInteger $ integer_of_digits 10 m
448 let day = fromInteger $ integer_of_digits 10 d
449 guard $ month >= 1 && month <= 12
450 guard $ day >= 1 && day <= 31
451 day_ <- case Time.fromGregorianValid year month day of
452 Nothing -> fail "invalid day"
453 Just day_ -> return day_
454 (hour, minu, sec, tz) <-
455 P.option (0, 0, 0, Time.utc) $ P.try $ do
456 P.skipMany1 $ space_horizontal
457 hour <- P.try (P.count 2 P.digit) <|> P.count 1 P.digit
458 sep <- hour_separator
459 minu <- P.try (P.count 2 P.digit) <|> P.count 1 P.digit
460 sec <- P.option Nothing $ P.try $ do
462 Just <$> (P.try (P.count 2 P.digit) <|> P.count 1 P.digit)
464 tz <- P.option Time.utc $ P.try $ do
465 P.skipMany $ space_horizontal
468 ( integer_of_digits 10 hour
469 , integer_of_digits 10 minu
470 , maybe 0 (integer_of_digits 10) sec
472 guard $ hour >= 0 && hour <= 23
473 guard $ minu >= 0 && minu <= 59
474 guard $ sec >= 0 && sec <= 60 -- NOTE: allow leap second
475 tod <- case Time.makeTimeOfDayValid
479 Nothing -> fail "invalid time of day"
480 Just tod -> return tod
483 (Time.LocalTime day_ tod)
487 time_zone :: Stream s m Char => ParsecT s u m TimeZone
489 -- DOC: http://www.timeanddate.com/time/zones/
490 -- TODO: only a few time zones are suported below.
492 [ P.char 'A' >> P.choice
493 [ P.string "ST" >> return (TimeZone ((-4) * 60) False "AST")
494 , P.string "DT" >> return (TimeZone ((-3) * 60) False "ADT")
495 , return (TimeZone ((-1) * 60) False "A")
497 , P.char 'B' >> P.choice
498 [ P.string "ST" >> return (TimeZone ((-11) * 60) False "BST")
499 , P.string "DT" >> return (TimeZone ((-10) * 60) True "BDT")
501 , P.char 'C' >> P.choice
502 [ P.char 'E' >> P.choice
503 [ P.string "T" >> return (TimeZone ((1) * 60) False "CET")
504 , P.string "ST" >> return (TimeZone ((2) * 60) True "CEST")
506 , P.string "ST" >> return (TimeZone ((-6) * 60) False "CST")
507 , P.string "DT" >> return (TimeZone ((-5) * 60) True "CDT")
509 , P.char 'E' >> P.choice
510 [ P.string "ST" >> return (TimeZone ((-5) * 60) False "EST")
511 , P.string "DT" >> return (TimeZone ((-4) * 60) True "EDT")
513 , P.string "GMT" >> return (TimeZone 0 False "GMT")
514 , P.char 'H' >> P.choice
515 [ P.string "ST" >> return (TimeZone ((-10) * 60) False "HST")
516 , P.string "DT" >> return (TimeZone (( -9) * 60) True "HDT")
518 , P.char 'M' >> P.choice
519 [ P.string "ST" >> return (TimeZone ((-7) * 60) False "MST")
520 , P.string "DT" >> return (TimeZone ((-6) * 60) True "MDT")
521 , return (TimeZone ((-12) * 60) False "M")
523 , P.char 'N' >> P.choice
524 [ P.string "ST" >> return (TimeZone ((-3) * 60 - 30) False "NST")
525 , return (TimeZone (1 * 60) False "N")
527 , P.char 'P' >> P.choice
528 [ P.string "ST" >> return (TimeZone ((-8) * 60) False "PST")
529 , P.string "DT" >> return (TimeZone ((-7) * 60) True "PDT")
531 , P.char 'Y' >> P.choice
532 [ P.string "ST" >> return (TimeZone ((-9) * 60) False "YST")
533 , P.string "DT" >> return (TimeZone ((-8) * 60) True "YDT")
534 , return (TimeZone (12 * 60) False "Y")
536 , P.char 'Z' >> return (TimeZone 0 False "Z")
540 time_zone_digits :: Stream s m Char => ParsecT s st m TimeZone
541 {-# INLINEABLE time_zone_digits #-}
542 time_zone_digits = do
544 hour <- integer_of_digits 10 <$> P.count 2 P.digit
545 _ <- P.option ':' (P.char ':')
546 minute <- integer_of_digits 10 <$> P.count 2 P.digit
548 { timeZoneMinutes = sign_ (fromInteger hour * 60 + fromInteger minute)
549 , timeZoneSummerOnly = False
550 , timeZoneName = Time.timeZoneOffsetString tz
554 -- * Parsing 'Comment'.
556 type Comment = Transaction.Comment
558 comment_begin :: Char
561 comment :: Stream s m Char => ParsecT s st m Comment
563 _ <- P.char comment_begin
565 P.manyTill P.anyChar (P.lookAhead newline <|> P.eof)
568 comments :: Stream s m Char => ParsecT s st m [Comment]
570 many_separated comment $
572 P.many1 $ P.satisfy Data.Char.isSpace
576 tag_value_sep :: Char
583 tag :: Stream s m Char => ParsecT s st m Tag
586 _ <- P.char tag_value_sep
591 tag_name :: Stream s m Char => ParsecT s st m Tag.Name
593 P.many1 $ P.satisfy (\c -> c /= tag_value_sep && not (Data.Char.isSpace c))
595 tag_value :: Stream s m Char => ParsecT s st m Tag.Value
598 P.satisfy (\c -> c /= tag_sep && c /= '\n')
600 tags :: Stream s m Char => ParsecT s st m Tag.By_Name
603 many_separated tag $ do
604 P.skipMany $ space_horizontal
606 P.skipMany $ space_horizontal
609 -- * Parsing 'Posting'.
611 -- | Parse a 'Posting'.
612 posting :: Stream s m Char => ParsecT s Context m Posting
615 sourcepos <- P.getPosition
616 P.skipMany1 $ space_horizontal
618 P.skipMany $ space_horizontal
619 (account_, type_) <- account_with_posting_type
623 _ <- P.count 2 (space_horizontal)
624 Amount.from_List <$> do
625 many_separated amount $ P.try $ do
626 P.skipMany $ space_horizontal
628 P.skipMany $ space_horizontal
630 , return Data.Map.empty
632 P.skipMany $ space_horizontal
633 -- TODO: balance assertion
635 comments_ <- comments
636 let tags_ = tags_of_comments comments_
638 case Data.Map.lookup "date" tags_ of
641 let date2s = Data.Map.lookup "date2" tags_ -- NOTE: support hledger's date2
642 dates_ <- (flip mapM) (dates ++ fromMaybe [] date2s) $
643 P.runParserT (date (Just $ context_year ctx) <* P.eof) () ""
645 Left err -> fail $ show err
647 case (dates, date2s) of -- NOTE: put hledger's date2 at least in second position
649 return $ context_date ctx:dates_
651 return Posting.Posting
652 { Posting.account=account_
653 , Posting.amounts=amounts_
654 , Posting.comments=comments_
655 , Posting.dates=dates_
656 , Posting.sourcepos=sourcepos
657 , Posting.status=status_
659 , Posting.type_=type_
663 tags_of_comments :: [Comment] -> Tag.By_Name
665 Data.Map.unionsWith (++)
667 ( Data.Either.either (const Data.Map.empty) id
669 P.skipMany $ P.try $ do
670 P.skipMany $ P.satisfy
671 (\c -> c /= tag_value_sep
672 && not (Data.Char.isSpace c))
677 status :: Stream s m Char => ParsecT s st m Bool
680 P.skipMany $ space_horizontal
681 _ <- (P.char '*' <|> P.char '!') <?> "status"
686 -- | Parse an 'Account' with Posting.'Posting.Type'.
687 account_with_posting_type :: Stream s m Char => ParsecT s st m (Account, Posting.Type)
688 account_with_posting_type = do
690 [ (, Posting.Type_Virtual) <$> P.between (P.char '(') (P.char posting_type_virtual_end) account
691 , (, Posting.Type_Virtual_Balanced) <$> P.between (P.char '[') (P.char posting_type_virtual_balanced_end) account
692 , (, Posting.Type_Regular) <$> account
695 posting_type_virtual_end :: Char
696 posting_type_virtual_end = ')'
697 posting_type_virtual_balanced_end :: Char
698 posting_type_virtual_balanced_end = ']'
700 -- * Parsing 'Transaction'.
702 transaction :: Stream s m Char => ParsecT s Context m Transaction
704 sourcepos <- P.getPosition
706 comments_before <- comments
707 date_ <- date (Just $ context_year ctx)
709 P.option [] $ P.try $ do
710 _ <- P.many $ space_horizontal
712 _ <- P.many $ space_horizontal
714 (date (Just $ context_year ctx)) $
716 P.many $ space_horizontal
718 >> (P.many $ space_horizontal)
719 _ <- P.many $ space_horizontal
721 code_ <- P.option "" $ P.try code
722 P.skipMany $ space_horizontal
723 description_ <- description
724 P.skipMany $ space_horizontal
725 comments_after <- comments
727 Data.Map.unionWith (++) -- TODO: check order is preserved on equality, or flip (++)
728 (tags_of_comments comments_before)
729 (tags_of_comments comments_after)
731 postings_ <- Posting.from_List <$> many1_separated posting (newline)
733 Transaction.Transaction
734 { Transaction.code=code_
735 , Transaction.comments_before=comments_before
736 , Transaction.comments_after=comments_after
737 , Transaction.dates=(date_, dates_)
738 , Transaction.description=description_
739 , Transaction.postings=postings_
740 , Transaction.sourcepos
741 , Transaction.status=status_
742 , Transaction.tags=tags_
746 code :: Stream s m Char => ParsecT s Context m Transaction.Code
749 P.skipMany $ space_horizontal
750 P.between (P.char '(') (P.char ')') $
751 P.many $ P.satisfy (\c -> c /= ')' && not (is_space_horizontal c))
754 description :: Stream s m Char => ParsecT s st m Transaction.Description
757 P.many $ P.try description_char
760 description_char :: Stream s m Char => ParsecT s st m Char
761 description_char = do
764 _ | c == comment_begin -> P.parserZero
765 _ | is_space_horizontal c -> return c <* P.lookAhead description_char
766 _ | not (Data.Char.isSpace c) -> return c
769 -- * Parsing directives.
771 default_year :: Stream s m Char => ParsecT s Context m ()
773 year <- integer_of_digits 10 <$> P.many1 P.digit
774 context_ <- P.getState
775 P.setState context_{context_year=year}
777 default_unit_and_style :: Stream s m Char => ParsecT s Context m ()
778 default_unit_and_style = do
779 P.skipMany1 space_horizontal
781 P.skipMany space_horizontal >> newline >> P.skipMany space_horizontal
782 context_ <- P.getState
783 P.setState context_{context_unit_and_style=Just $
784 ( Amount.unit amount_
785 , Amount.style amount_ )}
787 include :: Stream s IO Char => ParsecT s Context IO ()
789 sourcepos <- P.getPosition
790 P.skipMany1 $ space_horizontal
791 (filename::String) <- P.manyTill P.anyChar (P.lookAhead newline <|> P.eof)
792 context_ <- P.getState
793 let journal_ = context_journal context_
794 let cwd = Path.takeDirectory (P.sourceName sourcepos)
795 file_ <- liftIO $ path_abs cwd filename
796 (journal_included, context_included) <- liftIO $
799 (\ko -> fail $ concat -- TODO: i18n by using a custom data type
803 , ":\n", show (ko::Exception.IOException)
805 >>= P.runParserT (and_context $ journal_rec file_)
806 context_{context_journal=Journal.nil}
809 Left ko -> fail $ show ko
810 Right ok -> return ok
812 context_included{context_journal=
813 journal_{Journal.includes=
814 journal_included{Journal.file=file_}
815 : Journal.includes journal_}}
818 -- * Parsing 'Journal'.
820 journal :: Stream s IO Char => FilePath -> ParsecT s Context IO Journal
822 currentUTC <- liftIO $ Time.getCurrentTime
823 currentTimeZone <- liftIO $ Time.getCurrentTimeZone
824 let currentLocalTime = Time.utcToLocalTime currentTimeZone currentUTC
825 let (currentLocalYear, _, _) = Time.toGregorian $ Time.localDay currentLocalTime
826 context_ <- P.getState
827 P.setState $ context_{context_year=currentLocalYear}
831 journal_rec :: Stream s IO Char => FilePath -> ParsecT s Context IO Journal
832 journal_rec file_ = do
833 last_read_time <- liftIO $ Time.getCurrentTime
837 [ P.string "Y" >> return default_year
838 , P.string "D" >> return default_unit_and_style
839 , P.string "!include" >> return include
840 ] <?> "directive") >>= id)
843 context_' <- P.getState
844 let j = context_journal context_'
845 P.setState $ context_'{context_journal=
846 j{Journal.transactions=
847 Data.Map.insertWith (++)
848 (Date.to_UTC $ fst $ Transaction.dates t) [t]
849 (Journal.transactions j)}}
852 P.skipMany $ P.satisfy Data.Char.isSpace
854 journal_ <- context_journal <$> P.getState
858 , Journal.last_read_time
859 , Journal.includes = reverse $ Journal.includes journal_
862 -- ** Parsing 'Journal' from a file.
864 file :: FilePath -> ExceptT String IO Journal
868 (liftM Right $ Text.IO.readFile path) $
869 \ko -> return $ Left $ show (ko::Exception.IOException)
870 liftIO $ P.runParserT (journal path) nil_Context path content
872 Left ko -> throwE $ show ko
873 Right ok -> ExceptT $ return $ Right ok