1558 lines
		
	
	
		
			62 KiB
		
	
	
	
		
			Haskell
		
	
	
	
	
	
			
		
		
	
	
			1558 lines
		
	
	
		
			62 KiB
		
	
	
	
		
			Haskell
		
	
	
	
	
	
| --- * -*- outline-regexp:"--- \\*"; -*-
 | |
| --- ** doc
 | |
| -- In Emacs, use TAB on lines beginning with "-- *" to collapse/expand sections.
 | |
| {-|
 | |
| 
 | |
| File reading/parsing utilities used by multiple readers, and a good
 | |
| amount of the parsers for journal format, to avoid import cycles
 | |
| when JournalReader imports other readers.
 | |
| 
 | |
| Some of these might belong in Hledger.Read.JournalReader or Hledger.Read.
 | |
| 
 | |
| -}
 | |
| 
 | |
| --- ** language
 | |
| {-# LANGUAGE BangPatterns        #-}
 | |
| {-# LANGUAGE CPP                 #-}
 | |
| {-# LANGUAGE FlexibleContexts    #-}
 | |
| {-# LANGUAGE LambdaCase          #-}
 | |
| {-# LANGUAGE NamedFieldPuns      #-}
 | |
| {-# LANGUAGE NoMonoLocalBinds    #-}
 | |
| {-# LANGUAGE OverloadedStrings   #-}
 | |
| {-# LANGUAGE PackageImports      #-}
 | |
| {-# LANGUAGE Rank2Types          #-}
 | |
| {-# LANGUAGE RecordWildCards     #-}
 | |
| {-# LANGUAGE ScopedTypeVariables #-}
 | |
| {-# LANGUAGE TupleSections       #-}
 | |
| {-# LANGUAGE TypeFamilies        #-}
 | |
| 
 | |
| --- ** exports
 | |
| module Hledger.Read.Common (
 | |
|   Reader (..),
 | |
|   InputOpts (..),
 | |
|   definputopts,
 | |
|   rawOptsToInputOpts,
 | |
| 
 | |
|   -- * parsing utilities
 | |
|   runTextParser,
 | |
|   rtp,
 | |
|   runJournalParser,
 | |
|   rjp,
 | |
|   runErroringJournalParser,
 | |
|   rejp,
 | |
|   genericSourcePos,
 | |
|   journalSourcePos,
 | |
|   parseAndFinaliseJournal,
 | |
|   parseAndFinaliseJournal',
 | |
|   journalFinalise,
 | |
|   journalCheckAccountsDeclared,
 | |
|   journalCheckCommoditiesDeclared,
 | |
|   journalCheckPayeesDeclared,
 | |
|   setYear,
 | |
|   getYear,
 | |
|   setDefaultCommodityAndStyle,
 | |
|   getDefaultCommodityAndStyle,
 | |
|   getDefaultAmountStyle,
 | |
|   getAmountStyle,
 | |
|   addDeclaredAccountType,
 | |
|   pushParentAccount,
 | |
|   popParentAccount,
 | |
|   getParentAccount,
 | |
|   addAccountAlias,
 | |
|   getAccountAliases,
 | |
|   clearAccountAliases,
 | |
|   journalAddFile,
 | |
| 
 | |
|   -- * parsers
 | |
|   -- ** transaction bits
 | |
|   statusp,
 | |
|   codep,
 | |
|   descriptionp,
 | |
| 
 | |
|   -- ** dates
 | |
|   datep,
 | |
|   datetimep,
 | |
|   secondarydatep,
 | |
| 
 | |
|   -- ** account names
 | |
|   modifiedaccountnamep,
 | |
|   accountnamep,
 | |
| 
 | |
|   -- ** account aliases
 | |
|   accountaliasp,
 | |
| 
 | |
|   -- ** amounts
 | |
|   spaceandamountormissingp,
 | |
|   amountp,
 | |
|   amountp',
 | |
|   mamountp',
 | |
|   commoditysymbolp,
 | |
|   priceamountp,
 | |
|   balanceassertionp,
 | |
|   lotpricep,
 | |
|   numberp,
 | |
|   fromRawNumber,
 | |
|   rawnumberp,
 | |
| 
 | |
|   -- ** comments
 | |
|   multilinecommentp,
 | |
|   emptyorcommentlinep,
 | |
| 
 | |
|   followingcommentp,
 | |
|   transactioncommentp,
 | |
|   postingcommentp,
 | |
| 
 | |
|   -- ** bracketed dates
 | |
|   bracketeddatetagsp,
 | |
| 
 | |
|   -- ** misc
 | |
|   singlespacedtextp,
 | |
|   singlespacedtextsatisfyingp,
 | |
|   singlespacep,
 | |
|   skipNonNewlineSpaces,
 | |
|   skipNonNewlineSpaces1,
 | |
|   aliasesFromOpts,
 | |
| 
 | |
|   -- * tests
 | |
|   tests_Common,
 | |
| )
 | |
| where
 | |
| 
 | |
| --- ** imports
 | |
| import Prelude ()
 | |
| import "base-compat-batteries" Prelude.Compat hiding (fail, readFile)
 | |
| import Control.Applicative.Permutations (runPermutation, toPermutationWithDefault)
 | |
| import qualified "base-compat-batteries" Control.Monad.Fail.Compat as Fail (fail)
 | |
| import Control.Monad.Except (ExceptT(..), runExceptT, throwError)
 | |
| import Control.Monad.State.Strict hiding (fail)
 | |
| import Data.Bifunctor (bimap, second)
 | |
| import Data.Char (digitToInt, isDigit, isSpace)
 | |
| import Data.Decimal (DecimalRaw (Decimal), Decimal)
 | |
| import Data.Default (Default(..))
 | |
| import Data.Function ((&))
 | |
| import Data.Functor.Identity (Identity)
 | |
| import "base-compat-batteries" Data.List.Compat
 | |
| import Data.List.NonEmpty (NonEmpty(..))
 | |
| import Data.Maybe (catMaybes, fromMaybe, isJust, listToMaybe)
 | |
| import qualified Data.Map as M
 | |
| import qualified Data.Semigroup as Sem
 | |
| import Data.Text (Text)
 | |
| import qualified Data.Text as T
 | |
| import Data.Time.Calendar (Day, fromGregorianValid, toGregorian)
 | |
| import Data.Time.LocalTime (LocalTime(..), TimeOfDay(..))
 | |
| import Data.Word (Word8)
 | |
| import System.Time (getClockTime)
 | |
| import Text.Megaparsec
 | |
| import Text.Megaparsec.Char (char, char', digitChar, newline, string)
 | |
| import Text.Megaparsec.Char.Lexer (decimal)
 | |
| import Text.Megaparsec.Custom
 | |
|   (FinalParseError, attachSource, customErrorBundlePretty,
 | |
|   finalErrorBundlePretty, parseErrorAt, parseErrorAtRegion)
 | |
| 
 | |
| import Hledger.Data
 | |
| import Hledger.Utils
 | |
| import Safe (headMay)
 | |
| import Text.Printf (printf)
 | |
| 
 | |
| --- ** doctest setup
 | |
| -- $setup
 | |
| -- >>> :set -XOverloadedStrings
 | |
| 
 | |
| --- ** types
 | |
| 
 | |
| -- main types; a few more below
 | |
| 
 | |
| -- | A hledger journal reader is a triple of storage format name, a
 | |
| -- detector of that format, and a parser from that format to Journal.
 | |
| -- The type variable m appears here so that rParserr can hold a
 | |
| -- journal parser, which depends on it.
 | |
| data Reader m = Reader {
 | |
| 
 | |
|      -- The canonical name of the format handled by this reader
 | |
|      rFormat   :: StorageFormat
 | |
| 
 | |
|      -- The file extensions recognised as containing this format
 | |
|     ,rExtensions :: [String]
 | |
| 
 | |
|      -- The entry point for reading this format, accepting input options, file
 | |
|      -- path for error messages and file contents, producing an exception-raising IO
 | |
|      -- action that produces a journal or error message.
 | |
|     ,rReadFn   :: InputOpts -> FilePath -> Text -> ExceptT String IO Journal
 | |
| 
 | |
|      -- The actual megaparsec parser called by the above, in case
 | |
|      -- another parser (includedirectivep) wants to use it directly.
 | |
|     ,rParser :: MonadIO m => ErroringJournalParser m ParsedJournal
 | |
|     }
 | |
| 
 | |
| instance Show (Reader m) where show r = rFormat r ++ " reader"
 | |
| 
 | |
| -- $setup
 | |
| 
 | |
| -- | Various options to use when reading journal files.
 | |
| -- Similar to CliOptions.inputflags, simplifies the journal-reading functions.
 | |
| data InputOpts = InputOpts {
 | |
|      -- files_             :: [FilePath]
 | |
|      mformat_           :: Maybe StorageFormat  -- ^ a file/storage format to try, unless overridden
 | |
|                                                 --   by a filename prefix. Nothing means try all.
 | |
|     ,mrules_file_       :: Maybe FilePath       -- ^ a conversion rules file to use (when reading CSV)
 | |
|     ,aliases_           :: [String]             -- ^ account name aliases to apply
 | |
|     ,anon_              :: Bool                 -- ^ do light anonymisation/obfuscation of the data
 | |
|     ,ignore_assertions_ :: Bool                 -- ^ don't check balance assertions
 | |
|     ,new_               :: Bool                 -- ^ read only new transactions since this file was last read
 | |
|     ,new_save_          :: Bool                 -- ^ save latest new transactions state for next time
 | |
|     ,pivot_             :: String               -- ^ use the given field's value as the account name
 | |
|     ,auto_              :: Bool                 -- ^ generate automatic postings when journal is parsed
 | |
|     ,commoditystyles_   :: Maybe (M.Map CommoditySymbol AmountStyle) -- ^ optional commodity display styles affecting all files
 | |
|     ,strict_            :: Bool                 -- ^ do extra error checking (eg, all posted accounts are declared)
 | |
|  } deriving (Show)
 | |
| 
 | |
| instance Default InputOpts where def = definputopts
 | |
| 
 | |
| definputopts :: InputOpts
 | |
| definputopts = InputOpts
 | |
|     { mformat_           = Nothing
 | |
|     , mrules_file_       = Nothing
 | |
|     , aliases_           = []
 | |
|     , anon_              = False
 | |
|     , ignore_assertions_ = False
 | |
|     , new_               = False
 | |
|     , new_save_          = True
 | |
|     , pivot_             = ""
 | |
|     , auto_              = False
 | |
|     , commoditystyles_   = Nothing
 | |
|     , strict_            = False
 | |
|     }
 | |
| 
 | |
| rawOptsToInputOpts :: RawOpts -> InputOpts
 | |
| rawOptsToInputOpts rawopts = InputOpts{
 | |
|    -- files_             = listofstringopt "file" rawopts
 | |
|    mformat_           = Nothing
 | |
|   ,mrules_file_       = maybestringopt "rules-file" rawopts
 | |
|   ,aliases_           = listofstringopt "alias" rawopts
 | |
|   ,anon_              = boolopt "anon" rawopts
 | |
|   ,ignore_assertions_ = boolopt "ignore-assertions" rawopts
 | |
|   ,new_               = boolopt "new" rawopts
 | |
|   ,new_save_          = True
 | |
|   ,pivot_             = stringopt "pivot" rawopts
 | |
|   ,auto_              = boolopt "auto" rawopts
 | |
|   ,commoditystyles_   = Nothing
 | |
|   ,strict_            = boolopt "strict" rawopts
 | |
|   }
 | |
| 
 | |
| --- ** parsing utilities
 | |
| 
 | |
| -- | Run a text parser in the identity monad. See also: parseWithState.
 | |
| runTextParser, rtp
 | |
|   :: TextParser Identity a -> Text -> Either (ParseErrorBundle Text CustomErr) a
 | |
| runTextParser p t =  runParser p "" t
 | |
| rtp = runTextParser
 | |
| 
 | |
| -- | Run a journal parser in some monad. See also: parseWithState.
 | |
| runJournalParser, rjp
 | |
|   :: Monad m
 | |
|   => JournalParser m a -> Text -> m (Either (ParseErrorBundle Text CustomErr) a)
 | |
| runJournalParser p t = runParserT (evalStateT p nulljournal) "" t
 | |
| rjp = runJournalParser
 | |
| 
 | |
| -- | Run an erroring journal parser in some monad. See also: parseWithState.
 | |
| runErroringJournalParser, rejp
 | |
|   :: Monad m
 | |
|   => ErroringJournalParser m a
 | |
|   -> Text
 | |
|   -> m (Either FinalParseError (Either (ParseErrorBundle Text CustomErr) a))
 | |
| runErroringJournalParser p t =
 | |
|   runExceptT $ runParserT (evalStateT p nulljournal) "" t
 | |
| rejp = runErroringJournalParser
 | |
| 
 | |
| genericSourcePos :: SourcePos -> GenericSourcePos
 | |
| genericSourcePos p = GenericSourcePos (sourceName p) (unPos $ sourceLine p) (unPos $ sourceColumn p)
 | |
| 
 | |
| -- | Construct a generic start & end line parse position from start and end megaparsec SourcePos's.
 | |
| journalSourcePos :: SourcePos -> SourcePos -> GenericSourcePos
 | |
| journalSourcePos p p' = JournalSourcePos (sourceName p) (unPos $ sourceLine p, line')
 | |
|     where line' | (unPos $ sourceColumn p') == 1 = unPos (sourceLine p') - 1
 | |
|                 | otherwise = unPos $ sourceLine p' -- might be at end of file withat last new-line
 | |
| 
 | |
| -- | Given a parser to ParsedJournal, input options, file path and
 | |
| -- content: run the parser on the content, and finalise the result to
 | |
| -- get a Journal; or throw an error.
 | |
| parseAndFinaliseJournal :: ErroringJournalParser IO ParsedJournal -> InputOpts
 | |
|                            -> FilePath -> Text -> ExceptT String IO Journal
 | |
| parseAndFinaliseJournal parser iopts f txt = do
 | |
|   y <- liftIO getCurrentYear
 | |
|   let initJournal = nulljournal{ jparsedefaultyear = Just y, jincludefilestack = [f] }
 | |
|   eep <- liftIO $ runExceptT $ runParserT (evalStateT parser initJournal) f txt
 | |
|   -- TODO: urgh.. clean this up somehow
 | |
|   case eep of
 | |
|     Left finalParseError -> throwError $ finalErrorBundlePretty $ attachSource f txt finalParseError
 | |
|     Right ep -> case ep of
 | |
|                   Left e   -> throwError $ customErrorBundlePretty e
 | |
|                   Right pj -> journalFinalise iopts f txt pj
 | |
| 
 | |
| -- | Like parseAndFinaliseJournal but takes a (non-Erroring) JournalParser.
 | |
| -- Also, applies command-line account aliases before finalising.
 | |
| -- Used for timeclock/timedot.
 | |
| -- TODO: get rid of this, use parseAndFinaliseJournal instead
 | |
| parseAndFinaliseJournal' :: JournalParser IO ParsedJournal -> InputOpts
 | |
|                            -> FilePath -> Text -> ExceptT String IO Journal
 | |
| parseAndFinaliseJournal' parser iopts f txt = do
 | |
|   y <- liftIO getCurrentYear
 | |
|   let initJournal = nulljournal
 | |
|         { jparsedefaultyear = Just y
 | |
|         , jincludefilestack = [f] }
 | |
|   ep <- liftIO $ runParserT (evalStateT parser initJournal) f txt
 | |
|   -- see notes above
 | |
|   case ep of
 | |
|     Left e   -> throwError $ customErrorBundlePretty e
 | |
|     Right pj -> 
 | |
|       -- apply any command line account aliases. Can fail with a bad replacement pattern.
 | |
|       case journalApplyAliases (aliasesFromOpts iopts) pj of
 | |
|         Left e    -> throwError e
 | |
|         Right pj' -> journalFinalise iopts f txt pj'
 | |
| 
 | |
| -- | Post-process a Journal that has just been parsed or generated, in this order:
 | |
| --
 | |
| -- - apply canonical amount styles,
 | |
| --
 | |
| -- - save misc info and reverse transactions into their original parse order,
 | |
| --
 | |
| -- - evaluate balance assignments and balance each transaction,
 | |
| --
 | |
| -- - apply transaction modifiers (auto postings) if enabled,
 | |
| --
 | |
| -- - check balance assertions if enabled.
 | |
| --
 | |
| -- - infer transaction-implied market prices from transaction prices
 | |
| --
 | |
| journalFinalise :: InputOpts -> FilePath -> Text -> ParsedJournal -> ExceptT String IO Journal
 | |
| journalFinalise InputOpts{auto_,ignore_assertions_,commoditystyles_,strict_} f txt pj = do
 | |
|   t <- liftIO getClockTime
 | |
|   d <- liftIO getCurrentDay
 | |
|   let pj' =
 | |
|         pj{jglobalcommoditystyles=fromMaybe M.empty commoditystyles_}  -- save any global commodity styles
 | |
|         & journalAddFile (f, txt)  -- save the main file's info
 | |
|         & journalSetLastReadTime t -- save the last read time
 | |
|         & journalReverse -- convert all lists to the order they were parsed
 | |
| 
 | |
|   -- If in strict mode, check all postings are to declared accounts
 | |
|   case if strict_ then journalCheckAccountsDeclared pj' else Right () of
 | |
|     Left e   -> throwError e
 | |
|     Right () ->
 | |
| 
 | |
|       -- and using declared commodities
 | |
|       case if strict_ then journalCheckCommoditiesDeclared pj' else Right () of
 | |
|         Left e   -> throwError e
 | |
|         Right () ->
 | |
| 
 | |
|           -- Infer and apply canonical styles for each commodity (or throw an error).
 | |
|           -- This affects transaction balancing/assertions/assignments, so needs to be done early.
 | |
|           case journalApplyCommodityStyles pj' of
 | |
|             Left e     -> throwError e
 | |
|             Right pj'' -> either throwError return $
 | |
|               pj''
 | |
|               & (if not auto_ || null (jtxnmodifiers pj'')
 | |
|                 then
 | |
|                   -- Auto postings are not active.
 | |
|                   -- Balance all transactions and maybe check balance assertions.
 | |
|                   journalBalanceTransactions (not ignore_assertions_)
 | |
|                 else \j -> do  -- Either monad
 | |
|                   -- Auto postings are active.
 | |
|                   -- Balance all transactions without checking balance assertions,
 | |
|                   j' <- journalBalanceTransactions False j
 | |
|                   -- then add the auto postings
 | |
|                   -- (Note adding auto postings after balancing means #893b fails;
 | |
|                   -- adding them before balancing probably means #893a, #928, #938 fail.)
 | |
|                   case journalModifyTransactions d j' of
 | |
|                     Left e -> throwError e
 | |
|                     Right j'' -> do
 | |
|                       -- then apply commodity styles once more, to style the auto posting amounts. (XXX inefficient ?)
 | |
|                       j''' <- journalApplyCommodityStyles j''
 | |
|                       -- then check balance assertions.
 | |
|                       journalBalanceTransactions (not ignore_assertions_) j'''
 | |
|                 )
 | |
|             & fmap journalInferMarketPricesFromTransactions  -- infer market prices from commodity-exchanging transactions
 | |
| 
 | |
| -- | Check that all the journal's transactions have payees declared with
 | |
| -- payee directives, returning an error message otherwise.
 | |
| journalCheckPayeesDeclared :: Journal -> Either String ()
 | |
| journalCheckPayeesDeclared j = sequence_ $ map checkpayee $ jtxns j
 | |
|   where
 | |
|     checkpayee t
 | |
|       | p `elem` ps = Right ()
 | |
|       | otherwise = Left $
 | |
|           printf "undeclared payee \"%s\"\nat: %s\n\n%s"
 | |
|             (T.unpack p)
 | |
|             (showGenericSourcePos $ tsourcepos t)
 | |
|             (linesPrepend2 "> " "  " . (<>"\n") . textChomp $ showTransaction t)
 | |
|       where
 | |
|         p  = transactionPayee t
 | |
|         ps = journalPayeesDeclared j
 | |
| 
 | |
| -- | Check that all the journal's postings are to accounts declared with
 | |
| -- account directives, returning an error message otherwise.
 | |
| journalCheckAccountsDeclared :: Journal -> Either String ()
 | |
| journalCheckAccountsDeclared j = sequence_ $ map checkacct $ journalPostings j
 | |
|   where
 | |
|     checkacct Posting{paccount,ptransaction}
 | |
|       | paccount `elem` as = Right ()
 | |
|       | otherwise = Left $
 | |
|           (printf "undeclared account \"%s\"\n" (T.unpack paccount))
 | |
|           ++ case ptransaction of
 | |
|               Nothing -> ""
 | |
|               Just t  -> printf "in transaction at: %s\n\n%s"
 | |
|                           (showGenericSourcePos $ tsourcepos t)
 | |
|                           (linesPrepend "  " . (<>"\n") . textChomp $ showTransaction t)
 | |
|       where
 | |
|         as = journalAccountNamesDeclared j
 | |
| 
 | |
| -- | Check that all the commodities used in this journal's postings have been declared
 | |
| -- by commodity directives, returning an error message otherwise.
 | |
| journalCheckCommoditiesDeclared :: Journal -> Either String ()
 | |
| journalCheckCommoditiesDeclared j = 
 | |
|   sequence_ $ map checkcommodities $ journalPostings j
 | |
|   where
 | |
|     checkcommodities Posting{..} =
 | |
|       case mfirstundeclaredcomm of
 | |
|         Nothing -> Right ()
 | |
|         Just c  -> Left $
 | |
|           (printf "undeclared commodity \"%s\"\n" (T.unpack c))
 | |
|           ++ case ptransaction of
 | |
|               Nothing -> ""
 | |
|               Just t  -> printf "in transaction at: %s\n\n%s"
 | |
|                           (showGenericSourcePos $ tsourcepos t)
 | |
|                           (linesPrepend "  " . (<>"\n") . textChomp $ showTransaction t)
 | |
|       where
 | |
|         mfirstundeclaredcomm =
 | |
|           headMay $ filter (not . (`elem` cs)) $ catMaybes $
 | |
|           (acommodity . baamount <$> pbalanceassertion) :
 | |
|           (map (Just . acommodity) . filter (/= missingamt) $ amounts pamount)
 | |
|         cs = journalCommoditiesDeclared j
 | |
| 
 | |
| 
 | |
| setYear :: Year -> JournalParser m ()
 | |
| setYear y = modify' (\j -> j{jparsedefaultyear=Just y})
 | |
| 
 | |
| getYear :: JournalParser m (Maybe Year)
 | |
| getYear = fmap jparsedefaultyear get
 | |
| 
 | |
| -- | Get the decimal mark that has been specified for parsing, if any
 | |
| -- (eg by the CSV decimal-mark rule, or possibly a future journal directive).
 | |
| -- Return it as an AmountStyle that amount parsers can use.
 | |
| getDecimalMarkStyle :: JournalParser m (Maybe AmountStyle)
 | |
| getDecimalMarkStyle = do
 | |
|   Journal{jparsedecimalmark} <- get
 | |
|   let mdecmarkStyle = maybe Nothing (\c -> Just $ amountstyle{asdecimalpoint=Just c}) jparsedecimalmark
 | |
|   return mdecmarkStyle
 | |
| 
 | |
| setDefaultCommodityAndStyle :: (CommoditySymbol,AmountStyle) -> JournalParser m ()
 | |
| setDefaultCommodityAndStyle cs = modify' (\j -> j{jparsedefaultcommodity=Just cs})
 | |
| 
 | |
| getDefaultCommodityAndStyle :: JournalParser m (Maybe (CommoditySymbol,AmountStyle))
 | |
| getDefaultCommodityAndStyle = jparsedefaultcommodity `fmap` get
 | |
| 
 | |
| -- | Get amount style associated with default currency.
 | |
| --
 | |
| -- Returns 'AmountStyle' used to defined by a latest default commodity directive
 | |
| -- prior to current position within this file or its parents.
 | |
| getDefaultAmountStyle :: JournalParser m (Maybe AmountStyle)
 | |
| getDefaultAmountStyle = fmap snd <$> getDefaultCommodityAndStyle
 | |
| 
 | |
| -- | Get the 'AmountStyle' declared by the most recently parsed (in the current or parent files,
 | |
| -- prior to the current position) commodity directive for the given commodity, if any.
 | |
| getAmountStyle :: CommoditySymbol -> JournalParser m (Maybe AmountStyle)
 | |
| getAmountStyle commodity = do
 | |
|   Journal{jcommodities} <- get
 | |
|   let mspecificStyle = M.lookup commodity jcommodities >>= cformat
 | |
|   mdefaultStyle <- fmap snd <$> getDefaultCommodityAndStyle
 | |
|   return $ listToMaybe $ catMaybes [mspecificStyle, mdefaultStyle]
 | |
| 
 | |
| addDeclaredAccountType :: AccountName -> AccountType -> JournalParser m ()
 | |
| addDeclaredAccountType acct atype =
 | |
|   modify' (\j -> j{jdeclaredaccounttypes = M.insertWith (++) atype [acct] (jdeclaredaccounttypes j)})
 | |
| 
 | |
| pushParentAccount :: AccountName -> JournalParser m ()
 | |
| pushParentAccount acct = modify' (\j -> j{jparseparentaccounts = acct : jparseparentaccounts j})
 | |
| 
 | |
| popParentAccount :: JournalParser m ()
 | |
| popParentAccount = do
 | |
|   j <- get
 | |
|   case jparseparentaccounts j of
 | |
|     []       -> unexpected (Tokens ('E' :| "nd of apply account block with no beginning"))
 | |
|     (_:rest) -> put j{jparseparentaccounts=rest}
 | |
| 
 | |
| getParentAccount :: JournalParser m AccountName
 | |
| getParentAccount = fmap (concatAccountNames . reverse . jparseparentaccounts) get
 | |
| 
 | |
| addAccountAlias :: MonadState Journal m => AccountAlias -> m ()
 | |
| addAccountAlias a = modify' (\(j@Journal{..}) -> j{jparsealiases=a:jparsealiases})
 | |
| 
 | |
| getAccountAliases :: MonadState Journal m => m [AccountAlias]
 | |
| getAccountAliases = fmap jparsealiases get
 | |
| 
 | |
| clearAccountAliases :: MonadState Journal m => m ()
 | |
| clearAccountAliases = modify' (\j -> j{jparsealiases=[]})
 | |
| 
 | |
| -- getTransactionCount :: MonadState Journal m =>  m Integer
 | |
| -- getTransactionCount = fmap jparsetransactioncount get
 | |
| --
 | |
| -- setTransactionCount :: MonadState Journal m => Integer -> m ()
 | |
| -- setTransactionCount i = modify' (\j -> j{jparsetransactioncount=i})
 | |
| --
 | |
| -- -- | Increment the transaction index by one and return the new value.
 | |
| -- incrementTransactionCount :: MonadState Journal m => m Integer
 | |
| -- incrementTransactionCount = do
 | |
| --   modify' (\j -> j{jparsetransactioncount=jparsetransactioncount j + 1})
 | |
| --   getTransactionCount
 | |
| 
 | |
| journalAddFile :: (FilePath,Text) -> Journal -> Journal
 | |
| journalAddFile f j@Journal{jfiles=fs} = j{jfiles=fs++[f]}
 | |
|   -- append, unlike the other fields, even though we do a final reverse,
 | |
|   -- to compensate for additional reversal due to including/monoid-concatting
 | |
| 
 | |
| -- A version of `match` that is strict in the returned text
 | |
| match' :: TextParser m a -> TextParser m (Text, a)
 | |
| match' p = do
 | |
|   (!txt, p) <- match p
 | |
|   pure (txt, p)
 | |
| 
 | |
| --- ** parsers
 | |
| --- *** transaction bits
 | |
| 
 | |
| statusp :: TextParser m Status
 | |
| statusp =
 | |
|   choice'
 | |
|     [ skipNonNewlineSpaces >> char '*' >> return Cleared
 | |
|     , skipNonNewlineSpaces >> char '!' >> return Pending
 | |
|     , return Unmarked
 | |
|     ]
 | |
| 
 | |
| codep :: TextParser m Text
 | |
| codep = option "" $ do
 | |
|   try $ do
 | |
|     skipNonNewlineSpaces1
 | |
|     char '('
 | |
|   code <- takeWhileP Nothing $ \c -> c /= ')' && c /= '\n'
 | |
|   char ')' <?> "closing bracket ')' for transaction code"
 | |
|   pure code
 | |
| 
 | |
| descriptionp :: TextParser m Text
 | |
| descriptionp = takeWhileP Nothing (not . semicolonOrNewline)
 | |
|   where semicolonOrNewline c = c == ';' || c == '\n'
 | |
| 
 | |
| --- *** dates
 | |
| 
 | |
| -- | Parse a date in YYYY-MM-DD format.
 | |
| -- Slash (/) 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 :: JournalParser m Day
 | |
| datep = do
 | |
|   mYear <- getYear
 | |
|   lift $ datep' mYear
 | |
| 
 | |
| datep' :: Maybe Year -> TextParser m Day
 | |
| datep' mYear = do
 | |
|     startOffset <- getOffset
 | |
|     d1 <- yearorintp <?> "year or month"
 | |
|     sep <- datesepchar <?> "date separator"
 | |
|     d2 <- decimal <?> "month or day"
 | |
|     case d1 of
 | |
|          Left y  -> fullDate startOffset y sep d2
 | |
|          Right m -> partialDate startOffset mYear m sep d2
 | |
|     <?> "full or partial date"
 | |
|   where
 | |
|     fullDate :: Int -> Year -> Char -> Month -> TextParser m Day
 | |
|     fullDate startOffset year sep1 month = do
 | |
|       sep2 <- satisfy isDateSepChar <?> "date separator"
 | |
|       day <- decimal <?> "day"
 | |
|       endOffset <- getOffset
 | |
|       let dateStr = show year ++ [sep1] ++ show month ++ [sep2] ++ show day
 | |
| 
 | |
|       when (sep1 /= sep2) $ customFailure $ parseErrorAtRegion startOffset endOffset $
 | |
|         "invalid date: separators are different, should be the same"
 | |
| 
 | |
|       case fromGregorianValid year month day of
 | |
|         Nothing -> customFailure $ parseErrorAtRegion startOffset endOffset $
 | |
|                      "well-formed but invalid date: " ++ dateStr
 | |
|         Just date -> pure $! date
 | |
| 
 | |
|     partialDate :: Int -> Maybe Year -> Month -> Char -> MonthDay -> TextParser m Day
 | |
|     partialDate startOffset mYear month sep day = do
 | |
|       endOffset <- getOffset
 | |
|       case mYear of
 | |
|         Just year ->
 | |
|           case fromGregorianValid year month day of
 | |
|             Nothing -> customFailure $ parseErrorAtRegion startOffset endOffset $
 | |
|                         "well-formed but invalid date: " ++ dateStr
 | |
|             Just date -> pure $! date
 | |
|           where dateStr = show year ++ [sep] ++ show month ++ [sep] ++ show day
 | |
| 
 | |
|         Nothing -> customFailure $ parseErrorAtRegion startOffset endOffset $
 | |
|           "partial date "++dateStr++" found, but the current year is unknown"
 | |
|           where dateStr = show month ++ [sep] ++ show day
 | |
| 
 | |
| {-# INLINABLE datep' #-}
 | |
| 
 | |
| -- | Parse a date and time in YYYY-MM-DD HH:MM[:SS][+-ZZZZ] format.
 | |
| -- Slash (/) 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 :: JournalParser m LocalTime
 | |
| datetimep = do
 | |
|   mYear <- getYear
 | |
|   lift $ datetimep' mYear
 | |
| 
 | |
| datetimep' :: Maybe Year -> TextParser m LocalTime
 | |
| datetimep' mYear = do
 | |
|   day <- datep' mYear
 | |
|   skipNonNewlineSpaces1
 | |
|   time <- timeOfDay
 | |
|   optional timeZone -- ignoring time zones
 | |
|   pure $ LocalTime day time
 | |
| 
 | |
|   where
 | |
|     timeOfDay :: TextParser m TimeOfDay
 | |
|     timeOfDay = do
 | |
|       off1 <- getOffset
 | |
|       h' <- twoDigitDecimal <?> "hour"
 | |
|       off2 <- getOffset
 | |
|       unless (h' >= 0 && h' <= 23) $ customFailure $
 | |
|         parseErrorAtRegion off1 off2 "invalid time (bad hour)"
 | |
| 
 | |
|       char ':' <?> "':' (hour-minute separator)"
 | |
|       off3 <- getOffset
 | |
|       m' <- twoDigitDecimal <?> "minute"
 | |
|       off4 <- getOffset
 | |
|       unless (m' >= 0 && m' <= 59) $ customFailure $
 | |
|         parseErrorAtRegion off3 off4 "invalid time (bad minute)"
 | |
| 
 | |
|       s' <- option 0 $ do
 | |
|         char ':' <?> "':' (minute-second separator)"
 | |
|         off5 <- getOffset
 | |
|         s' <- twoDigitDecimal <?> "second"
 | |
|         off6 <- getOffset
 | |
|         unless (s' >= 0 && s' <= 59) $ customFailure $
 | |
|           parseErrorAtRegion off5 off6 "invalid time (bad second)"
 | |
|           -- we do not support leap seconds
 | |
|         pure s'
 | |
| 
 | |
|       pure $ TimeOfDay h' m' (fromIntegral s')
 | |
| 
 | |
|     twoDigitDecimal :: TextParser m Int
 | |
|     twoDigitDecimal = do
 | |
|       d1 <- digitToInt <$> digitChar
 | |
|       d2 <- digitToInt <$> (digitChar <?> "a second digit")
 | |
|       pure $ d1*10 + d2
 | |
| 
 | |
|     timeZone :: TextParser m String
 | |
|     timeZone = do
 | |
|       plusminus <- satisfy $ \c -> c == '-' || c == '+'
 | |
|       fourDigits <- count 4 (digitChar <?> "a digit (for a time zone)")
 | |
|       pure $ plusminus:fourDigits
 | |
| 
 | |
| secondarydatep :: Day -> TextParser m Day
 | |
| secondarydatep primaryDate = char '=' *> datep' (Just primaryYear)
 | |
|   where primaryYear = first3 $ toGregorian primaryDate
 | |
| 
 | |
| -- | Parse a year number or an Int. Years must contain at least four
 | |
| -- digits.
 | |
| yearorintp :: TextParser m (Either Year Int)
 | |
| yearorintp = do
 | |
|     yearOrMonth <- takeWhile1P (Just "digit") isDigit
 | |
|     let n = readDecimal yearOrMonth
 | |
|     return $ if T.length yearOrMonth >= 4 then Left n else Right (fromInteger n)
 | |
| 
 | |
| --- *** account names
 | |
| 
 | |
| -- | Parse an account name (plus one following space if present),
 | |
| -- then apply any parent account prefix and/or account aliases currently in effect,
 | |
| -- in that order. (Ie first add the parent account prefix, then rewrite with aliases).
 | |
| -- This calls error if any account alias with an invalid regular expression exists.
 | |
| modifiedaccountnamep :: JournalParser m AccountName
 | |
| modifiedaccountnamep = do
 | |
|   parent  <- getParentAccount
 | |
|   aliases <- getAccountAliases
 | |
|   -- off1    <- getOffset
 | |
|   a       <- lift accountnamep
 | |
|   -- off2    <- getOffset
 | |
|   -- XXX or accountNameApplyAliasesMemo ? doesn't seem to make a difference (retest that function)
 | |
|   case accountNameApplyAliases aliases $ joinAccountNames parent a of
 | |
|     Right a' -> return $! a'
 | |
|     -- should not happen, regexaliasp will have displayed a better error already:
 | |
|     -- (XXX why does customFailure cause error to be displayed there, but not here ?)
 | |
|     -- Left e  -> customFailure $! parseErrorAtRegion off1 off2 err
 | |
|     Left e   -> error' err  -- PARTIAL:
 | |
|       where
 | |
|         err = "problem in account alias applied to "++T.unpack a++": "++e
 | |
| 
 | |
| -- | Parse an account name, plus one following space if present.
 | |
| -- Account names have one or more parts separated by the account separator character,
 | |
| -- and are terminated by two or more spaces (or end of input).
 | |
| -- Each part is at least one character long, may have single spaces inside it,
 | |
| -- and starts with a non-whitespace.
 | |
| -- Note, this means "{account}", "%^!" and ";comment" are all accepted
 | |
| -- (parent parsers usually prevent/consume the last).
 | |
| -- It should have required parts to start with an alphanumeric;
 | |
| -- for now it remains as-is for backwards compatibility.
 | |
| accountnamep :: TextParser m AccountName
 | |
| accountnamep = singlespacedtextp
 | |
| 
 | |
| 
 | |
| -- | Parse any text beginning with a non-whitespace character, until a
 | |
| -- double space or the end of input.
 | |
| -- TODO including characters which normally start a comment (;#) - exclude those ? 
 | |
| singlespacedtextp :: TextParser m T.Text
 | |
| singlespacedtextp = singlespacedtextsatisfyingp (const True)
 | |
| 
 | |
| -- | Similar to 'singlespacedtextp', except that the text must only contain
 | |
| -- characters satisfying the given predicate.
 | |
| singlespacedtextsatisfyingp :: (Char -> Bool) -> TextParser m T.Text
 | |
| singlespacedtextsatisfyingp pred = do
 | |
|   firstPart <- partp
 | |
|   otherParts <- many $ try $ singlespacep *> partp
 | |
|   pure $! T.unwords $ firstPart : otherParts
 | |
|   where
 | |
|     partp = takeWhile1P Nothing (\c -> pred c && not (isSpace c))
 | |
| 
 | |
| -- | Parse one non-newline whitespace character that is not followed by another one.
 | |
| singlespacep :: TextParser m ()
 | |
| singlespacep = spacenonewline *> notFollowedBy spacenonewline
 | |
| 
 | |
| --- *** amounts
 | |
| 
 | |
| -- | Parse whitespace then an amount, with an optional left or right
 | |
| -- currency symbol and optional price, or return the special
 | |
| -- "missing" marker amount.
 | |
| spaceandamountormissingp :: JournalParser m MixedAmount
 | |
| spaceandamountormissingp =
 | |
|   option missingmixedamt $ try $ do
 | |
|     lift $ skipNonNewlineSpaces1
 | |
|     Mixed . (:[]) <$> amountp
 | |
| 
 | |
| -- | Parse a single-commodity amount, with optional symbol on the left
 | |
| -- or right, followed by, in any order: an optional transaction price,
 | |
| -- an optional ledger-style lot price, and/or an optional ledger-style
 | |
| -- lot date. A lot price and lot date will be ignored.
 | |
| --
 | |
| -- To parse the amount's quantity (number) we need to know which character 
 | |
| -- represents a decimal mark. We find it in one of three ways:
 | |
| --
 | |
| -- 1. If a decimal mark has been set explicitly in the journal parse state, 
 | |
| --    we use that
 | |
| --
 | |
| -- 2. Or if the journal has a commodity declaration for the amount's commodity,
 | |
| --    we get the decimal mark from  that
 | |
| --
 | |
| -- 3. Otherwise we will parse any valid decimal mark appearing in the
 | |
| --    number, as long as the number appears well formed.
 | |
| --
 | |
| -- Note 3 is the default zero-config case; it means we automatically handle
 | |
| -- files with any supported decimal mark, but it also allows different decimal marks
 | |
| -- in  different amounts, which is a bit too loose. There's an open issue.
 | |
| amountp :: JournalParser m Amount
 | |
| amountp = label "amount" $ do
 | |
|   let 
 | |
|     spaces = lift $ skipNonNewlineSpaces
 | |
|   amount <- amountwithoutpricep <* spaces
 | |
|   (mprice, _elotprice, _elotdate) <- runPermutation $
 | |
|     (,,) <$> toPermutationWithDefault Nothing (Just <$> priceamountp <* spaces)
 | |
|          <*> toPermutationWithDefault Nothing (Just <$> lotpricep <* spaces)
 | |
|          <*> toPermutationWithDefault Nothing (Just <$> lotdatep <* spaces)
 | |
|   pure $ amount { aprice = mprice }
 | |
| 
 | |
| amountpnolotpricesp :: JournalParser m Amount
 | |
| amountpnolotpricesp = label "amount" $ do
 | |
|   let spaces = lift $ skipNonNewlineSpaces
 | |
|   amount <- amountwithoutpricep
 | |
|   spaces
 | |
|   mprice <- optional $ priceamountp <* spaces
 | |
|   pure $ amount { aprice = mprice }
 | |
| 
 | |
| amountwithoutpricep :: JournalParser m Amount
 | |
| amountwithoutpricep = do
 | |
|   (mult, sign) <- lift $ (,) <$> multiplierp <*> signp
 | |
|   leftsymbolamountp mult sign <|> rightornosymbolamountp mult sign
 | |
| 
 | |
|   where
 | |
| 
 | |
|   leftsymbolamountp :: Bool -> (Decimal -> Decimal) -> JournalParser m Amount
 | |
|   leftsymbolamountp mult sign = label "amount" $ do
 | |
|     c <- lift commoditysymbolp
 | |
|     mdecmarkStyle <- getDecimalMarkStyle
 | |
|     mcommodityStyle <- getAmountStyle c
 | |
|     let suggestedStyle = mdecmarkStyle <|> mcommodityStyle
 | |
|     commodityspaced <- lift skipNonNewlineSpaces'
 | |
|     sign2 <- lift $ signp
 | |
|     offBeforeNum <- getOffset
 | |
|     ambiguousRawNum <- lift rawnumberp
 | |
|     mExponent <- lift $ optional $ try exponentp
 | |
|     offAfterNum <- getOffset
 | |
|     let numRegion = (offBeforeNum, offAfterNum)
 | |
|     (q,prec,mdec,mgrps) <- lift $ interpretNumber numRegion suggestedStyle ambiguousRawNum mExponent
 | |
|     let s = amountstyle{ascommodityside=L, ascommodityspaced=commodityspaced, asprecision=prec, asdecimalpoint=mdec, asdigitgroups=mgrps}
 | |
|     return $ nullamt{acommodity=c, aquantity=sign (sign2 q), aismultiplier=mult, astyle=s, aprice=Nothing}
 | |
| 
 | |
|   rightornosymbolamountp :: Bool -> (Decimal -> Decimal) -> JournalParser m Amount
 | |
|   rightornosymbolamountp mult sign = label "amount" $ do
 | |
|     offBeforeNum <- getOffset
 | |
|     ambiguousRawNum <- lift rawnumberp
 | |
|     mExponent <- lift $ optional $ try exponentp
 | |
|     offAfterNum <- getOffset
 | |
|     let numRegion = (offBeforeNum, offAfterNum)
 | |
|     mSpaceAndCommodity <- lift $ optional $ try $ (,) <$> skipNonNewlineSpaces' <*> commoditysymbolp
 | |
|     case mSpaceAndCommodity of
 | |
|       -- right symbol amount
 | |
|       Just (commodityspaced, c) -> do
 | |
|         mdecmarkStyle <- getDecimalMarkStyle
 | |
|         mcommodityStyle <- getAmountStyle c
 | |
|         let msuggestedStyle = mdecmarkStyle <|> mcommodityStyle
 | |
|         (q,prec,mdec,mgrps) <- lift $ interpretNumber numRegion msuggestedStyle ambiguousRawNum mExponent
 | |
|         let s = amountstyle{ascommodityside=R, ascommodityspaced=commodityspaced, asprecision=prec, asdecimalpoint=mdec, asdigitgroups=mgrps}
 | |
|         return $ nullamt{acommodity=c, aquantity=sign q, aismultiplier=mult, astyle=s, aprice=Nothing}
 | |
|       -- no symbol amount
 | |
|       Nothing -> do
 | |
|         mdecmarkStyle <- getDecimalMarkStyle
 | |
|         mcommodityStyle <- getDefaultAmountStyle
 | |
|         let msuggestedStyle = mdecmarkStyle <|> mcommodityStyle
 | |
|         (q,prec,mdec,mgrps) <- lift $ interpretNumber numRegion msuggestedStyle ambiguousRawNum mExponent
 | |
|         -- if a default commodity has been set, apply it and its style to this amount
 | |
|         -- (unless it's a multiplier in an automated posting)
 | |
|         defcs <- getDefaultCommodityAndStyle
 | |
|         let (c,s) = case (mult, defcs) of
 | |
|               (False, Just (defc,defs)) -> (defc, defs{asprecision=max (asprecision defs) prec})
 | |
|               _ -> ("", amountstyle{asprecision=prec, asdecimalpoint=mdec, asdigitgroups=mgrps})
 | |
|         return $ nullamt{acommodity=c, aquantity=sign q, aismultiplier=mult, astyle=s, aprice=Nothing}
 | |
| 
 | |
|   -- For reducing code duplication. Doesn't parse anything. Has the type
 | |
|   -- of a parser only in order to throw parse errors (for convenience).
 | |
|   interpretNumber
 | |
|     :: (Int, Int) -- offsets
 | |
|     -> Maybe AmountStyle
 | |
|     -> Either AmbiguousNumber RawNumber
 | |
|     -> Maybe Integer
 | |
|     -> TextParser m (Quantity, AmountPrecision, Maybe Char, Maybe DigitGroupStyle)
 | |
|   interpretNumber posRegion msuggestedStyle ambiguousNum mExp =
 | |
|     let rawNum = either (disambiguateNumber msuggestedStyle) id ambiguousNum
 | |
|     in  case fromRawNumber rawNum mExp of
 | |
|           Left errMsg -> customFailure $
 | |
|                            uncurry parseErrorAtRegion posRegion errMsg
 | |
|           Right (q,p,d,g) -> pure (q, Precision p, d, g)
 | |
| 
 | |
| -- | Parse an amount from a string, or get an error.
 | |
| amountp' :: String -> Amount
 | |
| amountp' s =
 | |
|   case runParser (evalStateT (amountp <* eof) nulljournal) "" (T.pack s) of
 | |
|     Right amt -> amt
 | |
|     Left err  -> error' $ show err  -- PARTIAL: XXX should throwError
 | |
| 
 | |
| -- | Parse a mixed amount from a string, or get an error.
 | |
| mamountp' :: String -> MixedAmount
 | |
| mamountp' = Mixed . (:[]) . amountp'
 | |
| 
 | |
| -- | Parse a minus or plus sign followed by zero or more spaces,
 | |
| -- or nothing, returning a function that negates or does nothing.
 | |
| signp :: Num a => TextParser m (a -> a)
 | |
| signp = ((char '-' *> pure negate <|> char '+' *> pure id) <* skipNonNewlineSpaces) <|> pure id
 | |
| 
 | |
| multiplierp :: TextParser m Bool
 | |
| multiplierp = option False $ char '*' *> pure True
 | |
| 
 | |
| commoditysymbolp :: TextParser m CommoditySymbol
 | |
| commoditysymbolp =
 | |
|   quotedcommoditysymbolp <|> simplecommoditysymbolp <?> "commodity symbol"
 | |
| 
 | |
| quotedcommoditysymbolp :: TextParser m CommoditySymbol
 | |
| quotedcommoditysymbolp =
 | |
|   between (char '"') (char '"') $ takeWhile1P Nothing f
 | |
|   where f c = c /= ';' && c /= '\n' && c /= '\"'
 | |
| 
 | |
| simplecommoditysymbolp :: TextParser m CommoditySymbol
 | |
| simplecommoditysymbolp = takeWhile1P Nothing (not . isNonsimpleCommodityChar)
 | |
| 
 | |
| priceamountp :: JournalParser m AmountPrice
 | |
| priceamountp = label "transaction price" $ do
 | |
|   -- https://www.ledger-cli.org/3.0/doc/ledger3.html#Virtual-posting-costs
 | |
|   parenthesised <- option False $ char '(' >> pure True
 | |
|   char '@'
 | |
|   priceConstructor <- char '@' *> pure TotalPrice <|> pure UnitPrice
 | |
|   when parenthesised $ void $ char ')'
 | |
| 
 | |
|   lift skipNonNewlineSpaces
 | |
|   priceAmount <- amountwithoutpricep -- <?> "unpriced amount (specifying a price)"
 | |
| 
 | |
|   pure $ priceConstructor priceAmount
 | |
| 
 | |
| balanceassertionp :: JournalParser m BalanceAssertion
 | |
| balanceassertionp = do
 | |
|   sourcepos <- genericSourcePos <$> lift getSourcePos
 | |
|   char '='
 | |
|   istotal <- fmap isJust $ optional $ try $ char '='
 | |
|   isinclusive <- fmap isJust $ optional $ try $ char '*'
 | |
|   lift skipNonNewlineSpaces
 | |
|   -- this amount can have a price; balance assertions ignore it,
 | |
|   -- but balance assignments will use it
 | |
|   a <- amountpnolotpricesp <?> "amount (for a balance assertion or assignment)"
 | |
|   return BalanceAssertion
 | |
|     { baamount    = a
 | |
|     , batotal     = istotal
 | |
|     , bainclusive = isinclusive
 | |
|     , baposition  = sourcepos
 | |
|     }
 | |
| 
 | |
| -- Parse a Ledger-style fixed {=UNITPRICE} or non-fixed {UNITPRICE}
 | |
| -- or fixed {{=TOTALPRICE}} or non-fixed {{TOTALPRICE}} lot price,
 | |
| -- and ignore it.
 | |
| -- https://www.ledger-cli.org/3.0/doc/ledger3.html#Fixing-Lot-Prices .
 | |
| lotpricep :: JournalParser m ()
 | |
| lotpricep = label "ledger-style lot price" $ do
 | |
|   char '{'
 | |
|   doublebrace <- option False $ char '{' >> pure True
 | |
|   _fixed <- fmap isJust $ optional $ lift skipNonNewlineSpaces >> char '='
 | |
|   lift skipNonNewlineSpaces
 | |
|   _a <- amountwithoutpricep
 | |
|   lift skipNonNewlineSpaces
 | |
|   char '}'
 | |
|   when (doublebrace) $ void $ char '}'
 | |
|   return ()
 | |
| 
 | |
| -- Parse a Ledger-style lot date [DATE], and ignore it.
 | |
| -- https://www.ledger-cli.org/3.0/doc/ledger3.html#Fixing-Lot-Prices .
 | |
| lotdatep :: JournalParser m ()
 | |
| lotdatep = (do
 | |
|   char '['
 | |
|   lift skipNonNewlineSpaces
 | |
|   _d <- datep
 | |
|   lift skipNonNewlineSpaces
 | |
|   char ']'
 | |
|   return ()
 | |
|   ) <?> "ledger-style lot date"
 | |
| 
 | |
| -- | 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 mark, 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 mark), the decimal mark character used if any,
 | |
| -- and the digit group style if any.
 | |
| --
 | |
| numberp :: Maybe AmountStyle -> TextParser m (Quantity, Word8, Maybe Char, Maybe DigitGroupStyle)
 | |
| numberp suggestedStyle = label "number" $ do
 | |
|     -- a number is an optional sign followed by a sequence of digits possibly
 | |
|     -- interspersed with periods, commas, or both
 | |
|     -- dbgparse 0 "numberp"
 | |
|     sign <- signp
 | |
|     rawNum <- either (disambiguateNumber suggestedStyle) id <$> rawnumberp
 | |
|     mExp <- optional $ try $ exponentp
 | |
|     dbg7 "numberp suggestedStyle" suggestedStyle `seq` return ()
 | |
|     case dbg7 "numberp quantity,precision,mdecimalpoint,mgrps"
 | |
|            $ fromRawNumber rawNum mExp of
 | |
|       Left errMsg -> Fail.fail errMsg
 | |
|       Right (q, p, d, g) -> pure (sign q, p, d, g)
 | |
| 
 | |
| exponentp :: TextParser m Integer
 | |
| exponentp = char' 'e' *> signp <*> decimal <?> "exponent"
 | |
| 
 | |
| -- | Interpret a raw number as a decimal number.
 | |
| --
 | |
| -- Returns:
 | |
| -- - the decimal number
 | |
| -- - the precision (number of digits after the decimal point)
 | |
| -- - the decimal point character, if any
 | |
| -- - the digit group style, if any (digit group character and sizes of digit groups)
 | |
| fromRawNumber
 | |
|   :: RawNumber
 | |
|   -> Maybe Integer
 | |
|   -> Either String
 | |
|             (Quantity, Word8, Maybe Char, Maybe DigitGroupStyle)
 | |
| fromRawNumber (WithSeparators _ _ _) (Just _) =
 | |
|     Left "invalid number: digit separators and exponents may not be used together"
 | |
| fromRawNumber raw mExp = do
 | |
|     (quantity, precision) <- toQuantity (fromMaybe 0 mExp) (digitGroup raw) (decimalGroup raw)
 | |
|     return (quantity, precision, mDecPt raw, digitGroupStyle raw)
 | |
|   where
 | |
|     toQuantity :: Integer -> DigitGrp -> DigitGrp -> Either String (Quantity, Word8)
 | |
|     toQuantity e preDecimalGrp postDecimalGrp
 | |
|       | precision < 0   = Right (Decimal 0 (digitGrpNum * 10^(-precision)), 0)
 | |
|       | precision < 256 = Right (Decimal precision8 digitGrpNum, precision8)
 | |
|       | otherwise = Left "invalid number: numbers with more than 255 decimal places are currently not supported"
 | |
|       where
 | |
|         digitGrpNum = digitGroupNumber $ preDecimalGrp <> postDecimalGrp
 | |
|         precision   = toInteger (digitGroupLength postDecimalGrp) - e
 | |
|         precision8  = fromIntegral precision :: Word8
 | |
| 
 | |
|     mDecPt (NoSeparators _ mDecimals)           = fst <$> mDecimals
 | |
|     mDecPt (WithSeparators _ _ mDecimals)       = fst <$> mDecimals
 | |
|     decimalGroup (NoSeparators _ mDecimals)     = maybe mempty snd mDecimals
 | |
|     decimalGroup (WithSeparators _ _ mDecimals) = maybe mempty snd mDecimals
 | |
|     digitGroup (NoSeparators digitGrp _)        = digitGrp
 | |
|     digitGroup (WithSeparators _ digitGrps _)   = mconcat digitGrps
 | |
|     digitGroupStyle (NoSeparators _ _)          = Nothing
 | |
|     digitGroupStyle (WithSeparators sep grps _) = Just . DigitGroups sep $ groupSizes grps
 | |
| 
 | |
|     -- Outputs digit group sizes from least significant to most significant
 | |
|     groupSizes :: [DigitGrp] -> [Word8]
 | |
|     groupSizes digitGrps = reverse $ case map (fromIntegral . digitGroupLength) digitGrps of
 | |
|       (a:b:cs) | a < b -> b:cs
 | |
|       gs               -> gs
 | |
| 
 | |
| disambiguateNumber :: Maybe AmountStyle -> AmbiguousNumber -> RawNumber
 | |
| disambiguateNumber msuggestedStyle (AmbiguousNumber grp1 sep grp2) =
 | |
|   -- If present, use the suggested style to disambiguate;
 | |
|   -- otherwise, assume that the separator is a decimal point where possible.
 | |
|   if isDecimalMark sep &&
 | |
|      maybe True (sep `isValidDecimalBy`) msuggestedStyle
 | |
|   then NoSeparators grp1 (Just (sep, grp2))
 | |
|   else WithSeparators sep [grp1, grp2] Nothing
 | |
|   where
 | |
|     isValidDecimalBy :: Char -> AmountStyle -> Bool
 | |
|     isValidDecimalBy c = \case
 | |
|       AmountStyle{asdecimalpoint = Just d} -> d == c
 | |
|       AmountStyle{asdigitgroups = Just (DigitGroups g _)} -> g /= c
 | |
|       AmountStyle{asprecision = Precision 0} -> False
 | |
|       _ -> True
 | |
| 
 | |
| -- | Parse and interpret the structure of a number without external hints.
 | |
| -- Numbers are digit strings, possibly separated into digit groups by one
 | |
| -- of two types of separators. (1) Numbers may optionally have a decimal
 | |
| -- mark, which may be either a period or comma. (2) Numbers may
 | |
| -- optionally contain digit group marks, which must all be either a
 | |
| -- period, a comma, or a space.
 | |
| --
 | |
| -- It is our task to deduce the characters used as decimal mark and
 | |
| -- digit group mark, based on the allowed syntax. For instance, we
 | |
| -- make use of the fact that a decimal mark can occur at most once and
 | |
| -- must be to the right of all digit group marks.
 | |
| --
 | |
| -- >>> parseTest rawnumberp "1,234,567.89"
 | |
| -- Right (WithSeparators ',' ["1","234","567"] (Just ('.',"89")))
 | |
| -- >>> parseTest rawnumberp "1,000"
 | |
| -- Left (AmbiguousNumber "1" ',' "000")
 | |
| -- >>> parseTest rawnumberp "1 000"
 | |
| -- Right (WithSeparators ' ' ["1","000"] Nothing)
 | |
| --
 | |
| rawnumberp :: TextParser m (Either AmbiguousNumber RawNumber)
 | |
| rawnumberp = label "number" $ do
 | |
|   rawNumber <- fmap Right leadingDecimalPt <|> leadingDigits
 | |
| 
 | |
|   -- Guard against mistyped numbers
 | |
|   mExtraDecimalSep <- optional $ lookAhead $ satisfy isDecimalMark
 | |
|   when (isJust mExtraDecimalSep) $
 | |
|     Fail.fail "invalid number (invalid use of separator)"
 | |
| 
 | |
|   mExtraFragment <- optional $ lookAhead $ try $
 | |
|     char ' ' *> getOffset <* digitChar
 | |
|   case mExtraFragment of
 | |
|     Just off -> customFailure $
 | |
|                   parseErrorAt off "invalid number (excessive trailing digits)"
 | |
|     Nothing -> pure ()
 | |
| 
 | |
|   return $ dbg7 "rawnumberp" rawNumber
 | |
|   where
 | |
| 
 | |
|   leadingDecimalPt :: TextParser m RawNumber
 | |
|   leadingDecimalPt = do
 | |
|     decPt <- satisfy isDecimalMark
 | |
|     decGrp <- digitgroupp
 | |
|     pure $ NoSeparators mempty (Just (decPt, decGrp))
 | |
| 
 | |
|   leadingDigits :: TextParser m (Either AmbiguousNumber RawNumber)
 | |
|   leadingDigits = do
 | |
|     grp1 <- digitgroupp
 | |
|     withSeparators grp1 <|> fmap Right (trailingDecimalPt grp1)
 | |
|                         <|> pure (Right $ NoSeparators grp1 Nothing)
 | |
| 
 | |
|   withSeparators :: DigitGrp -> TextParser m (Either AmbiguousNumber RawNumber)
 | |
|   withSeparators grp1 = do
 | |
|     (sep, grp2) <- try $ (,) <$> satisfy isDigitSeparatorChar <*> digitgroupp
 | |
|     grps <- many $ try $ char sep *> digitgroupp
 | |
| 
 | |
|     let digitGroups = grp1 : grp2 : grps
 | |
|     fmap Right (withDecimalPt sep digitGroups)
 | |
|       <|> pure (withoutDecimalPt grp1 sep grp2 grps)
 | |
| 
 | |
|   withDecimalPt :: Char -> [DigitGrp] -> TextParser m RawNumber
 | |
|   withDecimalPt digitSep digitGroups = do
 | |
|     decPt <- satisfy $ \c -> isDecimalMark c && c /= digitSep
 | |
|     decDigitGrp <- option mempty digitgroupp
 | |
| 
 | |
|     pure $ WithSeparators digitSep digitGroups (Just (decPt, decDigitGrp))
 | |
| 
 | |
|   withoutDecimalPt
 | |
|     :: DigitGrp
 | |
|     -> Char
 | |
|     -> DigitGrp
 | |
|     -> [DigitGrp]
 | |
|     -> Either AmbiguousNumber RawNumber
 | |
|   withoutDecimalPt grp1 sep grp2 grps
 | |
|     | null grps && isDecimalMark sep =
 | |
|         Left $ AmbiguousNumber grp1 sep grp2
 | |
|     | otherwise = Right $ WithSeparators sep (grp1:grp2:grps) Nothing
 | |
| 
 | |
|   trailingDecimalPt :: DigitGrp -> TextParser m RawNumber
 | |
|   trailingDecimalPt grp1 = do
 | |
|     decPt <- satisfy isDecimalMark
 | |
|     pure $ NoSeparators grp1 (Just (decPt, mempty))
 | |
| 
 | |
| isDigitSeparatorChar :: Char -> Bool
 | |
| isDigitSeparatorChar c = isDecimalMark c || c == ' '
 | |
| 
 | |
| -- | Some kinds of number literal we might parse.
 | |
| data RawNumber
 | |
|   = NoSeparators   DigitGrp (Maybe (Char, DigitGrp))
 | |
|     -- ^ A number with no digit group marks (eg 100),
 | |
|     --   or with a leading or trailing comma or period
 | |
|     --   which (apparently) we interpret as a decimal mark (like 100. or .100)
 | |
|   | WithSeparators Char [DigitGrp] (Maybe (Char, DigitGrp))
 | |
|     -- ^ A number with identifiable digit group marks
 | |
|     --   (eg 1,000,000 or 1,000.50 or 1 000)
 | |
|   deriving (Show, Eq)
 | |
| 
 | |
| -- | Another kind of number literal: this one contains either a digit
 | |
| -- group separator or a decimal mark, we're not sure which (eg 1,000 or 100.50).
 | |
| data AmbiguousNumber = AmbiguousNumber DigitGrp Char DigitGrp
 | |
|   deriving (Show, Eq)
 | |
| 
 | |
| -- | Description of a single digit group in a number literal.
 | |
| -- "Thousands" is one well known digit grouping, but there are others.
 | |
| data DigitGrp = DigitGrp {
 | |
|   digitGroupLength :: !Word,    -- ^ The number of digits in this group.
 | |
|                                 -- This is Word to avoid the need to do overflow
 | |
|                                 -- checking for the Semigroup instance of DigitGrp.
 | |
|   digitGroupNumber :: !Integer  -- ^ The natural number formed by this group's digits. This should always be positive.
 | |
| } deriving (Eq)
 | |
| 
 | |
| -- | A custom show instance, showing digit groups as the parser saw them.
 | |
| instance Show DigitGrp where
 | |
|   show (DigitGrp len num) = "\"" ++ padding ++ numStr ++ "\""
 | |
|     where numStr = show num
 | |
|           padding = genericReplicate (toInteger len - toInteger (length numStr)) '0'
 | |
| 
 | |
| instance Sem.Semigroup DigitGrp where
 | |
|   DigitGrp l1 n1 <> DigitGrp l2 n2 = DigitGrp (l1 + l2) (n1 * 10^l2 + n2)
 | |
| 
 | |
| instance Monoid DigitGrp where
 | |
|   mempty = DigitGrp 0 0
 | |
|   mappend = (Sem.<>)
 | |
| 
 | |
| digitgroupp :: TextParser m DigitGrp
 | |
| digitgroupp = label "digits"
 | |
|             $ makeGroup <$> takeWhile1P (Just "digit") isDigit
 | |
|   where
 | |
|     makeGroup = uncurry DigitGrp . T.foldl' step (0, 0)
 | |
|     step (!l, !a) c = (l+1, a*10 + fromIntegral (digitToInt c))
 | |
| 
 | |
| --- *** comments
 | |
| 
 | |
| multilinecommentp :: TextParser m ()
 | |
| multilinecommentp = startComment *> anyLine `skipManyTill` endComment
 | |
|   where
 | |
|     startComment = string "comment" *> trailingSpaces
 | |
|     endComment = eof <|> string "end comment" *> trailingSpaces
 | |
| 
 | |
|     trailingSpaces = skipNonNewlineSpaces <* newline
 | |
|     anyLine = void $ takeWhileP Nothing (\c -> c /= '\n') *> newline
 | |
| 
 | |
| {-# INLINABLE multilinecommentp #-}
 | |
| 
 | |
| -- | A blank or comment line in journal format: a line that's empty or
 | |
| -- containing only whitespace or whose first non-whitespace character
 | |
| -- is semicolon, hash, or star.
 | |
| emptyorcommentlinep :: TextParser m ()
 | |
| emptyorcommentlinep = do
 | |
|   skipNonNewlineSpaces
 | |
|   skiplinecommentp <|> void newline
 | |
|   where
 | |
|     skiplinecommentp :: TextParser m ()
 | |
|     skiplinecommentp = do
 | |
|       satisfy $ \c -> c == ';' || c == '#' || c == '*'
 | |
|       void $ takeWhileP Nothing (\c -> c /= '\n')
 | |
|       optional newline
 | |
|       pure ()
 | |
| 
 | |
| {-# INLINABLE emptyorcommentlinep #-}
 | |
| 
 | |
| -- A parser combinator for parsing (possibly multiline) comments
 | |
| -- following journal items.
 | |
| --
 | |
| -- Several journal items may be followed by comments, which begin with
 | |
| -- semicolons and extend to the end of the line. Such comments may span
 | |
| -- multiple lines, but comment lines below the journal item must be
 | |
| -- preceded by leading whitespace.
 | |
| --
 | |
| -- This parser combinator accepts a parser that consumes all input up
 | |
| -- until the next newline. This parser should extract the "content" from
 | |
| -- comments. The resulting parser returns this content plus the raw text
 | |
| -- of the comment itself.
 | |
| --
 | |
| -- See followingcommentp for tests.
 | |
| --
 | |
| followingcommentp' :: (Monoid a, Show a) => TextParser m a -> TextParser m (Text, a)
 | |
| followingcommentp' contentp = do
 | |
|   skipNonNewlineSpaces
 | |
|   -- there can be 0 or 1 sameLine
 | |
|   sameLine <- try headerp *> ((:[]) <$> match' contentp) <|> pure []
 | |
|   _ <- eolof
 | |
|   -- there can be 0 or more nextLines
 | |
|   nextLines <- many $
 | |
|     try (skipNonNewlineSpaces1 *> headerp) *> match' contentp <* eolof
 | |
|   let
 | |
|     -- if there's just a next-line comment, insert an empty same-line comment
 | |
|     -- so the next-line comment doesn't get rendered as a same-line comment.
 | |
|     sameLine' | null sameLine && not (null nextLines) = [("",mempty)]
 | |
|               | otherwise = sameLine
 | |
|     (texts, contents) = unzip $ sameLine' ++ nextLines
 | |
|     strippedCommentText = T.unlines $ map T.strip texts
 | |
|     commentContent = mconcat contents
 | |
|   pure (strippedCommentText, commentContent)
 | |
| 
 | |
|   where
 | |
|     headerp = char ';' *> skipNonNewlineSpaces
 | |
| 
 | |
| {-# INLINABLE followingcommentp' #-}
 | |
| 
 | |
| -- | Parse the text of a (possibly multiline) comment following a journal item.
 | |
| --
 | |
| -- >>> rtp followingcommentp ""   -- no comment
 | |
| -- Right ""
 | |
| -- >>> rtp followingcommentp ";"    -- just a (empty) same-line comment. newline is added
 | |
| -- Right "\n"
 | |
| -- >>> rtp followingcommentp ";  \n"
 | |
| -- Right "\n"
 | |
| -- >>> rtp followingcommentp ";\n ;\n"  -- a same-line and a next-line comment
 | |
| -- Right "\n\n"
 | |
| -- >>> rtp followingcommentp "\n ;\n"  -- just a next-line comment. Insert an empty same-line comment so the next-line comment doesn't become a same-line comment.
 | |
| -- Right "\n\n"
 | |
| --
 | |
| followingcommentp :: TextParser m Text
 | |
| followingcommentp =
 | |
|   fst <$> followingcommentp' (void $ takeWhileP Nothing (/= '\n'))  -- XXX support \r\n ?
 | |
| {-# INLINABLE followingcommentp #-}
 | |
| 
 | |
| 
 | |
| -- | Parse a transaction comment and extract its tags.
 | |
| --
 | |
| -- The first line of a transaction may be followed by comments, which
 | |
| -- begin with semicolons and extend to the end of the line. Transaction
 | |
| -- comments may span multiple lines, but comment lines below the
 | |
| -- transaction must be preceded by leading whitespace.
 | |
| --
 | |
| -- 2000/1/1 ; a transaction comment starting on the same line ...
 | |
| --   ; extending to the next line
 | |
| --   account1  $1
 | |
| --   account2
 | |
| --
 | |
| -- Tags are name-value pairs.
 | |
| --
 | |
| -- >>> let getTags (_,tags) = tags
 | |
| -- >>> let parseTags = fmap getTags . rtp transactioncommentp
 | |
| --
 | |
| -- >>> parseTags "; name1: val1, name2:all this is value2"
 | |
| -- Right [("name1","val1"),("name2","all this is value2")]
 | |
| --
 | |
| -- A tag's name must be immediately followed by a colon, without
 | |
| -- separating whitespace. The corresponding value consists of all the text
 | |
| -- following the colon up until the next colon or newline, stripped of
 | |
| -- leading and trailing whitespace.
 | |
| --
 | |
| transactioncommentp :: TextParser m (Text, [Tag])
 | |
| transactioncommentp = followingcommentp' commenttagsp
 | |
| {-# INLINABLE transactioncommentp #-}
 | |
| 
 | |
| commenttagsp :: TextParser m [Tag]
 | |
| commenttagsp = do
 | |
|   tagName <- fmap (last . T.split isSpace)
 | |
|             $ takeWhileP Nothing (\c -> c /= ':' && c /= '\n')
 | |
|   atColon tagName <|> pure [] -- if not ':', then either '\n' or EOF
 | |
| 
 | |
|   where
 | |
|     atColon :: Text -> TextParser m [Tag]
 | |
|     atColon name = char ':' *> do
 | |
|       if T.null name
 | |
|         then commenttagsp
 | |
|         else do
 | |
|           skipNonNewlineSpaces
 | |
|           val <- tagValue
 | |
|           let tag = (name, val)
 | |
|           (tag:) <$> commenttagsp
 | |
| 
 | |
|     tagValue :: TextParser m Text
 | |
|     tagValue = do
 | |
|       val <- T.strip <$> takeWhileP Nothing (\c -> c /= ',' && c /= '\n')
 | |
|       _ <- optional $ char ','
 | |
|       pure val
 | |
| 
 | |
| {-# INLINABLE commenttagsp #-}
 | |
| 
 | |
| 
 | |
| -- | Parse a posting comment and extract its tags and dates.
 | |
| --
 | |
| -- Postings may be followed by comments, which begin with semicolons and
 | |
| -- extend to the end of the line. Posting comments may span multiple
 | |
| -- lines, but comment lines below the posting must be preceded by
 | |
| -- leading whitespace.
 | |
| --
 | |
| -- 2000/1/1
 | |
| --   account1  $1 ; a posting comment starting on the same line ...
 | |
| --   ; extending to the next line
 | |
| --
 | |
| --   account2
 | |
| --   ; a posting comment beginning on the next line
 | |
| --
 | |
| -- Tags are name-value pairs.
 | |
| --
 | |
| -- >>> let getTags (_,tags,_,_) = tags
 | |
| -- >>> let parseTags = fmap getTags . rtp (postingcommentp Nothing)
 | |
| --
 | |
| -- >>> parseTags "; name1: val1, name2:all this is value2"
 | |
| -- Right [("name1","val1"),("name2","all this is value2")]
 | |
| --
 | |
| -- A tag's name must be immediately followed by a colon, without
 | |
| -- separating whitespace. The corresponding value consists of all the text
 | |
| -- following the colon up until the next colon or newline, stripped of
 | |
| -- leading and trailing whitespace.
 | |
| --
 | |
| -- Posting dates may be expressed with "date"/"date2" tags or with
 | |
| -- bracketed date syntax. Posting dates will inherit their year from the
 | |
| -- transaction date if the year is not specified. We throw parse errors on
 | |
| -- invalid dates.
 | |
| --
 | |
| -- >>> let getDates (_,_,d1,d2) = (d1, d2)
 | |
| -- >>> let parseDates = fmap getDates . rtp (postingcommentp (Just 2000))
 | |
| --
 | |
| -- >>> parseDates "; date: 1/2, date2: 1999/12/31"
 | |
| -- Right (Just 2000-01-02,Just 1999-12-31)
 | |
| -- >>> parseDates "; [1/2=1999/12/31]"
 | |
| -- Right (Just 2000-01-02,Just 1999-12-31)
 | |
| --
 | |
| -- Example: tags, date tags, and bracketed dates
 | |
| -- >>> rtp (postingcommentp (Just 2000)) "; a:b, date:3/4, [=5/6]"
 | |
| -- Right ("a:b, date:3/4, [=5/6]\n",[("a","b"),("date","3/4")],Just 2000-03-04,Just 2000-05-06)
 | |
| --
 | |
| -- Example: extraction of dates from date tags ignores trailing text
 | |
| -- >>> rtp (postingcommentp (Just 2000)) "; date:3/4=5/6"
 | |
| -- Right ("date:3/4=5/6\n",[("date","3/4=5/6")],Just 2000-03-04,Nothing)
 | |
| --
 | |
| postingcommentp
 | |
|   :: Maybe Year -> TextParser m (Text, [Tag], Maybe Day, Maybe Day)
 | |
| postingcommentp mYear = do
 | |
|   (commentText, (tags, dateTags)) <-
 | |
|     followingcommentp' (commenttagsanddatesp mYear)
 | |
|   let mdate  = fmap snd $ find ((=="date") .fst) dateTags
 | |
|       mdate2 = fmap snd $ find ((=="date2").fst) dateTags
 | |
|   pure (commentText, tags, mdate, mdate2)
 | |
| {-# INLINABLE postingcommentp #-}
 | |
| 
 | |
| 
 | |
| commenttagsanddatesp
 | |
|   :: Maybe Year -> TextParser m ([Tag], [DateTag])
 | |
| commenttagsanddatesp mYear = do
 | |
|   (txt, dateTags) <- match $ readUpTo ':'
 | |
|   -- next char is either ':' or '\n' (or EOF)
 | |
|   let tagName = last (T.split isSpace txt)
 | |
|   (fmap.second) (dateTags++) (atColon tagName) <|> pure ([], dateTags) -- if not ':', then either '\n' or EOF
 | |
| 
 | |
|   where
 | |
|     readUpTo :: Char -> TextParser m [DateTag]
 | |
|     readUpTo end = do
 | |
|       void $ takeWhileP Nothing (\c -> c /= end && c /= '\n' && c /= '[')
 | |
|       -- if not '[' then ':' or '\n' or EOF
 | |
|       atBracket (readUpTo end) <|> pure []
 | |
| 
 | |
|     atBracket :: TextParser m [DateTag] -> TextParser m [DateTag]
 | |
|     atBracket cont = do
 | |
|       -- Uses the fact that bracketed date-tags cannot contain newlines
 | |
|       dateTags <- option [] $ lookAhead (bracketeddatetagsp mYear)
 | |
|       _ <- char '['
 | |
|       dateTags' <- cont
 | |
|       pure $ dateTags ++ dateTags'
 | |
| 
 | |
|     atColon :: Text -> TextParser m ([Tag], [DateTag])
 | |
|     atColon name = char ':' *> do
 | |
|       skipNonNewlineSpaces
 | |
|       (tags, dateTags) <- case name of
 | |
|         ""      -> pure ([], [])
 | |
|         "date"  -> dateValue name
 | |
|         "date2" -> dateValue name
 | |
|         _       -> tagValue name
 | |
|       _ <- optional $ char ','
 | |
|       bimap (tags++) (dateTags++) <$> commenttagsanddatesp mYear
 | |
| 
 | |
|     dateValue :: Text -> TextParser m ([Tag], [DateTag])
 | |
|     dateValue name = do
 | |
|       (txt, (date, dateTags)) <- match' $ do
 | |
|         date <- datep' mYear
 | |
|         dateTags <- readUpTo ','
 | |
|         pure (date, dateTags)
 | |
|       let val = T.strip txt
 | |
|       pure $ ( [(name, val)]
 | |
|              , (name, date) : dateTags )
 | |
| 
 | |
|     tagValue :: Text -> TextParser m ([Tag], [DateTag])
 | |
|     tagValue name = do
 | |
|       (txt, dateTags) <- match' $ readUpTo ','
 | |
|       let val = T.strip txt
 | |
|       pure $ ( [(name, val)]
 | |
|              , dateTags )
 | |
| 
 | |
| {-# INLINABLE commenttagsanddatesp #-}
 | |
| 
 | |
| -- | Parse Ledger-style bracketed posting dates ([DATE=DATE2]), as
 | |
| -- "date" and/or "date2" tags. Anything that looks like an attempt at
 | |
| -- this (a square-bracketed sequence of 0123456789/-.= containing at
 | |
| -- least one digit and one date separator) is also parsed, and will
 | |
| -- throw an appropriate error.
 | |
| --
 | |
| -- The dates are parsed in full here so that errors are reported in
 | |
| -- the right position. A missing year in DATE can be inferred if a
 | |
| -- default date is provided. A missing year in DATE2 will be inferred
 | |
| -- from DATE.
 | |
| --
 | |
| -- >>> either (Left . customErrorBundlePretty) Right $ rtp (bracketeddatetagsp Nothing) "[2016/1/2=3/4]"
 | |
| -- Right [("date",2016-01-02),("date2",2016-03-04)]
 | |
| --
 | |
| -- >>> either (Left . customErrorBundlePretty) Right $ rtp (bracketeddatetagsp Nothing) "[1]"
 | |
| -- Left ...not a bracketed date...
 | |
| --
 | |
| -- >>> either (Left . customErrorBundlePretty) Right $ rtp (bracketeddatetagsp Nothing) "[2016/1/32]"
 | |
| -- Left ...1:2:...well-formed but invalid date: 2016/1/32...
 | |
| --
 | |
| -- >>> either (Left . customErrorBundlePretty) Right $ rtp (bracketeddatetagsp Nothing) "[1/31]"
 | |
| -- Left ...1:2:...partial date 1/31 found, but the current year is unknown...
 | |
| --
 | |
| -- >>> either (Left . customErrorBundlePretty) Right $ rtp (bracketeddatetagsp Nothing) "[0123456789/-.=/-.=]"
 | |
| -- Left ...1:13:...expecting month or day...
 | |
| --
 | |
| bracketeddatetagsp
 | |
|   :: Maybe Year -> TextParser m [(TagName, Day)]
 | |
| bracketeddatetagsp mYear1 = do
 | |
|   -- dbgparse 0 "bracketeddatetagsp"
 | |
|   try $ do
 | |
|     s <- lookAhead
 | |
|        $ between (char '[') (char ']')
 | |
|        $ takeWhile1P Nothing isBracketedDateChar
 | |
|     unless (T.any isDigit s && T.any isDateSepChar s) $
 | |
|       Fail.fail "not a bracketed date"
 | |
|   -- Looks sufficiently like a bracketed date to commit to parsing a date
 | |
| 
 | |
|   between (char '[') (char ']') $ do
 | |
|     md1 <- optional $ datep' mYear1
 | |
| 
 | |
|     let mYear2 = fmap readYear md1 <|> mYear1
 | |
|     md2 <- optional $ char '=' *> datep' mYear2
 | |
| 
 | |
|     pure $ catMaybes [("date",) <$> md1, ("date2",) <$> md2]
 | |
| 
 | |
|   where
 | |
|     readYear = first3 . toGregorian
 | |
|     isBracketedDateChar c = isDigit c || isDateSepChar c || c == '='
 | |
| 
 | |
| {-# INLINABLE bracketeddatetagsp #-}
 | |
| 
 | |
| -- | Get the account name aliases from options, if any.
 | |
| aliasesFromOpts :: InputOpts -> [AccountAlias]
 | |
| aliasesFromOpts = map (\a -> fromparse $ runParser accountaliasp ("--alias "++quoteIfNeeded a) $ T.pack a)
 | |
|                   . aliases_
 | |
| 
 | |
| accountaliasp :: TextParser m AccountAlias
 | |
| accountaliasp = regexaliasp <|> basicaliasp
 | |
| 
 | |
| basicaliasp :: TextParser m AccountAlias
 | |
| basicaliasp = do
 | |
|   -- dbgparse 0 "basicaliasp"
 | |
|   old <- rstrip <$> (some $ noneOf ("=" :: [Char]))
 | |
|   char '='
 | |
|   skipNonNewlineSpaces
 | |
|   new <- rstrip <$> anySingle `manyTill` eolof  -- eol in journal, eof in command lines, normally
 | |
|   return $ BasicAlias (T.pack old) (T.pack new)
 | |
| 
 | |
| regexaliasp :: TextParser m AccountAlias
 | |
| regexaliasp = do
 | |
|   -- dbgparse 0 "regexaliasp"
 | |
|   char '/'
 | |
|   off1 <- getOffset
 | |
|   re <- some $ noneOf ("/\n\r" :: [Char]) -- paranoid: don't try to read past line end
 | |
|   off2 <- getOffset
 | |
|   char '/'
 | |
|   skipNonNewlineSpaces
 | |
|   char '='
 | |
|   skipNonNewlineSpaces
 | |
|   repl <- anySingle `manyTill` eolof
 | |
|   case toRegexCI $ T.pack re of
 | |
|     Right r -> return $! RegexAlias r repl
 | |
|     Left e  -> customFailure $! parseErrorAtRegion off1 off2 e
 | |
| 
 | |
| --- ** tests
 | |
| 
 | |
| tests_Common = tests "Common" [
 | |
| 
 | |
|    tests "amountp" [
 | |
|     test "basic"                  $ assertParseEq amountp "$47.18"     (usd 47.18)
 | |
|    ,test "ends with decimal mark" $ assertParseEq amountp "$1."        (usd 1  `withPrecision` Precision 0)
 | |
|    ,test "unit price"             $ assertParseEq amountp "$10 @ €0.5"
 | |
|       -- not precise enough:
 | |
|       -- (usd 10 `withPrecision` 0 `at` (eur 0.5 `withPrecision` 1)) -- `withStyle` asdecimalpoint=Just '.'
 | |
|       amount{
 | |
|          acommodity="$"
 | |
|         ,aquantity=10 -- need to test internal precision with roundTo ? I think not
 | |
|         ,astyle=amountstyle{asprecision=Precision 0, asdecimalpoint=Nothing}
 | |
|         ,aprice=Just $ UnitPrice $
 | |
|           amount{
 | |
|              acommodity="€"
 | |
|             ,aquantity=0.5
 | |
|             ,astyle=amountstyle{asprecision=Precision 1, asdecimalpoint=Just '.'}
 | |
|             }
 | |
|         }
 | |
|    ,test "total price"            $ assertParseEq amountp "$10 @@ €5"
 | |
|       amount{
 | |
|          acommodity="$"
 | |
|         ,aquantity=10
 | |
|         ,astyle=amountstyle{asprecision=Precision 0, asdecimalpoint=Nothing}
 | |
|         ,aprice=Just $ TotalPrice $
 | |
|           amount{
 | |
|              acommodity="€"
 | |
|             ,aquantity=5
 | |
|             ,astyle=amountstyle{asprecision=Precision 0, asdecimalpoint=Nothing}
 | |
|             }
 | |
|         }
 | |
|    ,test "unit price, parenthesised" $ assertParse amountp "$10 (@) €0.5"
 | |
|    ,test "total price, parenthesised" $ assertParse amountp "$10 (@@) €0.5"
 | |
|    ]
 | |
| 
 | |
|   ,let p = lift (numberp Nothing) :: JournalParser IO (Quantity, Word8, Maybe Char, Maybe DigitGroupStyle) in
 | |
|    test "numberp" $ do
 | |
|      assertParseEq p "0"          (0, 0, Nothing, Nothing)
 | |
|      assertParseEq p "1"          (1, 0, Nothing, Nothing)
 | |
|      assertParseEq p "1.1"        (1.1, 1, Just '.', Nothing)
 | |
|      assertParseEq p "1,000.1"    (1000.1, 1, Just '.', Just $ DigitGroups ',' [3])
 | |
|      assertParseEq p "1.00.000,1" (100000.1, 1, Just ',', Just $ DigitGroups '.' [3,2])
 | |
|      assertParseEq p "1,000,000"  (1000000, 0, Nothing, Just $ DigitGroups ',' [3,3])  -- could be simplified to [3]
 | |
|      assertParseEq p "1."         (1, 0, Just '.', Nothing)
 | |
|      assertParseEq p "1,"         (1, 0, Just ',', Nothing)
 | |
|      assertParseEq p ".1"         (0.1, 1, Just '.', Nothing)
 | |
|      assertParseEq p ",1"         (0.1, 1, Just ',', Nothing)
 | |
|      assertParseError p "" ""
 | |
|      assertParseError p "1,000.000,1" ""
 | |
|      assertParseError p "1.000,000.1" ""
 | |
|      assertParseError p "1,000.000.1" ""
 | |
|      assertParseError p "1,,1" ""
 | |
|      assertParseError p "1..1" ""
 | |
|      assertParseError p ".1," ""
 | |
|      assertParseError p ",1." ""
 | |
|      assertParseEq    p "1.555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555" (1.555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555, 255, Just '.', Nothing)
 | |
|      assertParseError p "1.5555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555" ""
 | |
| 
 | |
|   ,tests "spaceandamountormissingp" [
 | |
|      test "space and amount" $ assertParseEq spaceandamountormissingp " $47.18" (Mixed [usd 47.18])
 | |
|     ,test "empty string" $ assertParseEq spaceandamountormissingp "" missingmixedamt
 | |
|     -- ,test "just space" $ assertParseEq spaceandamountormissingp " " missingmixedamt  -- XXX should it ?
 | |
|     -- ,test "just amount" $ assertParseError spaceandamountormissingp "$47.18" ""  -- succeeds, consuming nothing
 | |
|     ]
 | |
| 
 | |
|   ]
 |