packages feed

hledger-lib 1.43.2 → 1.50

raw patch · 35 files changed

+2233/−1280 lines, 35 filesdep +thesedep ~basePVP ok

version bump matches the API change (PVP)

Dependencies added: these

Dependency ranges changed: base

API changes (from Hackage documentation)

- Hledger.Data: [aebalance] :: Account -> MixedAmount
- Hledger.Data: [aibalance] :: Account -> MixedAmount
- Hledger.Data: [anumpostings] :: Account -> Int
- Hledger.Data.Account: instance GHC.Classes.Eq Hledger.Data.Types.Account
- Hledger.Data.Account: instance GHC.Show.Show Hledger.Data.Types.Account
- Hledger.Data.Account: sortAccountTreeByAmount :: NormalSign -> Account -> Account
- Hledger.Data.Amount: instance Hledger.Data.Types.HasAmounts Hledger.Data.Types.Account
- Hledger.Data.Journal: canonicalStyleFrom :: [AmountStyle] -> AmountStyle
- Hledger.Data.Journal: commodityStylesFromAmounts :: [Amount] -> Either String (Map CommoditySymbol AmountStyle)
- Hledger.Data.Journal: journalLeafAccountNamesDeclared :: Journal -> [AccountName]
- Hledger.Data.Journal: showJournalAmountsDebug :: Journal -> String
- Hledger.Data.Json: instance Data.Aeson.Types.FromJSON.FromJSON Hledger.Data.Types.Account
- Hledger.Data.Json: instance Data.Aeson.Types.ToJSON.ToJSON Hledger.Data.Types.Account
- Hledger.Data.Timeclock: timeclockEntriesToTransactions :: LocalTime -> [TimeclockEntry] -> [Transaction]
- Hledger.Data.Timeclock: timeclockEntriesToTransactionsSingle :: LocalTime -> [TimeclockEntry] -> [Transaction]
- Hledger.Data.Types: [aebalance] :: Account -> MixedAmount
- Hledger.Data.Types: [aibalance] :: Account -> MixedAmount
- Hledger.Data.Types: [anumpostings] :: Account -> Int
- Hledger.Data.Types: instance GHC.Generics.Generic Hledger.Data.Types.Account
- Hledger.Query: matchesTags :: Regexp -> Maybe Regexp -> [Tag] -> Bool
- Hledger.Read: type PrefixedFilePath = FilePath
- Hledger.Reports.BudgetReport: combineBudgetAndActual :: ReportOpts -> Journal -> MultiBalanceReport -> MultiBalanceReport -> BudgetReport
- Hledger.Reports.MultiBalanceReport: generateMultiBalanceReport :: ReportSpec -> Journal -> PriceOracle -> Set AccountName -> [(DateSpan, [Posting])] -> HashMap AccountName Account -> MultiBalanceReport
- Hledger.Reports.MultiBalanceReport: getPostingsByColumn :: ReportSpec -> Journal -> PriceOracle -> [DateSpan] -> [(DateSpan, [Posting])]
- Hledger.Reports.MultiBalanceReport: sortRows :: ReportOpts -> Journal -> [MultiBalanceReportRow] -> [MultiBalanceReportRow]
- Hledger.Reports.MultiBalanceReport: sortRowsLike :: [AccountName] -> [PeriodicReportRow DisplayName b] -> [PeriodicReportRow DisplayName b]
- Hledger.Reports.MultiBalanceReport: startingPostings :: ReportSpec -> Journal -> PriceOracle -> DateSpan -> [Posting]
+ Hledger.Data: BalanceData :: MixedAmount -> MixedAmount -> Int -> BalanceData
+ Hledger.Data: PeriodData :: a -> IntMap a -> PeriodData a
+ Hledger.Data: [adata] :: Account a -> PeriodData a
+ Hledger.Data: [bdexcludingsubs] :: BalanceData -> MixedAmount
+ Hledger.Data: [bdincludingsubs] :: BalanceData -> MixedAmount
+ Hledger.Data: [bdnumpostings] :: BalanceData -> Int
+ Hledger.Data: [pdperiods] :: PeriodData a -> IntMap a
+ Hledger.Data: [pdpre] :: PeriodData a -> a
+ Hledger.Data: data BalanceData
+ Hledger.Data: data PeriodData a
+ Hledger.Data: insertPeriodData :: Semigroup a => Maybe Day -> a -> PeriodData a -> PeriodData a
+ Hledger.Data: lookupPeriodData :: Day -> PeriodData a -> a
+ Hledger.Data: mapBalanceData :: (MixedAmount -> MixedAmount) -> BalanceData -> BalanceData
+ Hledger.Data: mergePeriodData :: (a -> c) -> (b -> c) -> (a -> b -> c) -> PeriodData a -> PeriodData b -> PeriodData c
+ Hledger.Data: opBalanceData :: (MixedAmount -> MixedAmount -> MixedAmount) -> BalanceData -> BalanceData -> BalanceData
+ Hledger.Data: opPeriodData :: (a -> b -> c) -> PeriodData a -> PeriodData b -> PeriodData c
+ Hledger.Data: padPeriodData :: Monoid a => IntSet -> PeriodData a -> PeriodData a
+ Hledger.Data: periodDataFromList :: a -> [(Day, a)] -> PeriodData a
+ Hledger.Data: tests_BalanceData :: TestTree
+ Hledger.Data: tests_PeriodData :: TestTree
+ Hledger.Data.Account: accountFromBalances :: AccountName -> PeriodData a -> Account a
+ Hledger.Data.Account: accountFromPostings :: (Posting -> Maybe Day) -> [Posting] -> Account BalanceData
+ Hledger.Data.Account: accountTreeFromBalanceAndNames :: AccountName -> PeriodData a -> [AccountName] -> Account a
+ Hledger.Data.Account: instance GHC.Classes.Eq (Hledger.Data.Types.Account a)
+ Hledger.Data.Account: instance GHC.Show.Show a => GHC.Show.Show (Hledger.Data.Types.Account a)
+ Hledger.Data.Account: mapPeriodData :: (PeriodData a -> PeriodData a) -> Account a -> Account a
+ Hledger.Data.Account: mergeAccounts :: Account a -> Account b -> Account (These a b)
+ Hledger.Data.Account: sortAccountTreeByDeclaration :: Account a -> Account a
+ Hledger.Data.Account: sortAccountTreeOn :: Ord b => (Account a -> b) -> Account a -> Account a
+ Hledger.Data.Account: tests_Account :: TestTree
+ Hledger.Data.Amount: commodityStylesFromAmounts :: [Amount] -> Either String (Map CommoditySymbol AmountStyle)
+ Hledger.Data.Amount: getAmounts :: Amount -> [Amount]
+ Hledger.Data.Amount: instance Hledger.Data.Types.HasAmounts Hledger.Data.Types.BalanceData
+ Hledger.Data.Amount: instance Hledger.Data.Types.HasAmounts a => Hledger.Data.Types.HasAmounts (Hledger.Data.Types.Account a)
+ Hledger.Data.Amount: instance Hledger.Data.Types.HasAmounts a => Hledger.Data.Types.HasAmounts (Hledger.Data.Types.PeriodData a)
+ Hledger.Data.Amount: sumAndAverageMixedAmounts :: Foldable f => f MixedAmount -> (MixedAmount, MixedAmount)
+ Hledger.Data.Balancing: [txn_balancing_] :: BalancingOpts -> TransactionBalancingPrecision
+ Hledger.Data.Balancing: txn_balancing :: HasBalancingOpts c => Lens' c TransactionBalancingPrecision
+ Hledger.Data.Dates: spanValidDefaultsFrom :: DateSpan -> DateSpan -> DateSpan
+ Hledger.Data.Journal: journalAmounts :: Journal -> Set Amount
+ Hledger.Data.Journal: journalCommoditiesFromPriceDirectives :: Journal -> Set CommoditySymbol
+ Hledger.Data.Journal: journalCommoditiesFromTransactions :: Journal -> Set CommoditySymbol
+ Hledger.Data.Journal: journalPostingAndCostAmounts :: Journal -> [Amount]
+ Hledger.Data.Journal: journalPostingsKeepAccountTagsOnly :: Journal -> Journal
+ Hledger.Data.Journal: showJournalPostingAmountsDebug :: Journal -> String
+ Hledger.Data.Json: instance Data.Aeson.Types.FromJSON.FromJSON Hledger.Data.Types.BalanceData
+ Hledger.Data.Json: instance Data.Aeson.Types.FromJSON.FromJSON a => Data.Aeson.Types.FromJSON.FromJSON (Hledger.Data.Types.Account a)
+ Hledger.Data.Json: instance Data.Aeson.Types.FromJSON.FromJSON a => Data.Aeson.Types.FromJSON.FromJSON (Hledger.Data.Types.PeriodData a)
+ Hledger.Data.Json: instance Data.Aeson.Types.ToJSON.ToJSON Hledger.Data.Types.BalanceData
+ Hledger.Data.Json: instance Data.Aeson.Types.ToJSON.ToJSON a => Data.Aeson.Types.ToJSON.ToJSON (Hledger.Data.Types.Account a)
+ Hledger.Data.Json: instance Data.Aeson.Types.ToJSON.ToJSON a => Data.Aeson.Types.ToJSON.ToJSON (Hledger.Data.Types.PeriodData a)
+ Hledger.Data.Timeclock: instance GHC.Show.Show Hledger.Data.Timeclock.Session
+ Hledger.Data.Timeclock: instance GHC.Show.Show Hledger.Data.Timeclock.Sessions
+ Hledger.Data.Timeclock: timeclockToTransactions :: LocalTime -> [TimeclockEntry] -> [Transaction]
+ Hledger.Data.Timeclock: timeclockToTransactionsOld :: LocalTime -> [TimeclockEntry] -> [Transaction]
+ Hledger.Data.Transaction: TBPExact :: TransactionBalancingPrecision
+ Hledger.Data.Transaction: TBPOld :: TransactionBalancingPrecision
+ Hledger.Data.Transaction: data TransactionBalancingPrecision
+ Hledger.Data.Transaction: instance GHC.Classes.Eq Hledger.Data.Transaction.TransactionBalancingPrecision
+ Hledger.Data.Transaction: instance GHC.Classes.Ord Hledger.Data.Transaction.TransactionBalancingPrecision
+ Hledger.Data.Transaction: instance GHC.Enum.Bounded Hledger.Data.Transaction.TransactionBalancingPrecision
+ Hledger.Data.Transaction: instance GHC.Enum.Enum Hledger.Data.Transaction.TransactionBalancingPrecision
+ Hledger.Data.Transaction: instance GHC.Read.Read Hledger.Data.Transaction.TransactionBalancingPrecision
+ Hledger.Data.Transaction: instance GHC.Show.Show Hledger.Data.Transaction.TransactionBalancingPrecision
+ Hledger.Data.Transaction: transactionCommodityStyles :: Transaction -> Map CommoditySymbol AmountStyle
+ Hledger.Data.Transaction: transactionCommodityStylesWith :: Rounding -> Transaction -> Map CommoditySymbol AmountStyle
+ Hledger.Data.Types: BalanceData :: MixedAmount -> MixedAmount -> Int -> BalanceData
+ Hledger.Data.Types: PeriodData :: a -> IntMap a -> PeriodData a
+ Hledger.Data.Types: [adata] :: Account a -> PeriodData a
+ Hledger.Data.Types: [bdexcludingsubs] :: BalanceData -> MixedAmount
+ Hledger.Data.Types: [bdincludingsubs] :: BalanceData -> MixedAmount
+ Hledger.Data.Types: [bdnumpostings] :: BalanceData -> Int
+ Hledger.Data.Types: [pdperiods] :: PeriodData a -> IntMap a
+ Hledger.Data.Types: [pdpre] :: PeriodData a -> a
+ Hledger.Data.Types: data BalanceData
+ Hledger.Data.Types: data PeriodData a
+ Hledger.Data.Types: instance GHC.Base.Functor Hledger.Data.Types.Account
+ Hledger.Data.Types: instance GHC.Base.Functor Hledger.Data.Types.PeriodData
+ Hledger.Data.Types: instance GHC.Classes.Eq Hledger.Data.Types.BalanceData
+ Hledger.Data.Types: instance GHC.Classes.Eq a => GHC.Classes.Eq (Hledger.Data.Types.PeriodData a)
+ Hledger.Data.Types: instance GHC.Generics.Generic (Hledger.Data.Types.Account a)
+ Hledger.Data.Types: instance GHC.Generics.Generic (Hledger.Data.Types.PeriodData a)
+ Hledger.Data.Types: instance GHC.Generics.Generic Hledger.Data.Types.BalanceData
+ Hledger.Query: matchesTag :: Query -> Tag -> Bool
+ Hledger.Read: instance GHC.Show.Show Hledger.Read.LatestDatesForFile
+ Hledger.Read.Common: accountnamenosemicolonp :: forall (m :: Type -> Type). TextParser m AccountName
+ Hledger.Read.Common: isStdin :: PrefixedFilePath -> Bool
+ Hledger.Read.Common: type PrefixedFilePath = FilePath
+ Hledger.Reports.MultiBalanceReport: generateMultiBalanceAccount :: ReportSpec -> Journal -> PriceOracle -> [DateSpan] -> [Posting] -> Account BalanceData
+ Hledger.Reports.MultiBalanceReport: generatePeriodicReport :: Show c => (forall a. () => ReportOpts -> (BalanceData -> MixedAmount) -> a -> Account b -> PeriodicReportRow a c) -> (b -> MixedAmount) -> (c -> MixedAmount) -> ReportOpts -> [DateSpan] -> Account b -> PeriodicReport DisplayName c
+ Hledger.Reports.MultiBalanceReport: makePeriodicReportRow :: c -> (IntMap c -> (c, c)) -> ReportOpts -> (b -> c) -> a -> Account b -> PeriodicReportRow a c
+ Hledger.Reports.ReportOptions: requiresHistorical :: ReportOpts -> Bool
+ Hledger.Utils: all1 :: (a -> Bool) -> [a] -> Bool
+ Hledger.Utils.IO: getFlag :: [String] -> IO Bool
+ Hledger.Utils.IO: warnIO :: MonadIO m => String -> m ()
- Hledger.Data: Account :: AccountName -> Maybe AccountDeclarationInfo -> [Account] -> Maybe Account -> Bool -> Int -> MixedAmount -> MixedAmount -> Account
+ Hledger.Data: Account :: AccountName -> Maybe AccountDeclarationInfo -> [Account a] -> Maybe (Account a) -> Bool -> PeriodData a -> Account a
- Hledger.Data: Ledger :: Journal -> [Account] -> Ledger
+ Hledger.Data: Ledger :: Journal -> [Account BalanceData] -> Ledger
- Hledger.Data: [aboring] :: Account -> Bool
+ Hledger.Data: [aboring] :: Account a -> Bool
- Hledger.Data: [adeclarationinfo] :: Account -> Maybe AccountDeclarationInfo
+ Hledger.Data: [adeclarationinfo] :: Account a -> Maybe AccountDeclarationInfo
- Hledger.Data: [aname] :: Account -> AccountName
+ Hledger.Data: [aname] :: Account a -> AccountName
- Hledger.Data: [aparent] :: Account -> Maybe Account
+ Hledger.Data: [aparent] :: Account a -> Maybe (Account a)
- Hledger.Data: [asubs] :: Account -> [Account]
+ Hledger.Data: [asubs] :: Account a -> [Account a]
- Hledger.Data: [laccounts] :: Ledger -> [Account]
+ Hledger.Data: [laccounts] :: Ledger -> [Account BalanceData]
- Hledger.Data: data Account
+ Hledger.Data: data Account a
- Hledger.Data.Account: accountSetDeclarationInfo :: Journal -> Account -> Account
+ Hledger.Data.Account: accountSetDeclarationInfo :: Journal -> Account a -> Account a
- Hledger.Data.Account: accountTree :: AccountName -> [AccountName] -> Account
+ Hledger.Data.Account: accountTree :: Monoid a => AccountName -> [AccountName] -> Account a
- Hledger.Data.Account: accountsFromPostings :: [Posting] -> [Account]
+ Hledger.Data.Account: accountsFromPostings :: (Posting -> Maybe Day) -> [Posting] -> [Account BalanceData]
- Hledger.Data.Account: accountsLevels :: Account -> [[Account]]
+ Hledger.Data.Account: accountsLevels :: Account a -> [[Account a]]
- Hledger.Data.Account: anyAccounts :: (Account -> Bool) -> Account -> Bool
+ Hledger.Data.Account: anyAccounts :: (Account a -> Bool) -> Account a -> Bool
- Hledger.Data.Account: clipAccounts :: Int -> Account -> Account
+ Hledger.Data.Account: clipAccounts :: Int -> Account a -> Account a
- Hledger.Data.Account: clipAccountsAndAggregate :: DepthSpec -> [Account] -> [Account]
+ Hledger.Data.Account: clipAccountsAndAggregate :: Monoid a => DepthSpec -> [Account a] -> [Account a]
- Hledger.Data.Account: filterAccounts :: (Account -> Bool) -> Account -> [Account]
+ Hledger.Data.Account: filterAccounts :: (Account a -> Bool) -> Account a -> [Account a]
- Hledger.Data.Account: flattenAccounts :: Account -> [Account]
+ Hledger.Data.Account: flattenAccounts :: Account a -> [Account a]
- Hledger.Data.Account: lookupAccount :: AccountName -> [Account] -> Maybe Account
+ Hledger.Data.Account: lookupAccount :: AccountName -> [Account a] -> Maybe (Account a)
- Hledger.Data.Account: mapAccounts :: (Account -> Account) -> Account -> Account
+ Hledger.Data.Account: mapAccounts :: (Account a -> Account a) -> Account a -> Account a
- Hledger.Data.Account: nullacct :: Account
+ Hledger.Data.Account: nullacct :: Account BalanceData
- Hledger.Data.Account: parentAccounts :: Account -> [Account]
+ Hledger.Data.Account: parentAccounts :: Account a -> [Account a]
- Hledger.Data.Account: printAccounts :: Account -> IO ()
+ Hledger.Data.Account: printAccounts :: Show a => Account a -> IO ()
- Hledger.Data.Account: pruneAccounts :: (Account -> Bool) -> Account -> Maybe Account
+ Hledger.Data.Account: pruneAccounts :: (Account a -> Bool) -> Account a -> Maybe (Account a)
- Hledger.Data.Account: showAccounts :: Account -> String
+ Hledger.Data.Account: showAccounts :: Show a => Account a -> String
- Hledger.Data.Account: showAccountsBoringFlag :: Account -> String
+ Hledger.Data.Account: showAccountsBoringFlag :: Account a -> String
- Hledger.Data.Account: sumAccounts :: Account -> Account
+ Hledger.Data.Account: sumAccounts :: Account BalanceData -> Account BalanceData
- Hledger.Data.Amount: averageMixedAmounts :: [MixedAmount] -> MixedAmount
+ Hledger.Data.Amount: averageMixedAmounts :: Foldable f => f MixedAmount -> MixedAmount
- Hledger.Data.Balancing: BalancingOpts :: Bool -> Bool -> Maybe (Map CommoditySymbol AmountStyle) -> BalancingOpts
+ Hledger.Data.Balancing: BalancingOpts :: Bool -> Bool -> Maybe (Map CommoditySymbol AmountStyle) -> TransactionBalancingPrecision -> BalancingOpts
- Hledger.Data.Ledger: ledgerAccount :: Ledger -> AccountName -> Maybe Account
+ Hledger.Data.Ledger: ledgerAccount :: Ledger -> AccountName -> Maybe (Account BalanceData)
- Hledger.Data.Ledger: ledgerLeafAccounts :: Ledger -> [Account]
+ Hledger.Data.Ledger: ledgerLeafAccounts :: Ledger -> [Account BalanceData]
- Hledger.Data.Ledger: ledgerRootAccount :: Ledger -> Account
+ Hledger.Data.Ledger: ledgerRootAccount :: Ledger -> Account BalanceData
- Hledger.Data.Ledger: ledgerTopAccounts :: Ledger -> [Account]
+ Hledger.Data.Ledger: ledgerTopAccounts :: Ledger -> [Account BalanceData]
- Hledger.Data.Types: Account :: AccountName -> Maybe AccountDeclarationInfo -> [Account] -> Maybe Account -> Bool -> Int -> MixedAmount -> MixedAmount -> Account
+ Hledger.Data.Types: Account :: AccountName -> Maybe AccountDeclarationInfo -> [Account a] -> Maybe (Account a) -> Bool -> PeriodData a -> Account a
- Hledger.Data.Types: Ledger :: Journal -> [Account] -> Ledger
+ Hledger.Data.Types: Ledger :: Journal -> [Account BalanceData] -> Ledger
- Hledger.Data.Types: [aboring] :: Account -> Bool
+ Hledger.Data.Types: [aboring] :: Account a -> Bool
- Hledger.Data.Types: [adeclarationinfo] :: Account -> Maybe AccountDeclarationInfo
+ Hledger.Data.Types: [adeclarationinfo] :: Account a -> Maybe AccountDeclarationInfo
- Hledger.Data.Types: [aname] :: Account -> AccountName
+ Hledger.Data.Types: [aname] :: Account a -> AccountName
- Hledger.Data.Types: [aparent] :: Account -> Maybe Account
+ Hledger.Data.Types: [aparent] :: Account a -> Maybe (Account a)
- Hledger.Data.Types: [asubs] :: Account -> [Account]
+ Hledger.Data.Types: [asubs] :: Account a -> [Account a]
- Hledger.Data.Types: [laccounts] :: Ledger -> [Account]
+ Hledger.Data.Types: [laccounts] :: Ledger -> [Account BalanceData]
- Hledger.Data.Types: data Account
+ Hledger.Data.Types: data Account a
- Hledger.Read.Common: Reader :: StorageFormat -> [String] -> (InputOpts -> FilePath -> Handle -> ExceptT String IO Journal) -> (MonadIO m => ErroringJournalParser m ParsedJournal) -> Reader (m :: Type -> Type)
+ Hledger.Read.Common: Reader :: StorageFormat -> [String] -> (InputOpts -> FilePath -> Handle -> ExceptT String IO Journal) -> (MonadIO m => InputOpts -> ErroringJournalParser m ParsedJournal) -> Reader (m :: Type -> Type)
- Hledger.Read.Common: [rParser] :: Reader (m :: Type -> Type) -> MonadIO m => ErroringJournalParser m ParsedJournal
+ Hledger.Read.Common: [rParser] :: Reader (m :: Type -> Type) -> MonadIO m => InputOpts -> ErroringJournalParser m ParsedJournal
- Hledger.Read.Common: modifiedaccountnamep :: forall (m :: Type -> Type). JournalParser m AccountName
+ Hledger.Read.Common: modifiedaccountnamep :: forall (m :: Type -> Type). Bool -> JournalParser m AccountName
- Hledger.Read.JournalReader: directivep :: forall (m :: Type -> Type). MonadIO m => ErroringJournalParser m ()
+ Hledger.Read.JournalReader: directivep :: forall (m :: Type -> Type). MonadIO m => InputOpts -> ErroringJournalParser m ()
- Hledger.Read.JournalReader: journalp :: forall (m :: Type -> Type). MonadIO m => ErroringJournalParser m ParsedJournal
+ Hledger.Read.JournalReader: journalp :: forall (m :: Type -> Type). MonadIO m => InputOpts -> ErroringJournalParser m ParsedJournal
- Hledger.Read.JournalReader: modifiedaccountnamep :: forall (m :: Type -> Type). JournalParser m AccountName
+ Hledger.Read.JournalReader: modifiedaccountnamep :: forall (m :: Type -> Type). Bool -> JournalParser m AccountName
- Hledger.Read.TimedotReader: timedotfilep :: forall {m :: Type -> Type}. JournalParser m ParsedJournal
+ Hledger.Read.TimedotReader: timedotfilep :: forall {m :: Type -> Type}. InputOpts -> JournalParser m ParsedJournal
- Hledger.Reports.AccountTransactionsReport: type AccountTransactionsReportItem = (Transaction, Transaction, Bool, Text, MixedAmount, MixedAmount)
+ Hledger.Reports.AccountTransactionsReport: type AccountTransactionsReportItem = (Transaction, Transaction, Bool, [AccountName], MixedAmount, MixedAmount)
- Hledger.Reports.MultiBalanceReport: getPostings :: ReportSpec -> Journal -> PriceOracle -> [Posting]
+ Hledger.Reports.MultiBalanceReport: getPostings :: ReportSpec -> Journal -> PriceOracle -> DateSpan -> [Posting]
- Hledger.Reports.MultiBalanceReport: multiBalanceReportWith :: ReportSpec -> Journal -> PriceOracle -> Set AccountName -> MultiBalanceReport
+ Hledger.Reports.MultiBalanceReport: multiBalanceReportWith :: ReportSpec -> Journal -> PriceOracle -> MultiBalanceReport
- Hledger.Reports.ReportOptions: mixedAmountApplyValuationAfterSumFromOptsWith :: ReportOpts -> Journal -> PriceOracle -> DateSpan -> MixedAmount -> MixedAmount
+ Hledger.Reports.ReportOptions: mixedAmountApplyValuationAfterSumFromOptsWith :: ReportOpts -> Journal -> PriceOracle -> Day -> MixedAmount -> MixedAmount

Files

CHANGES.md view
@@ -11,16 +11,44 @@  Improvements +--> +Internal/api/developer-ish changes in the hledger-lib (and hledger) packages.+For user-visible changes, see the hledger package changelog.  +# 1.50 2025-09-03 +Breaking changes +- hledger now requires at least GHC 9.6 (and base 4.18), to ease maintenance. --->-Internal/api/developer-ish changes in the hledger-lib (and hledger) packages.-For user-visible changes, see the hledger package changelog.+Fixes +- Fix liftA2 build error with ghc <9.6 (broken since 1.43.1).++Improvements++- Account now stores balances, one per date period. This enables it do+  the hard work in MultiBalanceReport.+  Some new types are created to enable convenient operation of accounts:+  - `BalanceData` is a type which stores an exclusive balance, inclusive+    balance, and number of postings. This was previously directly stored+    in Account, but is now factored into a separate data type.+  - `PeriodData` is a container which stores date-indexed data, as well as+    pre-period data. In post cases, this represents the report spans,+    along with the historical data.+  - Account becomes polymorphic, allowing customisation of the type of+    data it stores. This will usually be `BalanceData`, but in+    `BudgetReport` it can use `These BalanceData BalanceData` to store+    both actuals and budgets in the same structure. The data structure+    changes to contain a `PeriodData`, allowing multiperiod accounts.+  (Stephen Morgan)+- Hledger.Read: make LatestDatesForFile showable+- Hledger.Read.Common: accountnamep and modifiedaccountnamep now take a flag to allow semicolons or not+- Hledger.Utils.IO: getFlag, warnIO, rename exitOnError -> handleExit, improve doc+- Hledger.Query: matchesCommodity handles all query types, not just cur:, and doesn't match by default+- Hledger.Data.Amount: move commodityStylesFromAmounts here, drop canonicalStyleFrom  # 1.43.2 2025-06-13 
Hledger/Data.hs view
@@ -10,6 +10,8 @@  module Hledger.Data (                module Hledger.Data.Account,+               module Hledger.Data.BalanceData,+               module Hledger.Data.PeriodData,                module Hledger.Data.AccountName,                module Hledger.Data.Amount,                module Hledger.Data.Balancing,@@ -36,6 +38,8 @@  import Test.Tasty (testGroup) import Hledger.Data.Account+import Hledger.Data.BalanceData+import Hledger.Data.PeriodData import Hledger.Data.AccountName import Hledger.Data.Amount import Hledger.Data.Balancing@@ -58,7 +62,10 @@ import Hledger.Data.Valuation  tests_Data = testGroup "Data" [-   tests_AccountName+   tests_Account+  ,tests_AccountName+  ,tests_BalanceData+  ,tests_PeriodData   ,tests_Amount   ,tests_Balancing   -- ,tests_Currency
Hledger/Data/Account.hs view
@@ -1,6 +1,7 @@-{-# LANGUAGE CPP               #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RecordWildCards   #-}+{-# LANGUAGE CPP                 #-}+{-# LANGUAGE OverloadedStrings   #-}+{-# LANGUAGE RecordWildCards     #-}+{-# LANGUAGE ScopedTypeVariables #-} {-|  @@ -11,8 +12,11 @@  module Hledger.Data.Account ( nullacct+, accountFromBalances+, accountFromPostings , accountsFromPostings , accountTree+, accountTreeFromBalanceAndNames , showAccounts , showAccountsBoringFlag , printAccounts@@ -20,6 +24,7 @@ , parentAccounts , accountsLevels , mapAccounts+, mapPeriodData , anyAccounts , filterAccounts , sumAccounts@@ -27,38 +32,53 @@ , clipAccountsAndAggregate , pruneAccounts , flattenAccounts+, mergeAccounts , accountSetDeclarationInfo , sortAccountNamesByDeclaration-, sortAccountTreeByAmount+, sortAccountTreeByDeclaration+, sortAccountTreeOn+-- -- * Tests+, tests_Account ) where +import Control.Applicative ((<|>)) import qualified Data.HashSet as HS import qualified Data.HashMap.Strict as HM+import qualified Data.IntMap as IM import Data.List (find, sortOn) #if !MIN_VERSION_base(4,20,0) import Data.List (foldl') #endif-import Data.List.Extra (groupOn)+import Data.List.NonEmpty (NonEmpty(..), groupWith) import qualified Data.Map as M-import Data.Ord (Down(..))+import Data.Maybe (fromMaybe)+import qualified Data.Text as T+import Data.These (These(..))+import Data.Time (Day(..), fromGregorian) import Safe (headMay) import Text.Printf (printf) -import Hledger.Data.AccountName (expandAccountName, clipOrEllipsifyAccountName)+import Hledger.Data.BalanceData ()+import Hledger.Data.PeriodData+import Hledger.Data.AccountName import Hledger.Data.Amount import Hledger.Data.Types+import Hledger.Utils   -- deriving instance Show Account-instance Show Account where-    show Account{..} = printf "Account %s (boring:%s, postings:%d, ebalance:%s, ibalance:%s)"-                       aname-                       (if aboring then "y" else "n" :: String)-                       anumpostings-                       (wbUnpack $ showMixedAmountB defaultFmt aebalance)-                       (wbUnpack $ showMixedAmountB defaultFmt aibalance)+instance Show a => Show (Account a) where+  showsPrec d acct =+    showParen (d > 10) $+       showString "Account "+      . showString (T.unpack $ aname acct)+      . showString " (boring:"+      . showString (if aboring acct then "y" else "n")+      . showString ", adata:"+      . shows (adata acct)+      . showChar ')' -instance Eq Account where+instance Eq (Account a) where   (==) a b = aname a == aname b -- quick equality test for speed              -- and              -- [ aname a == aname b@@ -68,50 +88,73 @@              -- , aibalance a == aibalance b              -- ] -nullacct = Account-  { aname            = ""+nullacct :: Account BalanceData+nullacct = accountFromBalances "" mempty++-- | Construct an 'Account" from an account name and balances. Other fields are+-- left blank.+accountFromBalances :: AccountName -> PeriodData a -> Account a+accountFromBalances name bal = Account+  { aname            = name   , adeclarationinfo = Nothing   , asubs            = []   , aparent          = Nothing   , aboring          = False-  , anumpostings     = 0-  , aebalance        = nullmixedamt-  , aibalance        = nullmixedamt+  , adata            = bal   } --- | Derive 1. an account tree and 2. each account's total exclusive--- and inclusive changes from a list of postings.+-- | Derive 1. an account tree and 2. each account's total exclusive and+-- inclusive changes associated with dates from a list of postings and a+-- function for associating a date to each posting (usually representing the+-- start dates of report subperiods). -- This is the core of the balance command (and of *ledger). -- The accounts are returned as a list in flattened tree order, -- and also reference each other as a tree. -- (The first account is the root of the tree.)-accountsFromPostings :: [Posting] -> [Account]-accountsFromPostings ps =-  let-    summed = foldr (\p -> HM.insertWith addAndIncrement (paccount p) (1, pamount p)) mempty ps-      where addAndIncrement (n, a) (m, b) = (n + m, a `maPlus` b)-    acctstree      = accountTree "root" $ HM.keys summed-    acctswithebals = mapAccounts setnumpsebalance acctstree-      where setnumpsebalance a = a{anumpostings=numps, aebalance=total}-              where (numps, total) = HM.lookupDefault (0, nullmixedamt) (aname a) summed-    acctswithibals = sumAccounts acctswithebals-    acctswithparents = tieAccountParents acctswithibals-    acctsflattened = flattenAccounts acctswithparents-  in-    acctsflattened+accountsFromPostings :: (Posting -> Maybe Day) -> [Posting] -> [Account BalanceData]+accountsFromPostings getPostingDate = flattenAccounts . accountFromPostings getPostingDate +-- | Derive 1. an account tree and 2. each account's total exclusive+-- and inclusive changes associated with dates from a list of postings and a+-- function for associating a date to each posting (usually representing the+-- start dates of report subperiods).+-- This is the core of the balance command (and of *ledger).+-- The accounts are returned as a tree.+accountFromPostings :: (Posting -> Maybe Day) -> [Posting] -> Account BalanceData+accountFromPostings getPostingDate ps =+    tieAccountParents . sumAccounts $ mapAccounts setBalance acctTree+  where+    -- The special name "..." is stored in the root of the tree+    acctTree     = accountTree "root" . HM.keys $ HM.delete "..." accountMap+    setBalance a = a{adata = HM.lookupDefault mempty name accountMap}+      where name = if aname a == "root" then "..." else aname a+    accountMap   = processPostings ps++    processPostings :: [Posting] -> HM.HashMap AccountName (PeriodData BalanceData)+    processPostings = foldl' (flip processAccountName) mempty+      where+        processAccountName p = HM.alter (updateBalanceData p) (paccount p)+        updateBalanceData p = Just+                            . insertPeriodData (getPostingDate p) (BalanceData (pamount p) nullmixedamt 1)+                            . fromMaybe mempty+ -- | Convert a list of account names to a tree of Account objects,--- with just the account names filled in.+-- with just the account names filled in and an empty balance. -- A single root account with the given name is added.-accountTree :: AccountName -> [AccountName] -> Account-accountTree rootname as = nullacct{aname=rootname, asubs=map (uncurry accountTree') $ M.assocs m }+accountTree :: Monoid a => AccountName -> [AccountName] -> Account a+accountTree rootname = accountTreeFromBalanceAndNames rootname mempty++-- | Convert a list of account names to a tree of Account objects,+-- with just the account names filled in. Each account is given the same+-- supplied balance.+-- A single root account with the given name is added.+accountTreeFromBalanceAndNames :: AccountName -> PeriodData a -> [AccountName] -> Account a+accountTreeFromBalanceAndNames rootname bals as =+    (accountFromBalances rootname bals){ asubs=map (uncurry accountTree') $ M.assocs m }   where     T m = treeFromPaths $ map expandAccountName as :: FastTree AccountName     accountTree' a (T m') =-      nullacct{-        aname=a-       ,asubs=map (uncurry accountTree') $ M.assocs m'-       }+      (accountFromBalances a bals){ asubs=map (uncurry accountTree') $ M.assocs m' }  -- | An efficient-to-build tree suggested by Cale Gibbard, probably -- better than accountNameTreeFrom.@@ -130,7 +173,7 @@   -- | Tie the knot so all subaccounts' parents are set correctly.-tieAccountParents :: Account -> Account+tieAccountParents :: Account a -> Account a tieAccountParents = tie Nothing   where     tie parent a@Account{..} = a'@@ -138,35 +181,50 @@         a' = a{aparent=parent, asubs=map (tie (Just a')) asubs}  -- | Get this account's parent accounts, from the nearest up to the root.-parentAccounts :: Account -> [Account]+parentAccounts :: Account a -> [Account a] parentAccounts Account{aparent=Nothing} = [] parentAccounts Account{aparent=Just a} = a:parentAccounts a  -- | List the accounts at each level of the account tree.-accountsLevels :: Account -> [[Account]]+accountsLevels :: Account a -> [[Account a]] accountsLevels = takeWhile (not . null) . iterate (concatMap asubs) . (:[])  -- | Map a (non-tree-structure-modifying) function over this and sub accounts.-mapAccounts :: (Account -> Account) -> Account -> Account+mapAccounts :: (Account a -> Account a) -> Account a -> Account a mapAccounts f a = f a{asubs = map (mapAccounts f) $ asubs a} +-- | Apply a function to all 'PeriodData' within this and sub accounts.+mapPeriodData :: (PeriodData a -> PeriodData a) -> Account a -> Account a+mapPeriodData f = mapAccounts (\a -> a{adata = f $ adata a})+ -- | Is the predicate true on any of this account or its subaccounts ?-anyAccounts :: (Account -> Bool) -> Account -> Bool+anyAccounts :: (Account a -> Bool) -> Account a -> Bool anyAccounts p a     | p a = True     | otherwise = any (anyAccounts p) $ asubs a --- | Add subaccount-inclusive balances to an account tree.-sumAccounts :: Account -> Account-sumAccounts a-  | null $ asubs a = a{aibalance=aebalance a}-  | otherwise      = a{aibalance=ibal, asubs=subs}+-- | Is the predicate true on all of this account and its subaccounts ?+allAccounts :: (Account a -> Bool) -> Account a -> Bool+allAccounts p a+    | not (p a) = False+    | otherwise = all (allAccounts p) $ asubs a++-- | Recalculate all the subaccount-inclusive balances in this tree.+sumAccounts :: Account BalanceData -> Account BalanceData+sumAccounts a = a{asubs = subs, adata = setInclusiveBalances $ adata a}   where     subs = map sumAccounts $ asubs a-    ibal = maSum $ aebalance a : map aibalance subs+    subtotals = foldMap adata subs +    setInclusiveBalances :: PeriodData BalanceData -> PeriodData BalanceData+    setInclusiveBalances = mergePeriodData onlyChildren noChildren combineChildren subtotals++    combineChildren children this = this  {bdincludingsubs = bdexcludingsubs this <> bdincludingsubs children}+    onlyChildren    children      = mempty{bdincludingsubs = bdincludingsubs children}+    noChildren               this = this  {bdincludingsubs = bdexcludingsubs this}+ -- | Remove all subaccounts below a certain depth.-clipAccounts :: Int -> Account -> Account+clipAccounts :: Int -> Account a -> Account a clipAccounts 0 a = a{asubs=[]} clipAccounts d a = a{asubs=subs}     where@@ -175,13 +233,14 @@ -- | Remove subaccounts below the specified depth, aggregating their balance at the depth limit -- (accounts at the depth limit will have any sub-balances merged into their exclusive balance). -- If the depth is Nothing, return the original accounts-clipAccountsAndAggregate :: DepthSpec -> [Account] -> [Account]+clipAccountsAndAggregate :: Monoid a => DepthSpec -> [Account a] -> [Account a] clipAccountsAndAggregate (DepthSpec Nothing []) as = as-clipAccountsAndAggregate d                      as = combined+clipAccountsAndAggregate depthSpec              as = combined     where-      clipped  = [a{aname=clipOrEllipsifyAccountName d $ aname a} | a <- as]-      combined = [a{aebalance=maSum $ map aebalance same}-                 | same@(a:_) <- groupOn aname clipped]+      clipped  = [a{aname=clipOrEllipsifyAccountName depthSpec $ aname a} | a <- as]+      combined = [a{adata=foldMap adata same}+                 | same@(a:|_) <- groupWith aname clipped]+ {- test cases, assuming d=1: @@ -211,7 +270,7 @@ -}  -- | Remove all leaf accounts and subtrees matching a predicate.-pruneAccounts :: (Account -> Bool) -> Account -> Maybe Account+pruneAccounts :: (Account a -> Bool) -> Account a -> Maybe (Account a) pruneAccounts p = headMay . prune   where     prune a@@ -224,33 +283,47 @@ -- | Flatten an account tree into a list, which is sometimes -- convenient. Note since accounts link to their parents/subs, the -- tree's structure remains intact and can still be used. It's a tree/list!-flattenAccounts :: Account -> [Account]+flattenAccounts :: Account a -> [Account a] flattenAccounts a = squish a []   where squish a' as = a' : Prelude.foldr squish as (asubs a')  -- | Filter an account tree (to a list).-filterAccounts :: (Account -> Bool) -> Account -> [Account]+filterAccounts :: (Account a -> Bool) -> Account a -> [Account a] filterAccounts p a     | p a       = a : concatMap (filterAccounts p) (asubs a)     | otherwise = concatMap (filterAccounts p) (asubs a) --- | Sort each group of siblings in an account tree by inclusive amount,--- so that the accounts with largest normal balances are listed first.--- The provided normal balance sign determines whether normal balances--- are negative or positive, affecting the sort order. Ie,--- if balances are normally negative, then the most negative balances--- sort first, and vice versa.-sortAccountTreeByAmount :: NormalSign -> Account -> Account-sortAccountTreeByAmount normalsign = mapAccounts $ \a -> a{asubs=sortSubs $ asubs a}+-- | Merge two account trees and their subaccounts.+--+-- This assumes that the top-level 'Account's have the same name.+mergeAccounts :: Account a -> Account b -> Account (These a b)+mergeAccounts a = tieAccountParents . merge a   where-    sortSubs = case normalsign of-        NormallyPositive -> sortOn (\a -> (Down $ amt a, aname a))-        NormallyNegative -> sortOn (\a -> (amt a, aname a))-    amt = mixedAmountStripCosts . aibalance+    merge acct1 acct2 = acct1+       { adeclarationinfo = adeclarationinfo acct1 <|> adeclarationinfo acct2+       , aparent = Nothing+       , aboring = aboring acct1 && aboring acct2+       , adata = mergeBalances (adata acct1) (adata acct2)+       , asubs = mergeSubs (sortOn aname $ asubs acct1) (sortOn aname $ asubs acct2)+       } +    mergeSubs (x:xs) (y:ys) = case compare (aname x) (aname y) of+      EQ -> merge x y : mergeSubs xs ys+      LT -> fmap This x : mergeSubs xs (y:ys)+      GT -> fmap That y : mergeSubs (x:xs) ys+    mergeSubs xs [] = map (fmap This) xs+    mergeSubs [] ys = map (fmap That) ys++    mergeBalances = mergePeriodData This That These++-- | Sort each group of siblings in an account tree by projecting through+-- a provided function.+sortAccountTreeOn :: Ord b => (Account a -> b) -> Account a -> Account a+sortAccountTreeOn f = mapAccounts $ \a -> a{asubs=sortOn f $ asubs a}+ -- | Add extra info for this account derived from the Journal's -- account directives, if any (comment, tags, declaration order..).-accountSetDeclarationInfo :: Journal -> Account -> Account+accountSetDeclarationInfo :: Journal -> Account a -> Account a accountSetDeclarationInfo j a@Account{..} =   a{ adeclarationinfo=lookup aname $ jdeclaredaccounts j } @@ -271,14 +344,13 @@     flattenAccounts $                                   -- convert to an account list     sortAccountTreeByDeclaration $                      -- sort by declaration order (and name)     mapAccounts (accountSetDeclarationInfo j) $         -- add declaration order info-    accountTree "root"                                  -- convert to an account tree-    as+    (accountTree "root" as :: Account ())               -- convert to an account tree  -- | Sort each group of siblings in an account tree by declaration order, then account name. -- So each group will contain first the declared accounts, -- in the same order as their account directives were parsed, -- and then the undeclared accounts, sorted by account name.-sortAccountTreeByDeclaration :: Account -> Account+sortAccountTreeByDeclaration :: Account a -> Account a sortAccountTreeByDeclaration a   | null $ asubs a = a   | otherwise      = a{asubs=@@ -286,26 +358,37 @@       map sortAccountTreeByDeclaration $ asubs a       } -accountDeclarationOrderAndName :: Account -> (Int, AccountName)+accountDeclarationOrderAndName :: Account a -> (Int, AccountName) accountDeclarationOrderAndName a = (adeclarationorder', aname a)   where     adeclarationorder' = maybe maxBound adideclarationorder $ adeclarationinfo a  -- | Search an account list by name.-lookupAccount :: AccountName -> [Account] -> Maybe Account+lookupAccount :: AccountName -> [Account a] -> Maybe (Account a) lookupAccount a = find ((==a).aname)  -- debug helpers -printAccounts :: Account -> IO ()+printAccounts :: Show a => Account a -> IO () printAccounts = putStrLn . showAccounts +showAccounts :: Show a => Account a -> String showAccounts = unlines . map showAccountDebug . flattenAccounts  showAccountsBoringFlag = unlines . map (show . aboring) . flattenAccounts -showAccountDebug a = printf "%-25s %4s %4s %s"+showAccountDebug a = printf "%-25s %s %4s"                      (aname a)-                     (wbUnpack . showMixedAmountB defaultFmt $ aebalance a)-                     (wbUnpack . showMixedAmountB defaultFmt $ aibalance a)                      (if aboring a then "b" else " " :: String)+                     (show $ adata a)+++tests_Account = testGroup "Account" [+    testGroup "accountFromPostings" [+      testCase "no postings, no days" $+        accountFromPostings undefined [] @?= accountTree "root" []+     ,testCase "no postings, only 2000-01-01" $+         allAccounts (all (\d -> (ModifiedJulianDay $ toInteger d) == fromGregorian 2000 01 01) . IM.keys . pdperiods . adata)+                     (accountFromPostings undefined []) @? "Not all adata have exactly 2000-01-01"+    ]+  ]
Hledger/Data/Amount.hs view
@@ -77,6 +77,10 @@   amountStyleSetRounding,   amountStylesSetRounding,   amountUnstyled,+  commodityStylesFromAmounts,+  -- canonicalStyleFrom,+  getAmounts,+   -- ** rendering   AmountFormat(..),   defaultFmt,@@ -135,6 +139,7 @@   divideMixedAmount,   multiplyMixedAmount,   averageMixedAmounts,+  sumAndAverageMixedAmounts,   isNegativeAmount,   isNegativeMixedAmount,   mixedAmountIsZero,@@ -171,7 +176,7 @@ ) where  import Prelude hiding (Applicative(..))-import Control.Applicative (Applicative(..))+import Control.Applicative (Applicative(..), (<|>)) import Control.Monad (foldM) import Data.Char (isDigit) import Data.Decimal (DecimalRaw(..), decimalPlaces, normalizeDecimal, roundTo)@@ -562,6 +567,13 @@                 olds)           Nothing -> olds +-- | Get an amount and its attached cost amount if any. Returns one or two amounts.+getAmounts :: Amount -> [Amount]+getAmounts a@Amount{acost} = a : case acost of+  Nothing            -> []+  Just (UnitCost  c) -> [c]+  Just (TotalCost c) -> [c]+ -- AmountStyle helpers  -- | Replace one AmountStyle with another, but don't just replace the display precision;@@ -631,6 +643,52 @@ amountUnstyled :: Amount -> Amount amountUnstyled a = a{astyle=amountstyle} +-- | Given a list of amounts, in parse order (roughly speaking; see journalStyleInfluencingAmounts),+-- build a map from their commodity names to standard commodity+-- display formats. Can return an error message eg if inconsistent+-- number formats are found.+--+-- Though, these amounts may have come from multiple files, so we+-- shouldn't assume they use consistent number formats.+-- Currently we don't enforce that even within a single file,+-- and this function never reports an error.+commodityStylesFromAmounts :: [Amount] -> Either String (M.Map CommoditySymbol AmountStyle)+commodityStylesFromAmounts =+  Right . foldr (\a -> M.insertWith canonicalStyle (acommodity a) (astyle a)) mempty++-- -- | Given a list of amount styles (assumed to be from parsed amounts+-- -- in a single commodity), in parse order, choose a canonical style.+-- canonicalStyleFrom :: [AmountStyle] -> AmountStyle+-- canonicalStyleFrom = foldl' canonicalStyle amountstyle++-- TODO: should probably detect and report inconsistencies here.+-- Though, we don't have the info for a good error message, so maybe elsewhere.++-- | Given a pair of AmountStyles, choose a canonical style.+-- This is:+-- the general style of the first amount,+-- with the first digit group style seen,+-- with the maximum precision of all.+canonicalStyle :: AmountStyle -> AmountStyle -> AmountStyle+canonicalStyle a b = a{asprecision = prec, asdecimalmark = decmark, asdigitgroups = mgrps}+ where+  -- precision is maximum of all precisions+  prec = max (asprecision a) (asprecision b)+  -- identify the digit group mark (& group sizes)+  mgrps = asdigitgroups a <|> asdigitgroups b+  -- if a digit group mark was identified above, we can rely on that;+  -- make sure the decimal mark is different. If not, default to period.+  defdecmark = case mgrps of+    Just (DigitGroups '.' _) -> ','+    _ -> '.'+  -- identify the decimal mark: the first one used, or the above default,+  -- but never the same character as the digit group mark.+  -- urgh.. refactor..+  decmark = case mgrps of+    Just _ -> Just defdecmark+    Nothing -> asdecimalmark a <|> asdecimalmark b <|> Just defdecmark++ -- | Set (or clear) an amount's display decimal point. setAmountDecimalPoint :: Maybe Char -> Amount -> Amount setAmountDecimalPoint mc a@Amount{ astyle=s } = a{ astyle=s{asdecimalmark=mc} }@@ -851,9 +909,15 @@ transformMixedAmount f = mapMixedAmountUnsafe (transformAmount f)  -- | Calculate the average of some mixed amounts.-averageMixedAmounts :: [MixedAmount] -> MixedAmount-averageMixedAmounts as = fromIntegral (length as) `divideMixedAmount` maSum as+averageMixedAmounts :: Foldable f => f MixedAmount -> MixedAmount+averageMixedAmounts = snd . sumAndAverageMixedAmounts +-- | Calculate the sum and average of some mixed amounts.+sumAndAverageMixedAmounts :: Foldable f => f MixedAmount -> (MixedAmount, MixedAmount)+sumAndAverageMixedAmounts amts = (total, fromIntegral nAmts `divideMixedAmount` total)+  where+    (nAmts, total) = foldl' (\(n, a) b -> (n + 1, maPlus a b)) (0 :: Int, nullmixedamt) amts+ -- | Is this mixed amount negative, if we can tell that unambiguously? -- Ie when normalised, are all individual commodity amounts negative ? isNegativeMixedAmount :: MixedAmount -> Maybe Bool@@ -1038,10 +1102,21 @@ -- v4 instance HasAmounts MixedAmount where   styleAmounts styles = mapMixedAmountUnsafe (styleAmounts styles)+  -- getAmounts = concatMap getAmounts . amounts -instance HasAmounts Account where-  styleAmounts styles acct@Account{aebalance,aibalance} =-    acct{aebalance=styleAmounts styles aebalance, aibalance=styleAmounts styles aibalance}+instance HasAmounts BalanceData where+  styleAmounts styles balance@BalanceData{bdexcludingsubs,bdincludingsubs} =+    balance{bdexcludingsubs=styleAmounts styles bdexcludingsubs, bdincludingsubs=styleAmounts styles bdincludingsubs}+  -- getAmounts BalanceData{bdexcludingsubs, bdincludingsubs} =+  --   getAmounts bdexcludingsubs <> getAmounts bdincludingsubs++instance HasAmounts a => HasAmounts (PeriodData a) where+  styleAmounts styles = fmap (styleAmounts styles)+  -- getAmounts ++instance HasAmounts a => HasAmounts (Account a) where+  styleAmounts styles acct@Account{adata} =+    acct{adata = styleAmounts styles <$> adata}  -- | Reset each individual amount's display style to the default. mixedAmountUnstyled :: MixedAmount -> MixedAmount
+ Hledger/Data/BalanceData.hs view
@@ -0,0 +1,59 @@+{-# LANGUAGE CPP #-}+{-|+++A 'BalanceData is a data type tracking a number of postings, exclusive, and inclusive balance+for given date ranges.++-}+module Hledger.Data.BalanceData+( mapBalanceData+, opBalanceData++, tests_BalanceData+) where+++import Test.Tasty (testGroup)+import Test.Tasty.HUnit ((@?=), testCase)++import Hledger.Data.Amount+import Hledger.Data.Types+++instance Show BalanceData where+  showsPrec d (BalanceData e i n) =+    showParen (d > 10) $+        showString "BalanceData"+      . showString "{ bdexcludingsubs = " . showString (wbUnpack (showMixedAmountB defaultFmt e))+      . showString ", bdincludingsubs = " . showString (wbUnpack (showMixedAmountB defaultFmt i))+      . showString ", bdnumpostings = " . shows n+      . showChar '}'++instance Semigroup BalanceData where+  BalanceData e i n <> BalanceData e' i' n' = BalanceData (maPlus e e') (maPlus i i') (n + n')++instance Monoid BalanceData where+  mempty = BalanceData nullmixedamt nullmixedamt 0++-- | Apply an operation to both 'MixedAmount' in an 'BalanceData'.+mapBalanceData :: (MixedAmount -> MixedAmount) -> BalanceData -> BalanceData+mapBalanceData f a = a{bdexcludingsubs = f $ bdexcludingsubs a, bdincludingsubs = f $ bdincludingsubs a}++-- | Merge two 'BalanceData', using the given operation to combine their amounts.+opBalanceData :: (MixedAmount -> MixedAmount -> MixedAmount) -> BalanceData -> BalanceData -> BalanceData+opBalanceData f a b = a{bdexcludingsubs = f (bdexcludingsubs a) (bdexcludingsubs b), bdincludingsubs = f (bdincludingsubs a) (bdincludingsubs b)}+++-- tests++tests_BalanceData = testGroup "BalanceData" [++  testCase "opBalanceData maPlus" $ do+    opBalanceData maPlus (BalanceData (mixed [usd 1]) (mixed [usd 2]) 5) (BalanceData (mixed [usd 3]) (mixed [usd 4]) 0)+      @?= BalanceData (mixed [usd 4]) (mixed [usd 6]) 5,++  testCase "opBalanceData maMinus" $ do+    opBalanceData maMinus (BalanceData (mixed [usd 1]) (mixed [usd 2]) 5) (BalanceData (mixed [usd 3]) (mixed [usd 4]) 0)+      @?= BalanceData (mixed [usd (-2)]) (mixed [usd (-2)]) 5+  ]
Hledger/Data/Balancing.hs view
@@ -34,6 +34,7 @@ import Control.Monad.ST (ST, runST) import Control.Monad.Trans.Class (lift) import Data.Array.ST (STArray, getElems, newListArray, writeArray)+import Data.Bifunctor (second) import Data.Foldable (asum) import Data.Function ((&)) import Data.Functor ((<&>), void)@@ -49,7 +50,6 @@ import Safe (headErr) import Text.Printf (printf) -import Hledger.Utils import Hledger.Data.Types import Hledger.Data.AccountName (isAccountNamePrefixOf) import Hledger.Data.Amount@@ -57,7 +57,7 @@ import Hledger.Data.Posting import Hledger.Data.Transaction import Hledger.Data.Errors-import Data.Bifunctor (second)+import Hledger.Utils   data BalancingOpts = BalancingOpts@@ -65,6 +65,7 @@   , infer_balancing_costs_ :: Bool  -- ^ Are we permitted to infer missing costs to balance transactions ?                                     --   Distinct from InputOpts{infer_costs_}.   , commodity_styles_      :: Maybe (M.Map CommoditySymbol AmountStyle)  -- ^ commodity display styles+  , txn_balancing_         :: TransactionBalancingPrecision   } deriving (Eq, Ord, Show)  defbalancingopts :: BalancingOpts@@ -72,6 +73,7 @@   { ignore_assertions_     = False   , infer_balancing_costs_ = True   , commodity_styles_      = Nothing+  , txn_balancing_         = TBPExact   }  -- | Check that this transaction would appear balanced to a human when displayed.@@ -92,7 +94,7 @@ --    (using the given display styles if provided) -- transactionCheckBalanced :: BalancingOpts -> Transaction -> [String]-transactionCheckBalanced BalancingOpts{commodity_styles_} t = errs+transactionCheckBalanced BalancingOpts{commodity_styles_=_mglobalstyles, txn_balancing_} t = errs   where     -- get real and balanced virtual postings, to be checked separately     (rps, bvps) = foldr partitionPosting ([], []) $ tpostings t@@ -102,30 +104,33 @@             BalancedVirtualPosting -> (l, p:r)             VirtualPosting         -> (l, r) -    -- convert this posting's amount to cost,+    -- convert a posting's amount to cost,     -- unless it has been marked as a redundant cost (equivalent to some nearby equity conversion postings),     -- in which case ignore it.     postingBalancingAmount p       | costPostingTagName `elem` map fst (ptags p) = mixedAmountStripCosts $ pamount p       | otherwise                                   = mixedAmountCost $ pamount p -    -- transaction balancedness is checked at each commodity's display precision-    lookszero = mixedAmountLooksZero . atdisplayprecision-      where-        atdisplayprecision = maybe id styleAmounts $ commodity_styles_+    lookszero = case txn_balancing_ of+      TBPOld    -> lookszeroatglobaldisplayprecision+      TBPExact  -> lookszeroatlocaltransactionprecision +    lookszeroatlocaltransactionprecision = mixedAmountLooksZero . styleAmounts (transactionCommodityStylesWith HardRounding t)+    lookszeroatglobaldisplayprecision    = mixedAmountLooksZero . maybe id styleAmounts _mglobalstyles++    -- check that the sum looks like zero+    (rsumcost,  bvsumcost)  = (foldMap postingBalancingAmount rps, foldMap postingBalancingAmount bvps)+    (rsumok,    bvsumok)    = (lookszero rsumcost, lookszero bvsumcost)+    (rsumokold, bvsumokold) = (lookszeroatglobaldisplayprecision rsumcost, lookszeroatglobaldisplayprecision bvsumcost)+     -- when there's multiple non-zeros, check they do not all have the same sign-    (rsignsok, bvsignsok) = (signsOk rps, signsOk bvps)+    (rsignsok, bvsignsok)   = (signsOk rps, signsOk bvps)       where         signsOk ps = length nonzeros < 2 || length nonzerosigns > 1           where             nonzeros = filter (not.lookszero) $ map postingBalancingAmount ps             nonzerosigns = nubSort $ mapMaybe isNegativeMixedAmount nonzeros -    -- check that the sum looks like zero-    (rsumcost, bvsumcost) = (foldMap postingBalancingAmount rps, foldMap postingBalancingAmount bvps)-    (rsumok, bvsumok) = (lookszero rsumcost, lookszero bvsumcost)-     -- Generate error messages if any. Show amounts with their original precisions.     errs = filter (not.null) [rmsg, bvmsg]       where@@ -136,6 +141,7 @@               (showMixedAmountWith oneLineNoCostFmt{displayCost=True, displayZeroCommodity=True} $               mixedAmountSetFullPrecisionUpTo Nothing $ mixedAmountSetFullPrecision               rsumcost)+              ++ if rsumokold then oldbalancingmsg else ""         bvmsg           | bvsumok       = ""           | not bvsignsok = "The balanced virtual postings all have the same sign. Consider negating some of them."@@ -143,6 +149,14 @@               (showMixedAmountWith oneLineNoCostFmt{displayCost=True, displayZeroCommodity=True} $               mixedAmountSetFullPrecisionUpTo Nothing $ mixedAmountSetFullPrecision               bvsumcost)+              ++ if bvsumokold then oldbalancingmsg else ""+        oldbalancingmsg = unlines [+          -- -------------------------------------------------------------------------------+           "\nNote, hledger <1.50 accepted this entry because of the global display precision,"+          ,"but hledger 1.50+ checks more strictly, using the entry's local precision."+          ,"You can use --txn-balancing=old to keep it working, or fix it (recommended);"+          ,"see 'Transaction balancing' in the hledger manual."+          ]  -- | Legacy form of transactionCheckBalanced. isTransactionBalanced :: BalancingOpts -> Transaction -> Bool@@ -207,9 +221,7 @@           [ "Consider adjusting this entry's amounts, adding missing postings,"           , "or recording conversion price(s) with @, @@ or equity postings."            ]-          else-          [ "Consider adjusting this entry's amounts, or adding missing postings."-          ]+          else []  transactionCommodities :: Transaction -> S.Set CommoditySymbol transactionCommodities t = mconcat $ map (maCommodities . pamount) $ tpostings t@@ -289,7 +301,7 @@                 & mixedAmountCost                 -- & dbg9With (lbl "balancing amount converted to cost".showMixedAmountOneLine)                 & styleAmounts (styles-                                -- Needed until we switch to locally-inferred balancing precisions:+                                -- Needed until we switch to locally-inferred balancing precisions: XXX #2402                                 -- these had hard rounding set to help with balanced-checking;                                 -- set no rounding now to avoid excessive display precision in output                                 & amountStylesSetRounding NoRounding
Hledger/Data/Dates.hs view
@@ -63,6 +63,7 @@   spanIntersect,   spansIntersect,   spanDefaultsFrom,+  spanValidDefaultsFrom,   spanExtend,   spanUnion,   spansUnion,@@ -88,7 +89,7 @@ import Control.Applicative.Permutations import Control.Monad (guard, unless) import qualified Control.Monad.Fail as Fail (MonadFail, fail)-import Data.Char (digitToInt, isDigit, ord)+import Data.Char (digitToInt, isDigit) import Data.Default (def) import Data.Foldable (asum) import Data.Function (on)@@ -348,10 +349,43 @@  -- | Fill any unspecified dates in the first span with the dates from -- the second one (if specified there). Sort of a one-way spanIntersect.+-- This one can create an invalid span that'll always be empty.+--+-- >>> :{+--  DateSpan (Just $ Exact $ fromGregorian 2024 1 1) Nothing+--  `spanDefaultsFrom`+--  DateSpan (Just $ Exact $ fromGregorian 2024 1 1) (Just $ Exact $ fromGregorian 2024 1 2)+-- :}+-- DateSpan 2024-01-01+--+-- >>> :{+--  DateSpan (Just $ Exact $ fromGregorian 2025 1 1) Nothing+--  `spanDefaultsFrom`+--  DateSpan (Just $ Exact $ fromGregorian 2024 1 1) (Just $ Exact $ fromGregorian 2024 1 2)+-- :}+-- DateSpan 2025-01-01..2024-01-01+--+spanDefaultsFrom :: DateSpan -> DateSpan -> DateSpan spanDefaultsFrom (DateSpan a1 b1) (DateSpan a2 b2) = DateSpan a b     where a = if isJust a1 then a1 else a2           b = if isJust b1 then b1 else b2 +-- | A smarter version of spanDefaultsFrom that avoids creating invalid+-- spans ending before they begin. Kept separate for now to reduce risk.+--+-- >>> :{+--  DateSpan (Just $ Exact $ fromGregorian 2025 1 1) Nothing+--  `spanValidDefaultsFrom`+--  DateSpan (Just $ Exact $ fromGregorian 2024 1 1) (Just $ Exact $ fromGregorian 2024 1 2)+-- :}+-- DateSpan 2025-01-01..+--+spanValidDefaultsFrom :: DateSpan -> DateSpan -> DateSpan+spanValidDefaultsFrom s1 s2 =+  case s1 `spanDefaultsFrom` s2 of+    DateSpan b e | b >= e -> s1+    s -> s+ -- | Calculate the union of two datespans. -- If either span is open-ended, the union will be too. --@@ -365,9 +399,11 @@ -- DateSpan ..2024-12-31 spanUnion (DateSpan b1 e1) (DateSpan b2 e2) = DateSpan (earlier b1 b2) (later e1 e2) --- | Extend the first span to include any definite end dates of the second.--- Unlike spanUnion, open ends in the second are ignored.--- If the first span was open-ended, it still will be after being extended.+-- | Extend the definite start/end dates of the first span, if needed,+-- to include the definite start/end dates of the second span.+-- And/or, replace open start/end dates in the first span with+-- definite start/end dates from the second.+-- Unlike spanUnion, open start/end dates in the second are ignored. -- -- >>> ys2024 = fromGregorian 2024 01 01 -- >>> ys2025 = fromGregorian 2025 01 01@@ -876,7 +912,8 @@ smartdate = choice'   -- XXX maybe obscures date errors ? see ledgerdate     [ relativeP-    , yyyymmdd, ymd+    , yyyymmdd+    , ymd     , (\(m,d) -> SmartFromReference (Just m) d) <$> md     , failIfInvalidDate . SmartFromReference Nothing =<< decimal     , SmartMonth <$> (month <|> mon)@@ -1107,12 +1144,11 @@         ]  periodexprdatespanp :: Day -> TextParser m DateSpan-periodexprdatespanp rdate = choice $ map try [+periodexprdatespanp rdate = choice' [                             doubledatespanp rdate,-                            quarterdatespanp rdate,                             fromdatespanp rdate,                             todatespanp rdate,-                            justdatespanp rdate+                            indatespanp rdate                            ]  -- |@@ -1128,45 +1164,95 @@ -- Right DateSpan 2017 doubledatespanp :: Day -> TextParser m DateSpan doubledatespanp rdate = liftA2 fromToSpan-    (optional ((string' "from" <|> string' "since") *> skipNonNewlineSpaces) *> smartdate)+    (optional ((string' "from" <|> string' "since") *> skipNonNewlineSpaces) *> smartdateorquarterstartp rdate)     (skipNonNewlineSpaces *> choice [string' "to", string "..", string "-"]-    *> skipNonNewlineSpaces *> smartdate)+    *> skipNonNewlineSpaces *> smartdateorquarterstartp rdate)   where     fromToSpan = DateSpan `on` (Just . fixSmartDate rdate)  -- |--- >>> parsewith (quarterdatespanp (fromGregorian 2018 01 01) <* eof) "q1"--- Right DateSpan 2018Q1--- >>> parsewith (quarterdatespanp (fromGregorian 2018 01 01) <* eof) "Q1"--- Right DateSpan 2018Q1--- >>> parsewith (quarterdatespanp (fromGregorian 2018 01 01) <* eof) "2020q4"--- Right DateSpan 2020Q4-quarterdatespanp :: Day -> TextParser m DateSpan-quarterdatespanp rdate = do-    y <- yearp <|> pure (first3 $ toGregorian rdate)-    q <- char' 'q' *> satisfy is4Digit-    return . periodAsDateSpan $ QuarterPeriod y (digitToInt q)-  where-    is4Digit c = (fromIntegral (ord c - ord '1') :: Word) <= 3-+-- >>> let p = parsewith (fromdatespanp (fromGregorian 2024 02 02) <* eof)+-- >>> p "2025-01-01.."+-- Right DateSpan 2025-01-01..+-- >>> p "2025Q1.."+-- Right DateSpan 2025-01-01..+-- >>> p "from q2"+-- Right DateSpan 2024-04-01.. fromdatespanp :: Day -> TextParser m DateSpan fromdatespanp rdate = fromSpan <$> choice-    [ (string' "from" <|> string' "since") *> skipNonNewlineSpaces *> smartdate-    , smartdate <* choice [string "..", string "-"]-    ]+  [ (string' "from" <|> string' "since") *> skipNonNewlineSpaces *> smartdateorquarterstartp rdate+  , smartdateorquarterstartp rdate <* choice [string "..", string "-"]+  ]   where     fromSpan b = DateSpan (Just $ fixSmartDate rdate b) Nothing +-- |+-- >>> let p = parsewith (todatespanp (fromGregorian 2024 02 02) <* eof)+-- >>> p "..2025-01-01"+-- Right DateSpan ..2024-12-31+-- >>> p "..2025Q1"+-- Right DateSpan ..2024-12-31+-- >>> p "to q2"+-- Right DateSpan ..2024-03-31 todatespanp :: Day -> TextParser m DateSpan todatespanp rdate =-    choice [string' "to", string' "until", string "..", string "-"]-    *> skipNonNewlineSpaces-    *> (DateSpan Nothing . Just . fixSmartDate rdate <$> smartdate)+  choice [string' "to", string' "until", string "..", string "-"]+  *> skipNonNewlineSpaces+  *> (DateSpan Nothing . Just . fixSmartDate rdate <$> smartdateorquarterstartp rdate) -justdatespanp :: Day -> TextParser m DateSpan-justdatespanp rdate =-    optional (string' "in" *> skipNonNewlineSpaces)-    *> (spanFromSmartDate rdate <$> smartdate)+-- |j+-- >>> let p = parsewith (indatespanp (fromGregorian 2024 02 02) <* eof)+-- >>> p "2025-01-01"+-- Right DateSpan 2025-01-01+-- >>> p "2025q1"+-- Right DateSpan 2025Q1+-- >>> p "in Q2"+-- Right DateSpan 2024Q2+indatespanp :: Day -> TextParser m DateSpan+indatespanp rdate =+  optional (string' "in" *> skipNonNewlineSpaces)+  *> choice' [+    quarterspanp rdate,+    spanFromSmartDate rdate <$> smartdate+  ]++-- Helper: parse a quarter number, optionally preceded by a year.+quarterp :: Day -> TextParser m (Year, Int)+quarterp rdate = do+  y <- yearp <|> pure (first3 $ toGregorian rdate)+  n <- char' 'q' *> satisfy (`elem` ['1' .. '4']) >>= return . digitToInt+  return (y, n)++-- | Parse a single quarter (YYYYqN or qN, case insensitive q) as a date span.+--+-- >>> parsewith (quarterspanp (fromGregorian 2018 01 01) <* eof) "q1"+-- Right DateSpan 2018Q1+-- >>> parsewith (quarterspanp (fromGregorian 2018 01 01) <* eof) "Q1"+-- Right DateSpan 2018Q1+-- >>> parsewith (quarterspanp (fromGregorian 2018 01 01) <* eof) "2020q4"+-- Right DateSpan 2020Q4+quarterspanp :: Day -> TextParser m DateSpan+quarterspanp rdate = do+  (y,q) <- quarterp rdate+  return . periodAsDateSpan $ QuarterPeriod y q++-- | Parse a quarter (YYYYqN or qN, case insensitive q) as its start date.+--+-- >>> parsewith (quarterstartp (fromGregorian 2025 02 02) <* eof) "q1"+-- Right 2025-01-01+-- >>> parsewith (quarterstartp (fromGregorian 2025 02 02) <* eof) "Q2"+-- Right 2025-04-01+-- >>> parsewith (quarterstartp (fromGregorian 2025 02 02) <* eof) "2025q4"+-- Right 2025-10-01+quarterstartp :: Day -> TextParser m Day+quarterstartp rdate = do+  (y,q) <- quarterp rdate+  return $+    fromMaybe (error' "Hledger.Data.Dates.quarterstartp: invalid date found") $  -- PARTIAL, shouldn't happen+    periodStart $ QuarterPeriod y q++smartdateorquarterstartp :: Day -> TextParser m SmartDate+smartdateorquarterstartp rdate = choice' [SmartCompleteDate <$> quarterstartp rdate, smartdate]  nulldatespan :: DateSpan nulldatespan = DateSpan Nothing Nothing
Hledger/Data/Journal.hs view
@@ -26,7 +26,6 @@   journalInferMarketPricesFromTransactions,   journalInferCommodityStyles,   journalStyleAmounts,-  commodityStylesFromAmounts,   journalCommodityStyles,   journalCommodityStylesWith,   journalToCost,@@ -56,7 +55,6 @@   journalAccountNamesDeclared,   journalAccountNamesDeclaredOrUsed,   journalAccountNamesDeclaredOrImplied,-  journalLeafAccountNamesDeclared,   journalAccountNames,   journalLeafAccountNames,   journalAccountNameTree,@@ -73,9 +71,14 @@   journalTagsDeclared,   journalTagsUsed,   journalTagsDeclaredOrUsed,+  journalAmounts,+  journalPostingAmounts,+  journalPostingAndCostAmounts,   journalCommoditiesDeclared,   journalCommoditiesUsed,   journalCommodities,+  journalCommoditiesFromPriceDirectives,+  journalCommoditiesFromTransactions,   journalDateSpan,   journalDateSpanBothDates,   journalStartDate,@@ -88,21 +91,19 @@   journalNextTransaction,   journalPrevTransaction,   journalPostings,-  journalPostingAmounts,-  showJournalAmountsDebug,+  showJournalPostingAmountsDebug,   journalTransactionsSimilarTo,   -- * Account types   journalAccountType,   journalAccountTypes,   journalAddAccountTypes,   journalPostingsAddAccountTags,+  journalPostingsKeepAccountTagsOnly,   defaultBaseConversionAccount,   -- journalPrices,   journalBaseConversionAccount,   journalConversionAccounts,   -- * Misc-  canonicalStyleFrom,-   nulljournal,   journalConcat,   journalNumberTransactions,@@ -114,7 +115,7 @@   -- * Tests   samplejournal,   samplejournalMaybeExplicit,-  tests_Journal+  tests_Journal,   -- ) where@@ -308,10 +309,10 @@     -- it seems unneeded except perhaps for debugging  -- | Debug log the ordering of a journal's account declarations--- (at debug level 5+).+-- (at debug level 7+). dbgJournalAcctDeclOrder :: String -> Journal -> Journal dbgJournalAcctDeclOrder prefix =-  dbg5With ((prefix++) . showAcctDeclsSummary . jdeclaredaccounts)+  dbg7With ((prefix++) . showAcctDeclsSummary . jdeclaredaccounts)   where     showAcctDeclsSummary :: [(AccountName,AccountDeclarationInfo)] -> String     showAcctDeclsSummary adis@@ -399,26 +400,48 @@ journalPostingAmounts :: Journal -> [MixedAmount] journalPostingAmounts = map pamount . journalPostings --- | Show the journal amounts rendered, suitable for debug logging.-showJournalAmountsDebug :: Journal -> String-showJournalAmountsDebug = show.map showMixedAmountOneLine.journalPostingAmounts+-- | Show the journal posting amounts rendered, suitable for debug logging.+showJournalPostingAmountsDebug :: Journal -> String+showJournalPostingAmountsDebug = show . map showMixedAmountOneLine . journalPostingAmounts +-- | All raw amounts used in this journal's postings and costs,+-- with MixedAmounts flattened, in parse order.+journalPostingAndCostAmounts :: Journal -> [Amount]+journalPostingAndCostAmounts = concatMap getAmounts . concatMap (amountsRaw . pamount) . journalPostings++-- | All raw amounts appearing in this journal, with MixedAmounts flattened, in no particular order.+-- (Including from posting amounts, cost amounts, P directives, and the last D directive.)+journalAmounts :: Journal -> S.Set Amount+journalAmounts = S.fromList . journalStyleInfluencingAmounts True+ -- | Sorted unique commodity symbols declared by commodity directives in this journal. journalCommoditiesDeclared :: Journal -> [CommoditySymbol] journalCommoditiesDeclared = M.keys . jdeclaredcommodities --- | Sorted unique commodity symbols used in this journal.+-- | Sorted unique commodity symbols used anywhere in this journal, including+-- commodity directives, P directives, the last D directive, posting amounts and cost amounts. journalCommoditiesUsed :: Journal -> [CommoditySymbol]-journalCommoditiesUsed = S.elems . S.fromList . concatMap (map acommodity . amounts) . journalPostingAmounts+journalCommoditiesUsed j = S.elems $+  journalCommoditiesFromPriceDirectives j <>+  (S.fromList $ map acommodity $ journalStyleInfluencingAmounts True j) --- | Sorted unique commodity symbols mentioned in this journal.+-- | Sorted unique commodity symbols mentioned anywhere in this journal.+-- (Including commodity directives, P directives, the last D directive, posting amounts and cost amounts.) journalCommodities :: Journal -> S.Set CommoditySymbol journalCommodities j =      M.keysSet (jdeclaredcommodities j)-  <> M.keysSet (jinferredcommoditystyles j)-  <> S.fromList (concatMap pdcommodities $ jpricedirectives j)-      where pdcommodities pd = [pdcommodity pd, acommodity $ pdamount pd]+  <> journalCommoditiesFromPriceDirectives j+  <> S.fromList (map acommodity $ journalStyleInfluencingAmounts True j) +-- | Sorted unique commodity symbols mentioned in this journal's P directives.+journalCommoditiesFromPriceDirectives :: Journal -> S.Set CommoditySymbol+journalCommoditiesFromPriceDirectives = S.fromList . concatMap pdcomms . jpricedirectives+  where pdcomms pd = [pdcommodity pd, acommodity $ pdamount pd]++-- | Sorted unique commodity symbols used in transactions, in either posting or cost amounts.+journalCommoditiesFromTransactions :: Journal -> S.Set CommoditySymbol+journalCommoditiesFromTransactions j = S.fromList $ map acommodity $ journalPostingAndCostAmounts j+ -- | Unique transaction descriptions used in this journal. journalDescriptions :: Journal -> [Text] journalDescriptions = nubSort . map tdescription . jtxns@@ -463,11 +486,6 @@ journalAccountNamesDeclared :: Journal -> [AccountName] journalAccountNamesDeclared = nubSort . map fst . jdeclaredaccounts --- | Sorted unique account names declared by account directives in this journal,--- which have no children.-journalLeafAccountNamesDeclared :: Journal -> [AccountName]-journalLeafAccountNamesDeclared = treeLeaves . accountNameTreeFrom . journalAccountNamesDeclared- -- | Sorted unique account names declared by account directives or posted to -- by transactions in this journal. journalAccountNamesDeclaredOrUsed :: Journal -> [AccountName]@@ -625,6 +643,13 @@ journalPostingsAddAccountTags j = journalMapPostings addtags j   where addtags p = p `postingAddTags` (journalInheritedAccountTags j $ paccount p) +-- | Remove all tags from the journal's postings except those provided by their account.+-- This is useful for the accounts report.+-- It does not remove tag declarations from the posting comments.+journalPostingsKeepAccountTagsOnly :: Journal -> Journal+journalPostingsKeepAccountTagsOnly j = journalMapPostings keepaccounttags j+  where keepaccounttags p = p{ptags=[]} `postingAddTags` (journalInheritedAccountTags j $ paccount p)+ -- | The account name to use for conversion postings generated by --infer-equity. -- This is the first account declared with type V/Conversion, -- or otherwise the defaultBaseConversionAccount (equity:conversion).@@ -910,59 +935,14 @@ journalCommodityStylesWith r = amountStylesSetRounding r . journalCommodityStyles  -- | Collect and save inferred amount styles for each commodity based on--- the posting amounts in that commodity (excluding price amounts).+-- P directive amounts, posting amounts but not cost amounts, and maybe the last D amount, in that commodity. -- Can return an error message eg if inconsistent number formats are found. journalInferCommodityStyles :: Journal -> Either String Journal journalInferCommodityStyles j =-  case commodityStylesFromAmounts $ journalStyleInfluencingAmounts j of+  case commodityStylesFromAmounts $ journalStyleInfluencingAmounts False j of     Left e   -> Left e     Right cs -> Right j{jinferredcommoditystyles = dbg7 "journalInferCommodityStyles" cs} --- | Given a list of amounts, in parse order (roughly speaking; see journalStyleInfluencingAmounts),--- build a map from their commodity names to standard commodity--- display formats. Can return an error message eg if inconsistent--- number formats are found.------ Though, these amounts may have come from multiple files, so we--- shouldn't assume they use consistent number formats.--- Currently we don't enforce that even within a single file,--- and this function never reports an error.----commodityStylesFromAmounts :: [Amount] -> Either String (M.Map CommoditySymbol AmountStyle)-commodityStylesFromAmounts =-    Right . foldr (\a -> M.insertWith canonicalStyle (acommodity a) (astyle a)) mempty---- | Given a list of amount styles (assumed to be from parsed amounts--- in a single commodity), in parse order, choose a canonical style.-canonicalStyleFrom :: [AmountStyle] -> AmountStyle-canonicalStyleFrom = foldl' canonicalStyle amountstyle---- TODO: should probably detect and report inconsistencies here.--- Though, we don't have the info for a good error message, so maybe elsewhere.--- | Given a pair of AmountStyles, choose a canonical style.--- This is:--- the general style of the first amount,--- with the first digit group style seen,--- with the maximum precision of all.-canonicalStyle :: AmountStyle -> AmountStyle -> AmountStyle-canonicalStyle a b = a{asprecision=prec, asdecimalmark=decmark, asdigitgroups=mgrps}-  where-    -- precision is maximum of all precisions-    prec = max (asprecision a) (asprecision b)-    -- identify the digit group mark (& group sizes)-    mgrps = asdigitgroups a <|> asdigitgroups b-    -- if a digit group mark was identified above, we can rely on that;-    -- make sure the decimal mark is different. If not, default to period.-    defdecmark = case mgrps of-        Just (DigitGroups '.' _) -> ','-        _                        -> '.'-    -- identify the decimal mark: the first one used, or the above default,-    -- but never the same character as the digit group mark.-    -- urgh.. refactor..-    decmark = case mgrps of-        Just _  -> Just defdecmark-        Nothing -> asdecimalmark a <|> asdecimalmark b <|> Just defdecmark- -- -- | Apply this journal's historical price records to unpriced amounts where possible. -- journalApplyPriceDirectives :: Journal -> Journal -- journalApplyPriceDirectives j@Journal{jtxns=ts} = j{jtxns=map fixtransaction ts}@@ -1036,23 +1016,23 @@ --               Just (UnitCost ma)  -> c:(concatMap amountCommodities $ amounts ma) --               Just (TotalCost ma) -> c:(concatMap amountCommodities $ amounts ma) --- | Get an ordered list of amounts in this journal which can--- influence canonical amount display styles. Those amounts are, in--- the following order:+-- | Get an ordered list of amounts in this journal which can influence+-- canonical amount display styles (excluding the ones in commodity directives). +-- They are, in the following order: -- -- * amounts in market price (P) directives (in parse order)--- * posting amounts in transactions (in parse order)+-- * posting amounts and optionally cost amounts (in parse order) -- * the amount in the final default commodity (D) directive ----- Transaction price amounts (posting amounts' acost field) are not included.----journalStyleInfluencingAmounts :: Journal -> [Amount]-journalStyleInfluencingAmounts j =+journalStyleInfluencingAmounts :: Bool -> Journal -> [Amount]+journalStyleInfluencingAmounts includecost j =   dbg7 "journalStyleInfluencingAmounts" $   catMaybes $ concat [    [mdefaultcommodityamt]   ,map (Just . pdamount) $ jpricedirectives j-  ,map Just . concatMap (amountsRaw . pamount) $ journalPostings j+  ,map Just $ if includecost+    then journalPostingAndCostAmounts j+    else concatMap amountsRaw $ journalPostingAmounts j   ]   where     -- D's amount style isn't actually stored as an amount, make it into one
Hledger/Data/Json.hs view
@@ -22,9 +22,11 @@ --import           Data.Aeson.TH import qualified Data.ByteString.Lazy as BL import           Data.Decimal (DecimalRaw(..), roundTo)+import qualified Data.IntMap as IM import           Data.Maybe (fromMaybe) import qualified Data.Text.Lazy    as TL import qualified Data.Text.Lazy.Builder as TB+import           Data.Time (Day(..)) import           Text.Megaparsec (Pos, SourcePos, mkPos, unPos)  import           Hledger.Data.Types@@ -155,24 +157,27 @@ instance ToJSON TimeclockEntry instance ToJSON Journal -instance ToJSON Account where+instance ToJSON BalanceData+instance ToJSON a => ToJSON (PeriodData a) where+  toJSON a = object+    [ "pdpre" .= pdpre a+    , "pdperiods" .= map (\(d, x) -> (ModifiedJulianDay (toInteger d), x)) (IM.toList $ pdperiods a)+    ]++instance ToJSON a => ToJSON (Account a) where   toJSON = object . accountKV   toEncoding = pairs . mconcat . accountKV  accountKV :: #if MIN_VERSION_aeson(2,2,0)-  KeyValue e kv+  (KeyValue e kv, ToJSON a) #else-  KeyValue kv+  (KeyValue kv, ToJSON a) #endif-  => Account -> [kv]+  => Account a -> [kv] accountKV a =     [ "aname"            .= aname a     , "adeclarationinfo" .= adeclarationinfo a-    , "aebalance"        .= aebalance a-    , "aibalance"        .= aibalance a-    , "anumpostings"     .= anumpostings a-    , "aboring"          .= aboring a     -- To avoid a cycle, show just the parent account's name     -- in a dummy field. When re-parsed, there will be no parent.     , "aparent_"         .= maybe "" aname (aparent a)@@ -181,7 +186,9 @@     -- The actual subaccounts (and their subs..), making a (probably highly redundant) tree     -- ,"asubs"        .= asubs a     -- Omit the actual subaccounts-    , "asubs"            .= ([]::[Account])+    , "asubs"            .= ([]::[Account BalanceData])+    , "aboring"          .= aboring a+    , "adata"            .= adata a     ]  instance ToJSON Ledger@@ -215,10 +222,17 @@ instance FromJSON Posting instance FromJSON Transaction instance FromJSON AccountDeclarationInfo++instance FromJSON BalanceData+instance FromJSON a => FromJSON (PeriodData a) where+  parseJSON = withObject "PeriodData" $ \v -> PeriodData+    <$> v .: "pdpre"+    <*> (IM.fromList . map (\(d, x) -> (fromInteger $ toModifiedJulianDay d, x)) <$> v .: "pdperiods")+ -- XXX The ToJSON instance replaces subaccounts with just names. -- Here we should try to make use of those to reconstruct the -- parent-child relationships.-instance FromJSON Account+instance FromJSON a => FromJSON (Account a)  -- Decimal, various attempts --
Hledger/Data/Ledger.hs view
@@ -32,6 +32,7 @@ import Test.Tasty.HUnit ((@?=), testCase) import Hledger.Data.Types import Hledger.Data.Account+import Hledger.Data.Dates (nulldate) import Hledger.Data.Journal import Hledger.Query @@ -61,7 +62,8 @@     (q',depthq)  = (filterQuery (not . queryIsDepth) q, filterQuery queryIsDepth q)     j'  = filterJournalAmounts (filterQuery queryIsSym q) $ -- remove amount parts which the query's sym: terms would exclude           filterJournalPostings q' j-    as  = accountsFromPostings $ journalPostings j'+    -- Ledger does not use date-separated balances, so dates are left empty+    as  = accountsFromPostings (const $ Just nulldate) $ journalPostings j'     j'' = filterJournalPostings depthq j'  -- | List a ledger's account names.@@ -69,21 +71,21 @@ ledgerAccountNames = drop 1 . map aname . laccounts  -- | Get the named account from a ledger.-ledgerAccount :: Ledger -> AccountName -> Maybe Account+ledgerAccount :: Ledger -> AccountName -> Maybe (Account BalanceData) ledgerAccount l a = lookupAccount a $ laccounts l  -- | Get this ledger's root account, which is a dummy "root" account -- above all others. This should always be first in the account list, -- if somehow not this returns a null account.-ledgerRootAccount :: Ledger -> Account+ledgerRootAccount :: Ledger -> Account BalanceData ledgerRootAccount = headDef nullacct . laccounts  -- | List a ledger's top-level accounts (the ones below the root), in tree order.-ledgerTopAccounts :: Ledger -> [Account]+ledgerTopAccounts :: Ledger -> [Account BalanceData] ledgerTopAccounts = asubs . headDef nullacct . laccounts  -- | List a ledger's bottom-level (subaccount-less) accounts, in tree order.-ledgerLeafAccounts :: Ledger -> [Account]+ledgerLeafAccounts :: Ledger -> [Account BalanceData] ledgerLeafAccounts = filter (null.asubs) . laccounts  -- | List a ledger's postings, in the order parsed.
+ Hledger/Data/PeriodData.hs view
@@ -0,0 +1,127 @@+{-# LANGUAGE CPP #-}+{-|+++Data values for zero or more report periods, and for the pre-report period.+Report periods are assumed to be contiguous, and represented only by start dates+(as keys of an IntMap).++-}+module Hledger.Data.PeriodData+( periodDataFromList++, lookupPeriodData+, insertPeriodData+, opPeriodData+, mergePeriodData+, padPeriodData++, tests_PeriodData+) where++#if MIN_VERSION_base(4,18,0)+import Data.Foldable1 (Foldable1(..))+#else+import Control.Applicative (liftA2)+#endif+import qualified Data.IntMap.Strict as IM+import qualified Data.IntSet as IS+#if !MIN_VERSION_base(4,20,0)+import Data.List (foldl')+#endif+import Data.Time (Day(..), fromGregorian)++import Test.Tasty (testGroup)+import Test.Tasty.HUnit ((@?=), testCase)++import Hledger.Data.Amount+import Hledger.Data.Types+++instance Show a => Show (PeriodData a) where+  showsPrec d (PeriodData h ds) =+    showParen (d > 10) $+        showString "PeriodData"+      . showString "{ pdpre = " . shows h+      . showString ", pdperiods = "+      . showString "fromList " . shows (map (\(day, x) -> (ModifiedJulianDay $ toInteger day, x)) $ IM.toList ds)+      . showChar '}'++instance Foldable PeriodData where+  foldr f z (PeriodData h as) = foldr f (f h z) as+  foldl f z (PeriodData h as) = foldl f (f z h) as+  foldl' f z (PeriodData h as) = let fzh = f z h in fzh `seq` foldl' f fzh as++#if MIN_VERSION_base(4,18,0)+instance Foldable1 PeriodData where+  foldrMap1 f g (PeriodData h as) = foldr g (f h) as+  foldlMap1 f g (PeriodData h as) = foldl g (f h) as+  foldlMap1' f g (PeriodData h as) = let fh = f h in fh `seq` foldl' g fh as+#endif++instance Traversable PeriodData where+  traverse f (PeriodData h as) = liftA2 PeriodData (f h) $ traverse f as++-- | The Semigroup instance for 'AccountBalance' will simply take the union of+-- keys in the date map section. This may not be the result you want if the+-- keys are not identical.+instance Semigroup a => Semigroup (PeriodData a) where+  PeriodData h1 as1 <> PeriodData h2 as2 = PeriodData (h1 <> h2) $ IM.unionWith (<>) as1 as2++instance Monoid a => Monoid (PeriodData a) where+  mempty = PeriodData mempty mempty++-- | Construct an 'PeriodData' from a list.+periodDataFromList :: a -> [(Day, a)] -> PeriodData a+periodDataFromList h = PeriodData h . IM.fromList . map (\(d, a) -> (fromInteger $ toModifiedJulianDay d, a))++-- | Get account balance information to the period containing a given 'Day'.+lookupPeriodData :: Day -> PeriodData a -> a+lookupPeriodData d (PeriodData h as) =+    maybe h snd $ IM.lookupLE (fromInteger $ toModifiedJulianDay d) as++-- | Add account balance information to the appropriate location in 'PeriodData'.+insertPeriodData :: Semigroup a => Maybe Day -> a -> PeriodData a -> PeriodData a+insertPeriodData mday b balances = case mday of+    Nothing  -> balances{pdpre = pdpre balances <> b}+    Just day -> balances{pdperiods = IM.insertWith (<>) (fromInteger $ toModifiedJulianDay day) b $ pdperiods balances}++-- | Merges two 'PeriodData', using the given operation to combine their balance information.+--+-- This will drop keys if they are not present in both 'PeriodData'.+opPeriodData :: (a -> b -> c) -> PeriodData a -> PeriodData b -> PeriodData c+opPeriodData f (PeriodData h1 as1) (PeriodData h2 as2) =+    PeriodData (f h1 h2) $ IM.intersectionWith f as1 as2++-- | Merges two 'PeriodData', using the given operations for balance+-- information only in the first, only in the second, or in both+-- 'PeriodData', respectively.+mergePeriodData :: (a -> c) -> (b -> c) -> (a -> b -> c)+                -> PeriodData a -> PeriodData b -> PeriodData c+mergePeriodData only1 only2 f = \(PeriodData h1 as1) (PeriodData h2 as2) ->+    PeriodData (f h1 h2) $ merge as1 as2+  where+    merge = IM.mergeWithKey (\_ x y -> Just $ f x y) (fmap only1) (fmap only2)++-- | Pad out the datemap of an 'PeriodData' so that every key from a set is present.+padPeriodData :: Monoid a => IS.IntSet -> PeriodData a -> PeriodData a+padPeriodData keys bal = bal{pdperiods = pdperiods bal <> IM.fromSet (const mempty) keys}+++-- tests++tests_PeriodData =+  let+    dayMap  = periodDataFromList (mixed [usd 1]) [(fromGregorian 2000 01 01, mixed [usd 2]), (fromGregorian 2004 02 28, mixed [usd 3])]+    dayMap2 = periodDataFromList (mixed [usd 2]) [(fromGregorian 2000 01 01, mixed [usd 4]), (fromGregorian 2004 02 28, mixed [usd 6])]+  in testGroup "PeriodData" [++  testCase "periodDataFromList" $ do+    length dayMap @?= 3,++  testCase "Semigroup instance" $ do+    dayMap <> dayMap @?= dayMap2,++  testCase "Monoid instance" $ do+    dayMap <> mempty @?= dayMap+  ]
Hledger/Data/PeriodicTransaction.hs view
@@ -215,7 +215,7 @@     alltxnspans = splitSpan adjust ptinterval span'       where         -- If the PT does not specify  start or end dates, we take them from the requestedspan.-        span' = ptspan `spanDefaultsFrom` requestedspan+        span' = ptspan `spanValidDefaultsFrom` requestedspan         -- Unless the PT specified a start date explicitly, we will adjust the start date to the previous interval boundary.         adjust = isNothing $ spanStart span' 
Hledger/Data/Posting.hs view
@@ -435,7 +435,7 @@ postingAllTags :: Posting -> [Tag] postingAllTags p = ptags p ++ maybe [] ttags (ptransaction p) --- | Tags for this transaction including any from its postings.+-- | Tags for this transaction including any from its postings (which includes any from the postings' accounts). transactionAllTags :: Transaction -> [Tag] transactionAllTags t = ttags t ++ concatMap ptags (tpostings t) 
Hledger/Data/Timeclock.hs view
@@ -7,10 +7,11 @@ -}  {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE StandaloneDeriving #-}  module Hledger.Data.Timeclock (-   timeclockEntriesToTransactions-  ,timeclockEntriesToTransactionsSingle+   timeclockToTransactions+  ,timeclockToTransactionsOld   ,tests_Timeclock ) where@@ -31,141 +32,158 @@ import Hledger.Data.Amount import Hledger.Data.Posting +-- detailed output for debugging+-- deriving instance Show TimeclockEntry++-- compact output instance Show TimeclockEntry where-    show t = printf "%s %s %s  %s" (show $ tlcode t) (show $ tldatetime t) (tlaccount t) (tldescription t)+  show t = printf "%s %s %s  %s" (show $ tlcode t) (show $ tldatetime t) (tlaccount t) (tldescription t)  instance Show TimeclockCode where-    show SetBalance = "b"-    show SetRequiredHours = "h"-    show In = "i"-    show Out = "o"-    show FinalOut = "O"+  show SetBalance = "b"+  show SetRequiredHours = "h"+  show In = "i"+  show Out = "o"+  show FinalOut = "O"  instance Read TimeclockCode where-    readsPrec _ ('b' : xs) = [(SetBalance, xs)]-    readsPrec _ ('h' : xs) = [(SetRequiredHours, xs)]-    readsPrec _ ('i' : xs) = [(In, xs)]-    readsPrec _ ('o' : xs) = [(Out, xs)]-    readsPrec _ ('O' : xs) = [(FinalOut, xs)]-    readsPrec _ _ = []--data Session = Session-    { in' :: TimeclockEntry,-      out :: TimeclockEntry-    }+  readsPrec _ ('b':xs) = [(SetBalance, xs)]+  readsPrec _ ('h':xs) = [(SetRequiredHours, xs)]+  readsPrec _ ('i':xs) = [(In, xs)]+  readsPrec _ ('o':xs) = [(Out, xs)]+  readsPrec _ ('O':xs) = [(FinalOut, xs)]+  readsPrec _ _ = [] -data Sessions = Sessions-    { completed :: [Session],-      active :: [TimeclockEntry]-    }+data Session = Session {+  in' :: TimeclockEntry,+  out :: TimeclockEntry+} deriving Show --- Find the relevant clockin in the actives list that should be paired with this clockout.--- If there is a session that has the same account name, then use that.--- Otherwise, if there is an active anonymous session, use that.--- Otherwise, raise an error.-findInForOut :: TimeclockEntry -> ([TimeclockEntry], [TimeclockEntry]) -> (TimeclockEntry, [TimeclockEntry])-findInForOut _ (matchingout : othermatches, rest) = (matchingout, othermatches <> rest)-findInForOut o ([], activeins) =-    if emptyname then (first, rest) else error' errmsg-    where-        l = show $ unPos $ sourceLine $ tlsourcepos o-        c = unPos $ sourceColumn $ tlsourcepos o-        emptyname = tlaccount o == ""-        (first, rest) = case uncons activeins of-            Just (hd, tl) -> (hd, tl)-            Nothing -> error' errmsg-        errmsg =-            printf-              "%s:\n%s\n%s\n\nCould not find previous clockin to match this clockout."-              (sourcePosPretty $ tlsourcepos o)-              (l ++ " | " ++ show o)-              (replicate (length l) ' ' ++ " |" ++ replicate c ' ' ++ "^")+data Sessions = Sessions {+  completed :: [Session],+  active :: [TimeclockEntry]+} deriving Show --- Assuming that entries has been sorted, we go through each time log entry.--- We collect all of the "i" in the list "actives," and each time we encounter--- an "o," we look for the corresponding "i" in actives.--- If we cannot find it, then it is an error (since the list is sorted).--- If the "o" is recorded on a different day than the "i," then we close the--- active entry at the end of its day, replace it in the active list--- with a start at midnight on the next day, and try again.--- This raises an error if any outs cannot be paired with an in.-pairClockEntries :: [TimeclockEntry] -> [TimeclockEntry] -> [Session] -> Sessions-pairClockEntries [] actives sessions = Sessions {completed = sessions, active = actives}-pairClockEntries (entry : rest) actives sessions-    | tlcode entry == In = pairClockEntries rest inentries sessions-    | tlcode entry == Out = pairClockEntries rest' actives' sessions'-    | otherwise = pairClockEntries rest actives sessions-    where-        (inentry, newactive) = findInForOut entry (partition (\e -> tlaccount e == tlaccount entry) actives)-        (itime, otime) = (tldatetime inentry, tldatetime entry)-        (idate, odate) = (localDay itime, localDay otime)-        omidnight = entry {tldatetime = itime {localDay = idate, localTimeOfDay = TimeOfDay 23 59 59}}-        imidnight = inentry {tldatetime = itime {localDay = addDays 1 idate, localTimeOfDay = midnight}}-        (sessions', actives', rest') = if odate > idate then-              (Session {in' = inentry, out = omidnight} : sessions, imidnight : newactive, entry : rest)-            else-              (Session {in' = inentry, out = entry} : sessions, newactive, rest)-        l = show $ unPos $ sourceLine $ tlsourcepos entry-        c = unPos $ sourceColumn $ tlsourcepos entry-        inentries =-          if any (\e -> tlaccount e == tlaccount entry) actives-            then error' $-                printf-                  "%s:\n%s\n%s\n\nEncountered clockin entry for session \"%s\" that is already active."-                  (sourcePosPretty $ tlsourcepos entry)-                  (l ++ " | " ++ show entry)-                  (replicate (length l) ' ' ++ " |" ++ replicate c ' ' ++ "^")-                  (tlaccount entry)-            else entry : actives+-- | Convert timeclock entries to journal transactions.+-- This is the old version from hledger <1.43, now enabled by --old-timeclock.+-- It requires strictly alternating clock-in and clock-entries.+-- It was documented as allowing only one clocked-in session at a time,+-- but in fact it allows concurrent sessions, even with the same account name.+--+-- Entries must be a strict alternation of in and out, beginning with in.+-- When there is no clockout, one is added with the provided current time. +-- Sessions crossing midnight are split into days to give accurate per-day totals.+-- If entries are not in the expected in/out order, an error is raised.+--+timeclockToTransactionsOld :: LocalTime -> [TimeclockEntry] -> [Transaction]+timeclockToTransactionsOld _ [] = []+timeclockToTransactionsOld now [i]+  | tlcode i /= In = errorExpectedCodeButGot In i+  | odate > idate = entryFromTimeclockInOut True i o' : timeclockToTransactionsOld now [i',o]+  | otherwise = [entryFromTimeclockInOut True i o]+  where+    o = TimeclockEntry (tlsourcepos i) Out end "" "" "" []+    end = if itime > now then itime else now+    (itime,otime) = (tldatetime i,tldatetime o)+    (idate,odate) = (localDay itime,localDay otime)+    o' = o{tldatetime=itime{localDay=idate, localTimeOfDay=TimeOfDay 23 59 59}}+    i' = i{tldatetime=itime{localDay=addDays 1 idate, localTimeOfDay=midnight}}+timeclockToTransactionsOld now (i:o:rest)+  | tlcode i /= In  = errorExpectedCodeButGot In i+  | tlcode o /= Out = errorExpectedCodeButGot Out o+  | odate > idate   = entryFromTimeclockInOut True i o' : timeclockToTransactionsOld now (i':o:rest)+  | otherwise       = entryFromTimeclockInOut True i o : timeclockToTransactionsOld now rest+  where+    (itime,otime) = (tldatetime i,tldatetime o)+    (idate,odate) = (localDay itime,localDay otime)+    o' = o{tldatetime=itime{localDay=idate, localTimeOfDay=TimeOfDay 23 59 59}}+    i' = i{tldatetime=itime{localDay=addDays 1 idate, localTimeOfDay=midnight}}+{- HLINT ignore timeclockToTransactionsOld -} --- | Convert time log entries to journal transactions. When there is no--- clockout, add one with the provided current time. Sessions crossing--- midnight are split into days to give accurate per-day totals.--- If any entries cannot be paired as expected, then an error is raised.-timeclockEntriesToTransactions :: LocalTime -> [TimeclockEntry] -> [Transaction]-timeclockEntriesToTransactions now entries = transactions+-- | Convert timeclock entries to journal transactions.+-- This is the new, default version added in hledger 1.43 and improved in 1.50.+-- It allows concurrent clocked-in sessions (though not with the same account name),+-- and clock-in/clock-out entries in any order.+--+-- Entries are processed in parse order.+-- Sessions crossing midnight are split into days to give accurate per-day totals.+-- At the end, any sessions with no clockout get an implicit clockout with the provided "now" time.+-- If any entries cannot be paired as expected, an error is raised.+--+timeclockToTransactions :: LocalTime -> [TimeclockEntry] -> [Transaction]+timeclockToTransactions now entries0 = transactions   where-    sessions = pairClockEntries (sortBy (\e1 e2 -> compare (tldatetime e1) (tldatetime e2)) entries) [] []-    transactionsFromSession s = entryFromTimeclockInOut (in' s) (out s)+    -- don't sort by time, it messes things up; just reverse to get the parsed order+    entries = dbg7 "timeclock entries" $ reverse entries0+    sessions = dbg6 "sessions" $ pairClockEntries entries [] []+    transactionsFromSession s = entryFromTimeclockInOut False (in' s) (out s)     -- If any "in" sessions are in the future, then set their out time to the initial time     outtime te = max now (tldatetime te)     createout te = TimeclockEntry (tlsourcepos te) Out (outtime te) (tlaccount te) "" "" []     outs = map createout (active sessions)-    stillopen = pairClockEntries ((active sessions) <> outs) [] []+    stillopen = dbg6 "stillopen" $ pairClockEntries ((active sessions) <> outs) [] []     transactions = map transactionsFromSession $ sortBy (\s1 s2 -> compare (in' s1) (in' s2)) (completed sessions ++ completed stillopen) --- | Convert time log entries to journal transactions, expecting the entries to be--- a strict in/out cycle. When there is no clockout, add one with the provided current time. --- Sessions crossing midnight are split into days to give accurate per-day totals.-timeclockEntriesToTransactionsSingle :: LocalTime -> [TimeclockEntry] -> [Transaction]-timeclockEntriesToTransactionsSingle _ [] = []-timeclockEntriesToTransactionsSingle now [i]-    | tlcode i /= In = errorExpectedCodeButGot In i-    | odate > idate = entryFromTimeclockInOut i o' : timeclockEntriesToTransactions now [i',o]-    | otherwise = [entryFromTimeclockInOut i o]-    where-      o = TimeclockEntry (tlsourcepos i) Out end "" "" "" []-      end = if itime > now then itime else now-      (itime,otime) = (tldatetime i,tldatetime o)-      (idate,odate) = (localDay itime,localDay otime)-      o' = o{tldatetime=itime{localDay=idate, localTimeOfDay=TimeOfDay 23 59 59}}-      i' = i{tldatetime=itime{localDay=addDays 1 idate, localTimeOfDay=midnight}}-timeclockEntriesToTransactionsSingle now (i:o:rest)-    | tlcode i /= In  = errorExpectedCodeButGot In i-    | tlcode o /= Out = errorExpectedCodeButGot Out o-    | odate > idate   = entryFromTimeclockInOut i o' : timeclockEntriesToTransactions now (i':o:rest)-    | otherwise       = entryFromTimeclockInOut i o : timeclockEntriesToTransactions now rest-    where-      (itime,otime) = (tldatetime i,tldatetime o)-      (idate,odate) = (localDay itime,localDay otime)-      o' = o{tldatetime=itime{localDay=idate, localTimeOfDay=TimeOfDay 23 59 59}}-      i' = i{tldatetime=itime{localDay=addDays 1 idate, localTimeOfDay=midnight}}-{- HLINT ignore timeclockEntriesToTransactions -}+    -- | Assuming that entries have been sorted, we go through each time log entry.+    -- We collect all of the "i" in the list "actives," and each time we encounter+    -- an "o," we look for the corresponding "i" in actives.+    -- If we cannot find it, then it is an error (since the list is sorted).+    -- If the "o" is recorded on a different day than the "i" then we close the+    -- active entry at the end of its day, replace it in the active list+    -- with a start at midnight on the next day, and try again.+    -- This raises an error if any outs cannot be paired with an in.+    pairClockEntries :: [TimeclockEntry] -> [TimeclockEntry] -> [Session] -> Sessions+    pairClockEntries [] actives sessions1 = Sessions {completed = sessions1, active = actives}+    pairClockEntries (entry:es) actives sessions1+      | tlcode entry == In  = pairClockEntries es inentries sessions1+      | tlcode entry == Out = pairClockEntries es' actives' sessions2+      | otherwise = pairClockEntries es actives sessions1+      where+        (inentry, newactive) = findInForOut entry (partition (\e -> tlaccount e == tlaccount entry) actives)+        (itime, otime) = (tldatetime inentry, tldatetime entry)+        (idate, odate) = (localDay itime, localDay otime)+        omidnight = entry {tldatetime = itime {localDay = idate, localTimeOfDay = TimeOfDay 23 59 59}}+        imidnight = inentry {tldatetime = itime {localDay = addDays 1 idate, localTimeOfDay = midnight}}+        (sessions2, actives', es')+          | odate > idate = (Session {in' = inentry, out = omidnight} : sessions1, imidnight:newactive, entry:es)+          | otherwise     = (Session {in' = inentry, out = entry} : sessions1, newactive, es)+        inentries = case filter ((== tlaccount entry) . tlaccount) actives of+          []                -> entry:actives+          activesinthisacct -> error' $ T.unpack $ makeTimeClockErrorExcerpt entry $ T.unlines $ [+            ""+            ,"overlaps with session beginning at:"+            ,""+            ]+            <> map (flip makeTimeClockErrorExcerpt "") activesinthisacct+            <> [ "Overlapping sessions with the same account name are not supported." ]+            -- XXX better to show full session(s)+            -- <> map (T.pack . show) (filter ((`elem` activesinthisacct).in') sessions) +        -- | Find the relevant clockin in the actives list that should be paired with this clockout.+        -- If there is a session that has the same account name, then use that.+        -- Otherwise, if there is an active anonymous session, use that.+        -- Otherwise, raise an error.+        findInForOut :: TimeclockEntry -> ([TimeclockEntry], [TimeclockEntry]) -> (TimeclockEntry, [TimeclockEntry])+        findInForOut _ (matchingout:othermatches, rest) = (matchingout, othermatches <> rest)+        findInForOut o ([], activeins) =+            if emptyname then (first, rest) else error' errmsg+            where+                l = show $ unPos $ sourceLine $ tlsourcepos o+                c = unPos $ sourceColumn $ tlsourcepos o+                emptyname = tlaccount o == ""+                (first, rest) = case uncons activeins of+                    Just (hd, tl) -> (hd, tl)+                    Nothing -> error' errmsg+                errmsg =+                    printf+                      "%s:\n%s\n%s\n\nCould not find previous clockin to match this clockout."+                      (sourcePosPretty $ tlsourcepos o)+                      (l ++ " | " ++ show o)+                      (replicate (length l) ' ' ++ " |" ++ replicate c ' ' ++ "^")+ errorExpectedCodeButGot :: TimeclockCode -> TimeclockEntry -> a errorExpectedCodeButGot expected actual = error' $ printf   ("%s:\n%s\n%s\n\nExpected a timeclock %s entry but got %s.\n"-  ++"Only one session may be clocked in at a time.\n"   ++"Please alternate i and o, beginning with i.")   (sourcePosPretty $ tlsourcepos actual)   (l ++ " | " ++ show actual)@@ -176,12 +194,22 @@     l = show $ unPos $ sourceLine $ tlsourcepos actual     c = unPos $ sourceColumn $ tlsourcepos actual +makeTimeClockErrorExcerpt :: TimeclockEntry -> T.Text -> T.Text+makeTimeClockErrorExcerpt e@TimeclockEntry{tlsourcepos=pos} msg = T.unlines [+  T.pack (sourcePosPretty pos) <> ":"+  ,l <> " | " <> T.pack (show e)+  -- ,T.replicate (T.length l) " " <> " |" -- <> T.replicate c " " <> "^")+  ] <> msg+  where+    l = T.pack $ show $ unPos $ sourceLine $ tlsourcepos e+    -- c = unPos $ sourceColumn $ tlsourcepos e+ -- | Convert a timeclock clockin and clockout entry to an equivalent journal -- transaction, representing the time expenditure. Note this entry is  not balanced, -- since we omit the \"assets:time\" transaction for simpler output.-entryFromTimeclockInOut :: TimeclockEntry -> TimeclockEntry -> Transaction-entryFromTimeclockInOut i o-    | otime >= itime = t+entryFromTimeclockInOut :: Bool -> TimeclockEntry -> TimeclockEntry -> Transaction+entryFromTimeclockInOut requiretimeordered i o+    | not requiretimeordered || otime >= itime = t     | otherwise =       -- Clockout time earlier than clockin is an error.       -- (Clockin earlier than preceding clockin/clockout is allowed.)@@ -209,8 +237,8 @@             tstatus      = Cleared,             tcode        = "",             tdescription = desc,-            tcomment     = tlcomment i,-            ttags        = tltags i,+            tcomment     = tlcomment i <> tlcomment o,+            ttags        = tltags i ++ tltags o,             tpostings    = ps,             tprecedingcomment=""           }@@ -228,14 +256,19 @@       -- since otherwise it will often have large recurring decimal parts which (since 1.21)       -- print would display all 255 digits of. timeclock amounts have one second resolution,       -- so two decimal places is precise enough (#1527).-      amt   = mixedAmount $ setAmountInternalPrecision 2 $ hrs hours-      ps       = [posting{paccount=acctname, pamount=amt, ptype=VirtualPosting, ptransaction=Just t}]+      amt = case mixedAmount $ setAmountInternalPrecision 2 $ hrs hours of+        a | not $ a < 0 -> a+        _ -> error' $ printf+          "%s%s:\nThis clockout is earlier than the clockin."+          (makeTimeClockErrorExcerpt i "")+          (makeTimeClockErrorExcerpt o "")+      ps = [posting{paccount=acctname, pamount=amt, ptype=VirtualPosting, ptransaction=Just t}]   -- tests  tests_Timeclock = testGroup "Timeclock" [-  testCaseSteps "timeclockEntriesToTransactions tests" $ \step -> do+  testCaseSteps "timeclockToTransactions tests" $ \step -> do       step "gathering data"       today <- getCurrentDay       now' <- getCurrentTime@@ -248,7 +281,7 @@           mktime d = LocalTime d . fromMaybe midnight .                      parseTimeM True defaultTimeLocale "%H:%M:%S"           showtime = formatTime defaultTimeLocale "%H:%M"-          txndescs = map (T.unpack . tdescription) . timeclockEntriesToTransactions now+          txndescs = map (T.unpack . tdescription) . timeclockToTransactions now           future = utcToLocalTime tz $ addUTCTime 100 now'           futurestr = showtime future       step "started yesterday, split session at midnight"@@ -260,13 +293,13 @@       step "use the clockin time for auto-clockout if it's in the future"       txndescs [clockin future "" "" "" []] @?= [printf "%s-%s" futurestr futurestr]       step "multiple open sessions"-      txndescs-        [ clockin (mktime today "00:00:00") "a" "" "" [],-          clockin (mktime today "01:00:00") "b" "" "" [],-          clockin (mktime today "02:00:00") "c" "" "" [],-          clockout (mktime today "03:00:00") "b" "" "" [],-          clockout (mktime today "04:00:00") "a" "" "" [],-          clockout (mktime today "05:00:00") "c" "" "" []-        ]+      txndescs (reverse [+        clockin (mktime today "00:00:00") "a" "" "" [],+        clockin (mktime today "01:00:00") "b" "" "" [],+        clockin (mktime today "02:00:00") "c" "" "" [],+        clockout (mktime today "03:00:00") "b" "" "" [],+        clockout (mktime today "04:00:00") "a" "" "" [],+        clockout (mktime today "05:00:00") "c" "" "" []+        ])         @?= ["00:00-04:00", "01:00-03:00", "02:00-05:00"]  ]
Hledger/Data/Transaction.hs view
@@ -33,11 +33,14 @@ , transactionMapPostings , transactionMapPostingAmounts , transactionAmounts+, transactionCommodityStyles+, transactionCommodityStylesWith , transactionNegate , partitionAndCheckConversionPostings , transactionAddTags , transactionAddHiddenAndMaybeVisibleTag   -- * helpers+, TransactionBalancingPrecision(..) , payeeAndNoteFromDescription , payeeAndNoteFromDescription'   -- nonzerobalanceerror@@ -83,6 +86,22 @@ import Data.List (union)  +-- | How to determine the precision used for checking that transactions are balanced. See #2402.+data TransactionBalancingPrecision =+    TBPOld+    -- ^ Legacy behaviour, as in hledger <1.50, included to ease upgrades.+    -- use precision inferred from the whole journal, overridable by commodity directive or -c.+    -- Display precision is also transaction balancing precision; increasing it can break journal reading.+    -- Some valid journals are rejected until commodity directives are added.+    -- Small unbalanced remainders can be hidden, and in accounts that are never reconciled, can accumulate over time.+  | TBPExact+    -- ^ Simpler, more robust behaviour, as in Ledger: use precision inferred from the transaction.+    -- Display precision and transaction balancing precision are independent; display precision never affects journal reading.+    -- Valid journals from ledger or beancount are accepted without needing commodity directives.+    -- Every imbalance in a transaction is visibly accounted for in that transaction's journal entry.++  deriving (Bounded, Enum, Eq, Ord, Read, Show)+ instance HasAmounts Transaction where   styleAmounts styles t = t{tpostings=styleAmounts styles $ tpostings t} @@ -264,7 +283,7 @@ -- If the transaction already has these tags (with any value), do nothing. transactionAddHiddenAndMaybeVisibleTag :: Bool -> HiddenTag -> Transaction -> Transaction transactionAddHiddenAndMaybeVisibleTag verbosetags ht t@Transaction{tcomment=c, ttags} =-  (t `transactionAddTags` ([ht] <> [vt|verbosetags]))+  (t `transactionAddTags` ([ht] <> [ vt|verbosetags]))   {tcomment=if verbosetags && not hadtag then c `commentAddTagNextLine` vt else c}   where     vt@(vname,_) = toVisibleTag ht@@ -482,6 +501,17 @@ -- | All posting amounts from this transaction, in order. transactionAmounts :: Transaction -> [MixedAmount] transactionAmounts = map pamount . tpostings++-- | Get the canonical amount styles inferred from this transaction's amounts.+transactionCommodityStyles :: Transaction -> M.Map CommoditySymbol AmountStyle+transactionCommodityStyles =+  either (const mempty) id .  -- ignore style problems, commodityStylesFromAmounts doesn't report them currently+  commodityStylesFromAmounts . concatMap (amountsRaw . pamount) . tpostings++-- | Like transactionCommodityStyles, but attach a particular rounding strategy to the styles,+-- affecting how they will affect display precisions when applied.+transactionCommodityStylesWith :: Rounding -> Transaction -> M.Map CommoditySymbol AmountStyle+transactionCommodityStylesWith r = amountStylesSetRounding r . transactionCommodityStyles  -- | Flip the sign of this transaction's posting amounts (and balance assertion amounts). transactionNegate :: Transaction -> Transaction
Hledger/Data/TransactionModifier.hs view
@@ -109,6 +109,7 @@   \p -> styleAmounts styles . renderPostingCommentDates $ pr       { pdate    = pdate  pr <|> pdate  p       , pdate2   = pdate2 pr <|> pdate2 p+      , paccount = account' p       , pamount  = amount' p       , pcomment = pcomment pr & (if verbosetags then (`commentAddTag` ("generated-posting",qry)) else id)       , ptags    = ptags pr@@ -119,6 +120,10 @@     pr = tmprPosting tmpr     qry = "= " <> querytxt     symq = filterQuery (liftA2 (||) queryIsSym queryIsAmt) query+    account' = if accountTemplate `T.isInfixOf` paccount pr+                 then \p -> T.replace accountTemplate (paccount p) $ paccount pr+                 else const $ paccount pr+      where accountTemplate = "%account"     amount' = case postingRuleMultiplier tmpr of         Nothing -> const $ pamount pr         Just n  -> \p ->
Hledger/Data/Types.hs view
@@ -18,6 +18,7 @@  -- {-# LANGUAGE DeriveAnyClass #-}  -- https://hackage.haskell.org/package/deepseq-1.4.4.0/docs/Control-DeepSeq.html#v:rnf {-# LANGUAGE CPP        #-}+{-# LANGUAGE DeriveFunctor        #-} {-# LANGUAGE DeriveGeneric        #-} {-# LANGUAGE FlexibleInstances    #-} {-# LANGUAGE OverloadedStrings    #-}@@ -39,6 +40,7 @@ import Data.Decimal (Decimal, DecimalRaw(..)) import Data.Default (Default(..)) import Data.Functor (($>))+import qualified Data.IntMap.Strict as IM import Data.List (intercalate, sortBy) --XXX https://hackage.haskell.org/package/containers/docs/Data-Map.html --Note: You should use Data.Map.Strict instead of this module if:@@ -670,6 +672,8 @@   | Ssv  -- semicolon-separated   deriving (Eq, Ord) +-- XXX A little confusion, this is also used to name readers in splitReaderPrefix.+-- readers, input formats, and output formats overlap but are distinct concepts. -- | The id of a data format understood by hledger, eg @journal@ or @csv@. -- The --output-format option selects one of these for output. data StorageFormat @@ -733,21 +737,37 @@   ,adisourcepos        = SourcePos "" (mkPos 1) (mkPos 1) } --- | An account, with its balances, parent/subaccount relationships, etc.--- Only the name is required; the other fields are added when needed.-data Account = Account {-   aname                     :: AccountName    -- ^ this account's full name+-- | An account within a hierarchy, with references to its parent+-- and subaccounts if any, and with per-report-period data of type 'a'.+-- Only the name is required; the other fields may or may not be present.+data Account a = Account {+   aname                     :: AccountName        -- ^ full name   ,adeclarationinfo          :: Maybe AccountDeclarationInfo  -- ^ optional extra info from account directives   -- relationships in the tree-  ,asubs                     :: [Account]      -- ^ this account's sub-accounts-  ,aparent                   :: Maybe Account  -- ^ parent account-  ,aboring                   :: Bool           -- ^ used in the accounts report to label elidable parents-  -- balance information-  ,anumpostings              :: Int            -- ^ the number of postings to this account-  ,aebalance                 :: MixedAmount    -- ^ this account's balance, excluding subaccounts-  ,aibalance                 :: MixedAmount    -- ^ this account's balance, including subaccounts-  } deriving (Generic)+  ,asubs                     :: [Account a]        -- ^ subaccounts+  ,aparent                   :: Maybe (Account a)  -- ^ parent account+  ,aboring                   :: Bool               -- ^ used in some reports to indicate elidable accounts+  ,adata                     :: PeriodData a       -- ^ associated data per report period+  } deriving (Generic, Functor) +-- | Data values for zero or more report periods, and for the pre-report period.+-- Report periods are assumed to be contiguous, and represented only by start dates+-- (as keys of an IntMap). XXX how does that work, again ?+data PeriodData a = PeriodData {+   pdpre     :: a            -- ^ data from the pre-report period (e.g. historical balances)+  ,pdperiods :: IM.IntMap a  -- ^ data for the periods+  } deriving (Eq, Functor, Generic)++-- | Data that's useful in "balance" reports:+-- subaccount-exclusive and -inclusive amounts,+-- typically representing either a balance change or an end balance;+-- and a count of postings.+data BalanceData = BalanceData {+   bdexcludingsubs :: MixedAmount  -- ^ balance data excluding subaccounts+  ,bdincludingsubs :: MixedAmount  -- ^ balance data including subaccounts+  ,bdnumpostings :: Int            -- ^ the number of postings+  } deriving (Eq, Generic)+ -- | Whether an account's balance is normally a positive number (in -- accounting terms, a debit balance) or a negative number (credit balance). -- Assets and expenses are normally positive (debit), while liabilities, equity@@ -761,7 +781,7 @@ -- account is the root of the tree and always exists. data Ledger = Ledger {    ljournal  :: Journal-  ,laccounts :: [Account]+  ,laccounts :: [Account BalanceData]   } deriving (Generic)  instance NFData AccountAlias
Hledger/Query.hs view
@@ -67,7 +67,8 @@   matchesMixedAmount,   matchesAmount,   matchesCommodity,-  matchesTags,+  matchesTag,+  -- patternsMatchTags,   matchesPriceDirective,   words'',   queryprefixes,@@ -76,7 +77,7 @@ ) where -import Control.Applicative ((<|>), many, optional)+import Control.Applicative import Data.Default (Default(..)) import Data.Either (partitionEithers) import Data.List (partition, intercalate)@@ -126,7 +127,7 @@   -- compound queries for transactions (any:, all:)   -- If used in a non transaction-matching context, these are equivalent to And.   | AnyPosting  [Query]       -- ^ match if any one posting is matched by all of these-  | AllPostings [Query]       -- ^ match if all postings are matched by all of these+  | AllPostings [Query]       -- ^ match if all of one or more postings are matched by all of these   deriving (Eq,Show)  instance Default Query where def = Any@@ -510,7 +511,7 @@     ([],[])   -> Left help     ([],ts)   -> Right $ Type ts   where-    help = "type:'s argument should be one or more of " ++ accountTypeChoices False ++ " (case insensitive)."+    help = "type:'s argument should be one or more of " ++ accountTypeChoices False  accountTypeChoices :: Bool -> String accountTypeChoices allowlongform = @@ -813,8 +814,14 @@ -- matching things with queries  matchesCommodity :: Query -> CommoditySymbol -> Bool-matchesCommodity (Sym r) = regexMatchText r-matchesCommodity _ = const True+matchesCommodity (Sym r)          s = regexMatchText r s+matchesCommodity (Any)            _ = True+matchesCommodity (None)           _ = False+matchesCommodity (Or qs)          s = any (`matchesCommodity` s) qs+matchesCommodity (And qs)         s = all (`matchesCommodity` s) qs+matchesCommodity (AnyPosting qs)  s = all (`matchesCommodity` s) qs+matchesCommodity (AllPostings qs) s = all1 (`matchesCommodity` s) qs+matchesCommodity _                _ = False  -- | Does the match expression match this (simple) amount ? matchesAmount :: Query -> Amount -> Bool@@ -824,7 +831,7 @@ matchesAmount (Or qs) a = any (`matchesAmount` a) qs matchesAmount (And qs) a = all (`matchesAmount` a) qs matchesAmount (AnyPosting  qs) a = all (`matchesAmount` a) qs-matchesAmount (AllPostings qs) a = all (`matchesAmount` a) qs+matchesAmount (AllPostings qs) a = all1 (`matchesAmount` a) qs matchesAmount (Amt ord n) a = compareAmount ord n a matchesAmount (Sym r) a = matchesCommodity (Sym r) (acommodity a) matchesAmount _ _ = True@@ -855,7 +862,7 @@ matchesAccount (Or ms) a = any (`matchesAccount` a) ms matchesAccount (And ms) a = all (`matchesAccount` a) ms matchesAccount (AnyPosting  qs) a = all (`matchesAccount` a) qs-matchesAccount (AllPostings qs) a = all (`matchesAccount` a) qs+matchesAccount (AllPostings qs) a = all1 (`matchesAccount` a) qs matchesAccount (Acct r) a = regexMatchText r a matchesAccount (Depth d) a = accountNameLevel a <= d matchesAccount (DepthAcct r d) a = accountNameLevel a <= d || not (regexMatchText r a)@@ -875,9 +882,9 @@ matchesAccountExtra atypes atags (Or  qs ) a = any (\q -> matchesAccountExtra atypes atags q a) qs matchesAccountExtra atypes atags (And qs ) a = all (\q -> matchesAccountExtra atypes atags q a) qs matchesAccountExtra atypes atags (AnyPosting  qs ) a = all (\q -> matchesAccountExtra atypes atags q a) qs-matchesAccountExtra atypes atags (AllPostings qs ) a = all (\q -> matchesAccountExtra atypes atags q a) qs+matchesAccountExtra atypes atags (AllPostings qs ) a = all1 (\q -> matchesAccountExtra atypes atags q a) qs matchesAccountExtra atypes _     (Type ts) a = maybe False (\t -> any (t `isAccountSubtypeOf`) ts) $ atypes a-matchesAccountExtra _      atags (Tag npat vpat) a = matchesTags npat vpat $ atags a+matchesAccountExtra _      atags (Tag npat vpat) a = patternsMatchTags npat vpat $ atags a matchesAccountExtra _      _     q         a = matchesAccount q a  -- | Does the match expression match this posting ?@@ -890,7 +897,7 @@ matchesPosting (Or qs) p = any (`matchesPosting` p) qs matchesPosting (And qs) p = all (`matchesPosting` p) qs matchesPosting (AnyPosting  qs) p = all (`matchesPosting` p) qs-matchesPosting (AllPostings qs) p = all (`matchesPosting` p) qs+matchesPosting (AllPostings qs) p = all1 (`matchesPosting` p) qs matchesPosting (Code r) p = maybe False (regexMatchText r . tcode) $ ptransaction p matchesPosting (Desc r) p = maybe False (regexMatchText r . tdescription) $ ptransaction p matchesPosting (Acct r) p = matches p || maybe False matches (poriginal p) where matches = regexMatchText r . paccount@@ -905,7 +912,7 @@ matchesPosting (Tag n v) p = case (reString n, v) of   ("payee", Just v') -> maybe False (regexMatchText v' . transactionPayee) $ ptransaction p   ("note", Just v') -> maybe False (regexMatchText v' . transactionNote) $ ptransaction p-  (_, mv) -> matchesTags n mv $ postingAllTags p+  (_, mv) -> patternsMatchTags n mv $ postingAllTags p matchesPosting (Type _) _ = False  -- | Like matchesPosting, but if the posting's account's type is provided,@@ -916,7 +923,7 @@ matchesPostingExtra atype (Or  qs)  p = any (\q -> matchesPostingExtra atype q p) qs matchesPostingExtra atype (And qs)  p = all (\q -> matchesPostingExtra atype q p) qs matchesPostingExtra atype (AnyPosting  qs)  p = all (\q -> matchesPostingExtra atype q p) qs-matchesPostingExtra atype (AllPostings qs)  p = all (\q -> matchesPostingExtra atype q p) qs+matchesPostingExtra atype (AllPostings qs)  p = all1 (\q -> matchesPostingExtra atype q p) qs matchesPostingExtra atype (Type ts) p =   -- does posting's account's type, if we can detect it, match any of the given types ?   (maybe False (\t -> any (t `isAccountSubtypeOf`) ts) . atype $ paccount p)@@ -937,7 +944,7 @@ matchesTransaction (Or qs) t = any (`matchesTransaction` t) qs matchesTransaction (And qs) t = all (`matchesTransaction` t) qs matchesTransaction (AnyPosting  qs) t = any (\p -> all (`matchesPosting` p) qs) $ tpostings t-matchesTransaction (AllPostings qs) t = all (\p -> all (`matchesPosting` p) qs) $ tpostings t+matchesTransaction (AllPostings qs) t = all1 (\p -> all (`matchesPosting` p) qs) $ tpostings t matchesTransaction (Code r) t = regexMatchText r $ tcode t matchesTransaction (Desc r) t = regexMatchText r $ tdescription t matchesTransaction q@(Acct _) t = any (q `matchesPosting`) $ tpostings t@@ -952,7 +959,7 @@ matchesTransaction (Tag n v) t = case (reString n, v) of   ("payee", Just v') -> regexMatchText v' $ transactionPayee t   ("note", Just v') -> regexMatchText v' $ transactionNote t-  (_, v') -> matchesTags n v' $ transactionAllTags t+  (_, v') -> patternsMatchTags n v' $ transactionAllTags t matchesTransaction (Type _) _ = False  -- | Like matchesTransaction, but if the journal's account types are provided,@@ -963,12 +970,12 @@ matchesTransactionExtra atype (Or  qs) t = any (\q -> matchesTransactionExtra atype q t) qs matchesTransactionExtra atype (And qs) t = all (\q -> matchesTransactionExtra atype q t) qs matchesTransactionExtra atype (AnyPosting  qs) t = any (\p -> all (\q -> matchesPostingExtra atype q p) qs) $ tpostings t-matchesTransactionExtra atype (AllPostings qs) t = all (\p -> all (\q -> matchesPostingExtra atype q p) qs) $ tpostings t+matchesTransactionExtra atype (AllPostings qs) t = all1 (\p -> all (\q -> matchesPostingExtra atype q p) qs) $ tpostings t matchesTransactionExtra atype q@(Type _) t = any (matchesPostingExtra atype q) $ tpostings t matchesTransactionExtra _ q t = matchesTransaction q t  -- | Does the query match this transaction description ?--- Tests desc: terms, any other terms are ignored.+-- Non-desc: query terms are ignored (this might disrupt some boolean queries). matchesDescription :: Query -> Text -> Bool matchesDescription (Not q) d          = not $ q `matchesDescription` d matchesDescription (Any) _            = True@@ -976,7 +983,7 @@ matchesDescription (Or qs) d          = any (`matchesDescription` d) $ filter queryIsDesc qs matchesDescription (And qs) d         = all (`matchesDescription` d) $ filter queryIsDesc qs matchesDescription (AnyPosting  qs) d = all (`matchesDescription` d) $ filter queryIsDesc qs-matchesDescription (AllPostings qs) d = all (`matchesDescription` d) $ filter queryIsDesc qs+matchesDescription (AllPostings qs) d = all1 (`matchesDescription` d) $ filter queryIsDesc qs matchesDescription (Code _) _         = False matchesDescription (Desc r) d         = regexMatchText r d matchesDescription _ _                = False@@ -988,12 +995,25 @@ matchesPayeeWIP :: Query -> Payee -> Bool matchesPayeeWIP = matchesDescription --- | Does the query match the name and optionally the value of any of these tags ?-matchesTags :: Regexp -> Maybe Regexp -> [Tag] -> Bool-matchesTags namepat valuepat = any (matches namepat valuepat)+-- | Do this name regex and optional value regex match the name and value of any of these tags ?+patternsMatchTags :: Regexp -> Maybe Regexp -> [Tag] -> Bool+patternsMatchTags namepat valuepat = any (matches namepat valuepat)   where     matches npat vpat (n,v) = regexMatchText npat n && maybe (const True) regexMatchText vpat v +-- | Does the query match the name and optionally the value of this tag ?+-- Non-tag: query terms are ignored (this might disrupt some boolean queries).+matchesTag :: Query -> Tag -> Bool+matchesTag (Not q)          t = not $ q `matchesTag` t+matchesTag (Any)            _ = True+matchesTag (None)           _ = False+matchesTag (Or qs)          t = any (`matchesTag` t) $ filter queryIsTag qs+matchesTag (And qs)         t = all (`matchesTag` t) $ filter queryIsTag qs+matchesTag (AnyPosting qs)  t = all (`matchesTag` t) $ filter queryIsTag qs+matchesTag (AllPostings qs) t = all1 (`matchesTag` t) $ filter queryIsTag qs+matchesTag (Tag npat mvpat) t = patternsMatchTags npat mvpat [t]+matchesTag _                _ = False+ -- | Does the query match this market price ? matchesPriceDirective :: Query -> PriceDirective -> Bool matchesPriceDirective (None) _           = False@@ -1001,7 +1021,7 @@ matchesPriceDirective (Or qs) p          = any (`matchesPriceDirective` p) qs matchesPriceDirective (And qs) p         = all (`matchesPriceDirective` p) qs matchesPriceDirective (AnyPosting  qs) p = all (`matchesPriceDirective` p) qs-matchesPriceDirective (AllPostings qs) p = all (`matchesPriceDirective` p) qs+matchesPriceDirective (AllPostings qs) p = all1 (`matchesPriceDirective` p) qs matchesPriceDirective q@(Amt _ _) p      = matchesAmount q (pdamount p) matchesPriceDirective q@(Sym _) p        = matchesCommodity q (pdcommodity p) matchesPriceDirective (Date spn) p       = spanContainsDate spn (pddate p)
Hledger/Read.hs view
@@ -87,7 +87,6 @@ module Hledger.Read (    -- * Journal files-  PrefixedFilePath,   defaultJournal,   defaultJournalWith,   defaultJournalSafely,@@ -227,10 +226,6 @@         home <- fromMaybe "" <$> getHomeSafe         return $ home </> journalDefaultFilename --- | A file path optionally prefixed by a reader name and colon--- (journal:, csv:, timedot:, etc.).-type PrefixedFilePath = FilePath- -- | @readJournal iopts mfile txt@ -- -- Read a Journal from some handle, with strict checks if enabled,@@ -337,9 +332,9 @@   when (new_ && new_save_) $ liftIO $ saveLatestDatesForFiles latestdatesforfiles   return j --- The implementation of readJournalFiles, but with --new, --- also returns the latest transaction date(s) read in each file.--- Used by the import command, to save those at the end.+-- The implementation of readJournalFiles.+-- With --new, it also returns the latest transaction date(s) read in each file+-- (used by the import command). readJournalFilesAndLatestDates :: InputOpts -> [PrefixedFilePath] -> ExceptT String IO (Journal, [LatestDatesForFile]) readJournalFilesAndLatestDates iopts pfs = do   (js, lastdates) <- unzip <$> mapM (readJournalFileAndLatestDates iopts) pfs@@ -413,6 +408,7 @@  -- The path of an input file, and its current "LatestDates". data LatestDatesForFile = LatestDatesForFile FilePath LatestDates+  deriving Show  -- | Get all instances of the latest date in an unsorted list of dates. -- Ie, if the latest date appears once, return it in a one-element list,@@ -423,8 +419,10 @@  -- | Save the given latest date(s) seen in the given data FILE, -- in a hidden file named .latest.FILE, creating it if needed.+-- Unless no latest dates are provided, in which case do nothing. saveLatestDates :: LatestDates -> FilePath -> IO ()-saveLatestDates dates f = T.writeFile (latestDatesFileFor f) $ T.unlines $ map showDate dates+saveLatestDates dates f = when (not $ null dates) $+  T.writeFile (latestDatesFileFor f) $ T.unlines $ map showDate dates  -- | Save each file's latest dates. saveLatestDatesForFiles :: [LatestDatesForFile] -> IO ()
Hledger/Read/Common.hs view
@@ -30,6 +30,8 @@ --- ** exports module Hledger.Read.Common (   Reader (..),+  PrefixedFilePath,+  isStdin,   InputOpts(..),   HasInputOpts(..),   definputopts,@@ -72,6 +74,7 @@   -- ** account names   modifiedaccountnamep,   accountnamep,+  accountnamenosemicolonp,    -- ** account aliases   accountaliasp,@@ -160,6 +163,7 @@ import Hledger.Utils import Hledger.Read.InputOptions + --- ** doctest setup -- $setup -- >>> :set -XOverloadedStrings@@ -168,29 +172,38 @@  -- 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.+-- | A hledger journal reader is a storage format name,+-- a list of file extensions assumed to be in this format,+-- and an IO action that reads data in this format, returning a Journal.+--+-- The journal parser used by the latter is also stored separately for direct use+-- by the journal reader's includedirectivep to parse included files.+-- The type variable m is needed for this parser.+-- Lately it requires an InputOpts, basically to support --old-timeclock. 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 canonical name of the format handled by this reader. "journal", "timedot", "csv" etc.+   rFormat :: StorageFormat+    -- The file extensions recognised as containing this format.+  ,rExtensions :: [String]+    -- An IO action for reading this format, producing a journal or an error message.+    -- It accepts input options, a file path to show in error messages, and a handle to read data from.+  ,rReadFn :: InputOpts -> FilePath -> Handle -> ExceptT String IO Journal+    -- The megaparsec parser called by the above, provided separately for parsing included files.+  ,rParser :: MonadIO m => InputOpts -> ErroringJournalParser m ParsedJournal+  } -     -- The entry point for reading this format, accepting input options, file-     -- path for error messages and file contents via the handle, producing an exception-raising IO-     -- action that produces a journal or error message.-    ,rReadFn   :: InputOpts -> FilePath -> Handle -> ExceptT String IO Journal+instance Show (Reader m) where show r = show (rFormat r) ++ " reader" -     -- The actual megaparsec parser called by the above, in case-     -- another parser (includedirectivep) wants to use it directly.-    ,rParser :: MonadIO m => ErroringJournalParser m ParsedJournal-    }+-- | A file path optionally prefixed by a reader name and colon (journal:, csv:, timedot:, etc.).+-- The file path part can also be - meaning standard input.+type PrefixedFilePath = FilePath -instance Show (Reader m) where show r = show (rFormat r) ++ " reader"+-- | Is this the special file path meaning standard input ? (-, possibly prefixed)+isStdin :: PrefixedFilePath -> Bool+isStdin f = case splitAtElement ':' f of+  [_,"-"] -> True+  ["-"] -> True+  _ -> False  -- | Parse an InputOpts from a RawOpts and a provided date. -- This will fail with a usage error if the forecast period expression cannot be parsed.@@ -214,8 +227,11 @@         argsquery = map fst . rights . map (parseQueryTerm day) $ querystring_ ropts         datequery = simplifyQuery . filterQuery queryIsDate . And $ queryFromFlags ropts : argsquery +        txnbalancingprecision = either err id $ transactionBalancingPrecisionFromOpts rawopts+          where err e = error' $ "could not parse --txn-balancing: '" ++ e ++ "'"  -- PARTIAL:+         styles = either err id $ commodityStyleFromRawOpts rawopts-          where err e = error' $ "could not parse commodity-style: '" ++ e ++ "'"  -- PARTIAL:+          where err e = error' $ "could not parse --commodity-style: '" ++ e ++ "'"  -- PARTIAL:      in definputopts{        -- files_             = listofstringopt "file" rawopts@@ -236,6 +252,7 @@       ,balancingopts_     = defbalancingopts{                                  ignore_assertions_     = boolopt "ignore-assertions" rawopts                                , infer_balancing_costs_ = not noinferbalancingcosts+                               , txn_balancing_         = txnbalancingprecision                                , commodity_styles_      = Just styles                                }       ,strict_            = boolopt "strict" rawopts@@ -262,18 +279,26 @@         _          -> usageError $ "--forecast's argument should not contain a report interval ("                                  ++ show interval ++ " in \"" ++ arg ++ "\")" --- | Given the name of the option and the raw options, returns either--- | * a map of successfully parsed commodity styles, if all options where successfully parsed--- | * the first option which failed to parse, if one or more options failed to parse+-- | Given the raw options, return either+-- * if all options were successfully parsed: a map of successfully parsed commodity styles,+-- * if one or more options failed to parse: the first option which failed to parse commodityStyleFromRawOpts :: RawOpts -> Either String (M.Map CommoditySymbol AmountStyle) commodityStyleFromRawOpts rawOpts =-    foldM (\r -> fmap (\(c,a) -> M.insert c a r) . parseCommodity) mempty optList+  foldM (\r -> fmap (\(c,a) -> M.insert c a r) . parseCommodity) mempty optList   where     optList = listofstringopt "commodity-style" rawOpts     parseCommodity optStr = case parseamount optStr of-        Left _ -> Left optStr-        Right (Amount acommodity _ astyle _) -> Right (acommodity, astyle)+      Left _ -> Left optStr+      Right (Amount acommodity _ astyle _) -> Right (acommodity, astyle) +transactionBalancingPrecisionFromOpts :: RawOpts -> Either String TransactionBalancingPrecision+transactionBalancingPrecisionFromOpts rawopts =+  case maybestringopt "txn-balancing" rawopts of+    Nothing      -> Right TBPExact+    Just "old"   -> Right TBPOld+    Just "exact" -> Right TBPExact+    Just s       -> Left $ s<>", should be one of: old, exact"+ -- | 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.@@ -285,7 +310,7 @@ -- | Given a parser to ParsedJournal, input options, file path and -- content: run the parser on the content. This is all steps of -- 'parseAndFinaliseJournal' without the finalisation step, and is used when--- you need to perform other actions before finalisation, as in parsing+-- you need to perform other actions before finalisatison, as in parsing -- Timeclock and Timedot files. initialiseAndParseJournal :: ErroringJournalParser IO ParsedJournal -> InputOpts                           -> FilePath -> Text -> ExceptT String IO Journal@@ -375,16 +400,16 @@       -- XXX how to force debug output here ?        -- >>= Right . dbg0With (concatMap (T.unpack.showTransaction).jtxns)        -- >>= \j -> deepseq (concatMap (T.unpack.showTransaction).jtxns $ j) (return j)-      <&> dbg9With (lbl "amounts after styling, forecasting, auto-posting".showJournalAmountsDebug)+      <&> dbg9With (lbl "amounts after styling, forecasting, auto-posting".showJournalPostingAmountsDebug)       >>= (\j -> if checkordereddates then journalCheckOrdereddates j $> j else Right j)  -- check ordereddates before assertions. The outer parentheses are needed.       >>= journalBalanceTransactions balancingopts_{ignore_assertions_=not checkassertions}  -- infer balance assignments and missing amounts, and maybe check balance assertions.-      <&> dbg9With (lbl "amounts after transaction-balancing".showJournalAmountsDebug)-      -- <&> dbg9With (("journalFinalise amounts after styling, forecasting, auto postings, transaction balancing"<>).showJournalAmountsDebug)+      <&> dbg9With (lbl "amounts after transaction-balancing".showJournalPostingAmountsDebug)+      -- <&> dbg9With (("journalFinalise amounts after styling, forecasting, auto postings, transaction balancing"<>).showJournalPostingAmountsDebug)       >>= journalInferCommodityStyles                    -- infer commodity styles once more now that all posting amounts are present       -- >>= Right . dbg0With (pshow.journalCommodityStyles)       >>= (if infer_costs_  then journalTagCostsAndEquityAndMaybeInferCosts verbose_tags_ True else pure)  -- With --infer-costs, infer costs from equity postings where possible       <&> (if infer_equity_ then journalInferEquityFromCosts verbose_tags_ else id)          -- With --infer-equity, infer equity postings from costs where possible-      <&> dbg9With (lbl "amounts after equity-inferring".showJournalAmountsDebug)+      <&> dbg9With (lbl "amounts after equity-inferring".showJournalPostingAmountsDebug)       <&> journalInferMarketPricesFromTransactions       -- infer market prices from commodity-exchanging transactions       -- <&> dbg6Msg fname  -- debug logging       <&> dbgJournalAcctDeclOrder (fname <> ": acct decls           : ")@@ -665,16 +690,18 @@  --- *** account names --- | Parse an account name (plus one following space if present),+-- | Parse an account name plus one following space if present (see accountnamep); -- 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).+-- 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+-- The flag says whether account names may include semicolons; currently account names+-- in journal format may, but account names in timeclock/timedot formats may not.+modifiedaccountnamep :: Bool -> JournalParser m AccountName+modifiedaccountnamep allowsemicolon = do   parent  <- getParentAccount   als     <- getAccountAliases   -- off1    <- getOffset-  a       <- lift accountnamep+  a       <- lift $ if allowsemicolon then accountnamep else accountnamenosemicolonp   -- off2    <- getOffset   -- XXX or accountNameApplyAliasesMemo ? doesn't seem to make a difference (retest that function)   case accountNameApplyAliases als $ joinAccountNames parent a of@@ -689,15 +716,17 @@ -- | 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.+-- Each part is at least one character long, may have single spaces inside it, and starts with a non-whitespace.+-- (We should have required them to start with an alphanumeric, but didn't.)+-- Note, this means account names can contain all kinds of punctuation, including ; which usually starts a following comment.+-- Parent parsers usually remove the following comment before using this parser. accountnamep :: TextParser m AccountName accountnamep = singlespacedtext1p +-- Like accountnamep, but stops parsing if it reaches a semicolon.+accountnamenosemicolonp :: TextParser m AccountName+accountnamenosemicolonp = singlespacednoncommenttext1p+ -- | Parse a single line of possibly empty text enclosed in double quotes. doublequotedtextp :: TextParser m Text doublequotedtextp = between (char '"') (char '"') $@@ -1348,10 +1377,10 @@ -- using the provided line parser to parse each line. -- This returns the comment text, and the combined results from the line parser. ----- Following comments begin with a semicolon and extend to the end of the line.--- They can optionally be continued on the next lines,--- where each next line begins with an indent and another semicolon.--- (This parser expects to see these semicolons and indents.)+-- Following comments are a 1-or-more-lines comment,+-- beginning with a semicolon possibly preceded by whitespace on the current line,+-- or with an indented semicolon on the next line.+-- Additional lines also must begin with an indented semicolon. -- -- Like Ledger, we sometimes allow data to be embedded in comments. -- account directive comments and transaction comments can contain tags,@@ -1415,10 +1444,11 @@  -- | 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.+-- The first line of a transaction may be followed a 1-or-more-lines comment,+-- beginning with a semicolon possibly preceded by whitespace on the current line,+-- or with an indented semicolon on the next line. Additional lines also must+-- begin with an indented semicolon.+-- See also followingcommentpWith. -- -- 2000/1/1 ; a transaction comment starting on the same line ... --   ; extending to the next line
Hledger/Read/CsvReader.hs view
@@ -46,13 +46,17 @@   {rFormat     = Sep sep   ,rExtensions = [show sep]   ,rReadFn     = parse sep-  ,rParser     = error' "sorry, CSV files can't be included yet"  -- PARTIAL:+  ,rParser     = const $ fail "sorry, CSV files can't be included yet"+    -- This unnecessarily shows the CSV file's first line in the error message,+    -- but gives a more useful message than just calling error'.+    -- XXX Note every call to error' in Hledger.Read.* is potentially a similar problem -+    -- the error message is good enough when the file was specified directly by the user,+    -- but not good if it was loaded by a possibly long chain of include directives.   } --- | Parse and post-process a "Journal" from CSV data, or give an error.--- This currently ignores the provided data, and reads it from the file path instead.--- This file path is normally the CSV(/SSV/TSV) data file, and a corresponding rules file is inferred.--- But it can also be the rules file, in which case the corresponding data file is inferred.+-- | Parse and post-process a "Journal" from a CSV(/SSV/TSV/*SV) data file, or give an error.+-- This currently ignores the provided input file handle, and reads from the data file itself,+-- inferring a corresponding rules file to help convert it. -- This does not check balance assertions. parse :: SepFormat -> InputOpts -> FilePath -> Handle -> ExceptT String IO Journal parse sep iopts f h = do@@ -71,4 +75,3 @@  tests_CsvReader = testGroup "CsvReader" [   ]-
Hledger/Read/JournalReader.hs view
@@ -71,9 +71,8 @@ where  --- ** imports-import qualified Control.Monad.Fail as Fail (fail) import qualified Control.Exception as C-import Control.Monad (forM_, when, void, unless)+import Control.Monad (forM_, when, void, unless, filterM) import Control.Monad.IO.Class (MonadIO, liftIO) import Control.Monad.Except (ExceptT(..), runExceptT) import Control.Monad.State.Strict (evalStateT,get,modify',put)@@ -94,6 +93,7 @@ import Text.Printf import System.FilePath import "Glob" System.FilePath.Glob hiding (match)+-- import "filepattern" System.FilePattern.Directory  import Hledger.Data import Hledger.Read.Common@@ -103,7 +103,8 @@ import qualified Hledger.Read.RulesReader as RulesReader (reader) import qualified Hledger.Read.TimeclockReader as TimeclockReader (reader) import qualified Hledger.Read.TimedotReader as TimedotReader (reader)-import System.Directory (canonicalizePath)+import System.Directory (canonicalizePath, doesFileExist)+import Data.Functor ((<&>))  --- ** doctest setup -- $setup@@ -166,28 +167,40 @@     (prefix,path') = splitReaderPrefix path     ext            = map toLower $ drop 1 $ takeExtension path' --- | A file path optionally prefixed by a reader name and colon--- (journal:, csv:, timedot:, etc.).-type PrefixedFilePath = FilePath---- | If a filepath is prefixed by one of the reader names and a colon,--- split that off. Eg "csv:-" -> (Just "csv", "-").--- These reader prefixes can be used to force a specific reader,--- overriding the file extension. +-- | Separate a file path and its reader prefix, if any.+--+-- >>> splitReaderPrefix "csv:-"+-- (Just csv,"-") splitReaderPrefix :: PrefixedFilePath -> (Maybe StorageFormat, FilePath) splitReaderPrefix f =   let -  candidates = [(Just r, drop (length r + 1) f) | r <- readerNames ++ ["ssv","tsv"], (r++":") `isPrefixOf` f]-  (strPrefix, newF) = headDef (Nothing, f) candidates+    candidates = [(Just r, drop (length r + 1) f) | r <- readerNames ++ ["ssv","tsv"], (r++":") `isPrefixOf` f]+    (strPrefix, newF) = headDef (Nothing, f) candidates   in case strPrefix of-  Just "csv" -> (Just (Sep Csv), newF)-  Just "tsv" -> (Just (Sep Tsv), newF)-  Just "ssv" -> (Just (Sep Ssv), newF)-  Just "journal" -> (Just Journal', newF)-  Just "timeclock" -> (Just Timeclock, newF)-  Just "timedot" -> (Just Timedot, newF)-  _ -> (Nothing, f)+    Just "csv" -> (Just (Sep Csv), newF)+    Just "tsv" -> (Just (Sep Tsv), newF)+    Just "ssv" -> (Just (Sep Ssv), newF)+    Just "journal" -> (Just Journal', newF)+    Just "timeclock" -> (Just Timeclock, newF)+    Just "timedot" -> (Just Timedot, newF)+    _ -> (Nothing, f) +-- -- | Does this file path have a reader prefix ?+-- hasReaderPrefix :: PrefixedFilePath -> Bool+-- hasReaderPrefix = isJust . fst. splitReaderPrefix++-- -- | Add a reader prefix to a file path, unless it already has one.+-- -- The argument should be a valid reader name.+-- --+-- -- >>> addReaderPrefix "csv" "a.txt"+-- -- >>> "csv:a.txt"+-- -- >>> addReaderPrefix "csv" "timedot:a.txt"+-- -- >>> "timedot:a.txt"+-- addReaderPrefix :: ReaderPrefix -> FilePath -> PrefixedFilePath+-- addReaderPrefix readername f+--   | hasReaderPrefix f = f+--   | otherwise = readername <> ":" <> f+ --- ** reader  reader :: MonadIO m => Reader m@@ -207,7 +220,7 @@     journalp' = do       -- reverse parsed aliases to ensure that they are applied in order given on commandline       mapM_ addAccountAlias (reverse $ aliasesFromOpts iopts)-      journalp+      journalp iopts  --- ** parsers --- *** journal@@ -215,23 +228,23 @@ -- | A journal parser. Accumulates and returns a "ParsedJournal", -- which should be finalised/validated before use. ----- >>> rejp (journalp <* eof) "2015/1/1\n a  0\n"+-- >>> rejp (journalp definputopts <* eof) "2015/1/1\n a  0\n" -- Right (Right Journal (unknown) with 1 transactions, 1 accounts) ---journalp :: MonadIO m => ErroringJournalParser m ParsedJournal-journalp = do-  many addJournalItemP+journalp :: MonadIO m => InputOpts -> ErroringJournalParser m ParsedJournal+journalp iopts = do+  many $ addJournalItemP iopts   eof   get  -- | A side-effecting parser; parses any kind of journal item -- and updates the parse state accordingly.-addJournalItemP :: MonadIO m => ErroringJournalParser m ()-addJournalItemP =+addJournalItemP :: MonadIO m => InputOpts -> ErroringJournalParser m ()+addJournalItemP iopts =   -- all journal line types can be distinguished by the first   -- character, can use choice without backtracking   choice [-      directivep+      directivep iopts     , transactionp          >>= modify' . addTransaction     , transactionmodifierp  >>= modify' . addTransactionModifier     , periodictransactionp  >>= modify' . addPeriodicTransaction@@ -245,11 +258,11 @@ -- | Parse any journal directive and update the parse state accordingly. -- Cf http://hledger.org/hledger.html#directives, -- http://ledger-cli.org/3.0/doc/ledger3.html#Command-Directives-directivep :: MonadIO m => ErroringJournalParser m ()-directivep = (do+directivep :: MonadIO m => InputOpts -> ErroringJournalParser m ()+directivep iopts = (do   optional $ oneOf ['!','@']   choice [-    includedirectivep+    includedirectivep iopts    ,aliasdirectivep    ,endaliasesdirectivep    ,accountdirectivep@@ -283,82 +296,174 @@   ) <?> "directive"  -- | Parse an include directive, and the file(s) it refers to, possibly recursively.--- include's argument is a file path or glob pattern, optionally with a file type prefix.--- ~ at the start is expanded to the user's home directory.--- Relative paths are relative to the current file.--- Examples: foo.j, ../foo/bar.j, timedot:/foo/2020*, *.journal-includedirectivep :: MonadIO m => ErroringJournalParser m ()-includedirectivep = do-  -- parse+-- Input options are required since they may affect parsing (of timeclock files, specifically).+-- include's argument is a file path or glob pattern (see findMatchedFiles for details),+-- optionally with a file type prefix. Relative paths are relative to the current file.+includedirectivep :: MonadIO m => InputOpts -> ErroringJournalParser m ()+includedirectivep iopts = do+  -- save the position at start of include directive, for error messages+  eoff <- getOffset+  -- and the parent file's path, for error messages and debug output+  parentf <- getSourcePos >>= sourcePosFilePath++  -- parse the directive   string "include"   lift skipNonNewlineSpaces1   prefixedglob <- rstrip . T.unpack <$> takeWhileP Nothing (`notElem` [';','\n'])   lift followingcommentp-  -- save the position (does sequencing wrt newline matter ? seems not)-  parentoff <- getOffset-  parentpos <- getSourcePos-  -- find file(s)   let (mprefix,glb) = splitReaderPrefix prefixedglob-  paths <- getFilePaths parentoff parentpos glb+  when (null $ dbg6 (parentf <> " include: glob pattern") glb) $+    customFailure $ parseErrorAt eoff $ "include needs a file path or glob pattern argument"++  -- Find the file or glob-matched files (just the ones from this include directive), with some IO error checking.+  -- Also report whether a glob pattern was used, and not just a literal file path.+  -- (paths, isglob) <- findMatchedFiles off pos glb+  paths <- findMatchedFiles eoff parentf glb++  -- XXX worth the trouble ? no+  -- Comprehensively exclude files already processed. Some complexities here:+  -- If this include directive uses a glob pattern, remove duplicates. +  -- Ie if this glob pattern matches any files we have already processed (or the current file),+  -- due to multiple includes in sequence or in a cycle, exclude those files so they're not processed again.+  -- If this include directive uses a literal file path, don't remove duplicates.+  -- Multiple includes in sequence will cause the included file to be processed multiple times.+  -- Multiple includes forming a cycle will be detected and reported as an error in parseIncludedFile.+  -- let paths' = if isglob then filter (...) paths else paths++  -- if there was a reader prefix, apply it to all the file paths   let prefixedpaths = case mprefix of         Nothing  -> paths         Just fmt -> map ((show fmt++":")++) paths-  -- parse them inline-  forM_ prefixedpaths $ parseChild parentpos +  -- Parse each one, as if inlined here.+  forM_ prefixedpaths $ parseIncludedFile iopts eoff+   where-    getFilePaths-      :: MonadIO m => Int -> SourcePos -> FilePath -> JournalParser m [FilePath]-    getFilePaths parseroff parserpos fileglobpattern = do-        -- Expand a ~ at the start of the glob pattern, if any.-        fileglobpattern' <- lift $ expandHomePath fileglobpattern-                         `orRethrowIOError` (show parserpos ++ " locating " ++ fileglobpattern)-        -- Compile the glob pattern.-        fileglob <- case tryCompileWith compDefault{errorRecovery=False} fileglobpattern' of-            Right x -> pure x-            Left e -> customFailure $ parseErrorAt parseroff $ "Invalid glob pattern: " ++ e-        -- Get the directory of the including file. This will be used to resolve relative paths.-        let parentfilepath = sourceName parserpos-        realparentfilepath <- liftIO $ canonicalizePath parentfilepath   -- Follow a symlink. If the path is already absolute, the operation never fails. -        let curdir = takeDirectory realparentfilepath-        -- Find all matched files, in lexicographic order mimicking the output of 'ls'.-        filepaths <- liftIO $ sort <$> globDir1 fileglob curdir-        if (not . null) filepaths-            then pure filepaths-            else customFailure $ parseErrorAt parseroff $-                   "No existing files match pattern: " ++ fileglobpattern -    parseChild :: MonadIO m => SourcePos -> PrefixedFilePath -> ErroringJournalParser m ()-    parseChild parentpos prefixedpath = do+    -- | Find the files matched by a literal path or a glob pattern.+    -- Examples: foo.j, ../foo/bar.j, timedot:/foo/2020*, *.journal, **.journal.+    --+    -- Uses the current parse context for detecting the current directory and for error messages.+    -- Expands a leading tilde to the user's home directory.+    -- Converts ** without a slash to **/*, like zsh's GLOB_STAR_SHORT, so ** also matches file name parts.+    -- Checks if any matched paths are directories and excludes those.+    -- Converts all matched paths to their canonical form.+    --+    -- Glob patterns never match dot files or files under dot directories,+    -- even if it seems like they should; this is a workaround for Glob bug #49.+    -- This workaround is disabled if the --old-glob flag is present in the command line+    -- (detected with unsafePerformIO; it's not worth a ton of boilerplate).+    -- In that case, be aware ** recursive globs will search intermediate dot directories.++    findMatchedFiles :: (MonadIO m) => Int -> FilePath -> FilePath -> JournalParser m [FilePath]+    findMatchedFiles off parentf globpattern = do++      -- Some notes about the Glob library that we use (related: https://github.com/Deewiant/glob/issues/49):+      -- It does not expand tilde.+      -- It does not canonicalise paths.+      -- The results are not in any particular order.+      -- The results can include directories.+      -- DIRPAT/ is equivalent to DIRPAT, except results will end with // (double slash).+      -- A . or .. path component can match the current or parent directories (including them in the results).+      -- * matches zero or more characters in a file or directory name.+      -- * at the start of a file name ignores dot-named files and directories, by default.+      -- ** (or zero or more consecutive *'s) not followed by slash is equivalent to *.+      -- A **/ component matches any number of directory parts.+      -- A **/ ignores dot-named directories in its starting and ending directories, by default.+      -- But **/ does search intermediate dot-named directories. Eg it can find a/.b/c.++      -- expand a tilde at the start of the glob pattern, or throw an error+      expandedglob <- lift $ expandHomePath globpattern `orRethrowIOError` "failed to expand ~"++      -- get the directory of the including file+      let cwd = takeDirectory parentf++      -- Don't allow 3 or more stars.+      when ("***" `isInfixOf` expandedglob) $+        customFailure $ parseErrorAt off $ "Invalid glob pattern: too many stars, use * or **"++      -- Make ** also match file name parts like zsh's GLOB_STAR_SHORT.+      let+        expandedglob' =+          -- ** without a slash is equivalent to **/*+          case regexReplace (toRegex' $ T.pack "\\*\\*([^/\\])") "**/*\\1" expandedglob of+            Right s -> s+            Left  _ -> expandedglob   -- ignore any error, there should be none++      -- Compile as a Pattern. Can throw an error.+      g <- case tryCompileWith compDefault{errorRecovery=False} expandedglob' of+        Left e  -> customFailure $ parseErrorAt off $ "Invalid glob pattern: " ++ e+        Right x -> pure x+      let isglob = not $ isLiteral g++      -- Find all matched paths. These might include directories or the current file.+      paths <- liftIO $ globDir1 g cwd++      -- Exclude any directories or symlinks to directories, and canonicalise, and sort.+      files <- liftIO $+        filterM doesFileExist paths+        >>= mapM canonicalizePath+        <&> sort++      -- Work around a Glob bug with dot dirs: while **/ ignores dot dirs in the starting and ending dirs,+      -- it does search dot dirs in between those two (Glob #49).+      -- This could be inconvenient, eg making it hard to avoid VCS directories in a source tree.+      -- We work around as follows: when any glob was used, paths involving dot dirs are excluded in post processing.+      -- Unfortunately this means valid globs like .dotdir/* can't be used; only literal paths can match+      -- things in dot dirs. An --old-glob command line flag disables this workaround, for backward compatibility.+      oldglobflag <- liftIO $ getFlag ["old-glob"]+      let+        files2 = (if isglob && not oldglobflag then filter (not.hasdotdir) else id) files+          where+            hasdotdir p = any isdotdir $ splitPath p+              where+                isdotdir c = "." `isPrefixOf` c && "/" `isSuffixOf` c++      -- Throw an error if no files were matched.+      when (null files2) $+        customFailure $ parseErrorAt off $ "No files were matched by glob pattern: " ++ globpattern++      -- If a glob was used, exclude the current file, for convenience.+      let+        files3 =+          dbg6 (parentf <> " include: matched files" <> if isglob then " (excluding current file)" else "") $+          (if isglob then filter (/= parentf) else id) files2++      return files3++    -- Parse the given included file (and any deeper includes, recursively) as if it was inlined in the current (parent) file.+    -- The offset of the start of the include directive in the parent file is provided for error messages.+    parseIncludedFile :: MonadIO m => InputOpts -> Int -> PrefixedFilePath -> ErroringJournalParser m ()+    parseIncludedFile iopts1 eoff prefixedpath = do       let (_mprefix,filepath) = splitReaderPrefix prefixedpath +      -- Throw an error if a cycle is detected       parentj <- get       let parentfilestack = jincludefilestack parentj-      when (filepath `elem` parentfilestack) $-        Fail.fail ("Cyclic include: " ++ filepath)+      when (dbg7 "parseIncludedFile: reading" filepath `elem` parentfilestack) $+        customFailure $ parseErrorAt eoff $ "This included file forms a cycle: " ++ filepath -      childInput <--        dbg6Msg ("parseChild: "++takeFileName filepath) $-        lift $ readFilePortably filepath-          `orRethrowIOError` (show parentpos ++ " reading " ++ filepath)+      -- Read the file's content, or throw an error+      childInput <- lift $ readFilePortably filepath `orRethrowIOError` "failed to read a file"       let initChildj = newJournalWithParseStateFrom filepath parentj -      -- Choose a reader/parser based on the file path prefix or file extension,+      -- Choose a reader based on the file path prefix or file extension,       -- defaulting to JournalReader. Duplicating readJournal a bit here.       let r = fromMaybe reader $ findReader Nothing (Just prefixedpath)-          parser = rParser r-      dbg6IO "parseChild: trying reader" (rFormat r)+          parser = (rParser r) iopts1+      dbg7IO "parseIncludedFile: trying reader" (rFormat r) -      -- Parse the file (of whichever format) to a Journal, with file path and source text attached.+      -- Parse the file (and its own includes, if any) to a Journal+      -- with file path and source text attached. Or throw an error.       updatedChildj <- journalAddFile (filepath, childInput) <$>                         parseIncludeFile parser initChildj filepath childInput -      -- Merge this child journal into the parent journal-      -- (with debug logging for troubleshooting account display order).+      -- Child journal was parsed successfully; now merge it into the parent journal.+      -- Debug logging is provided for troubleshooting account display order (eg).       -- The parent journal is the second argument to journalConcat; this means       -- its parse state is kept, and its lists are appended to child's (which       -- ultimately produces the right list order, because parent's and child's-      -- lists are in reverse order at this stage. Cf #1909).+      -- lists are in reverse order at this stage. Cf #1909)       let         parentj' =           dbgJournalAcctDeclOrder ("parseChild: child " <> childfilename <> " acct decls: ") updatedChildj@@ -369,22 +474,29 @@             childfilename = takeFileName filepath             parentfilename = maybe "(unknown)" takeFileName $ headMay $ jincludefilestack parentj  -- XXX more accurate than journalFilePath for some reason -      -- Update the parse state.+      -- And update the current parse state.       put parentj' -    newJournalWithParseStateFrom :: FilePath -> Journal -> Journal-    newJournalWithParseStateFrom filepath j = nulljournal{-      jparsedefaultyear      = jparsedefaultyear j-      ,jparsedefaultcommodity = jparsedefaultcommodity j-      ,jparseparentaccounts   = jparseparentaccounts j-      ,jparsedecimalmark      = jparsedecimalmark j-      ,jparsealiases          = jparsealiases j-      ,jdeclaredcommodities           = jdeclaredcommodities j-      -- ,jparsetransactioncount = jparsetransactioncount j-      ,jparsetimeclockentries = jparsetimeclockentries j-      ,jincludefilestack      = filepath : jincludefilestack j-      }+      where+        newJournalWithParseStateFrom :: FilePath -> Journal -> Journal+        newJournalWithParseStateFrom filepath j = nulljournal{+          jparsedefaultyear      = jparsedefaultyear j+          ,jparsedefaultcommodity = jparsedefaultcommodity j+          ,jparseparentaccounts   = jparseparentaccounts j+          ,jparsedecimalmark      = jparsedecimalmark j+          ,jparsealiases          = jparsealiases j+          ,jdeclaredcommodities           = jdeclaredcommodities j+          -- ,jparsetransactioncount = jparsetransactioncount j+          ,jparsetimeclockentries = jparsetimeclockentries j+          ,jincludefilestack      = filepath : jincludefilestack j+          } +-- Get the canonical path of the file referenced by this parse position.+-- Symbolic links will be dereferenced. This probably will always succeed+-- (since the parse file's path is probably always absolute).+sourcePosFilePath :: (MonadIO m) => SourcePos -> m FilePath+sourcePosFilePath = liftIO . canonicalizePath . sourceName+ -- | Lift an IO action into the exception monad, rethrowing any IO -- error with the given message prepended. orRethrowIOError :: MonadIO m => IO a -> String -> TextParser m a@@ -392,7 +504,7 @@   eResult <- liftIO $ (Right <$> io) `C.catch` \(e::C.IOException) -> pure $ Left $ printf "%s:\n%s" msg (show e)   case eResult of     Right res -> pure res-    Left errMsg -> Fail.fail errMsg+    Left errMsg -> fail errMsg  -- Parse an account directive, adding its info to the journal's -- list of account declarations.@@ -406,7 +518,7 @@    -- the account name, possibly modified by preceding alias or apply account directives   acct <- (notFollowedBy (char '(' <|> char '[') <?> "account name without brackets") >>-          modifiedaccountnamep+          modifiedaccountnamep True    -- maybe a comment, on this and/or following lines   (cmt, tags) <- lift transactioncommentp@@ -511,7 +623,7 @@     pure $ (off, amt)   lift skipNonNewlineSpaces   _ <- lift followingcommentp-  let comm = Commodity{csymbol=acommodity, cformat=Just $ dbg6 "style from commodity directive" astyle}+  let comm = Commodity{csymbol=acommodity, cformat=Just $ dbg7 "style from commodity directive" astyle}   if isNothing $ asdecimalmark astyle   then customFailure $ parseErrorAt off pleaseincludedecimalpoint   else modify' (\j -> j{jdeclaredcommodities=M.insert acommodity comm $ jdeclaredcommodities j})@@ -557,7 +669,7 @@     then       if isNothing $ asdecimalmark astyle       then customFailure $ parseErrorAt off pleaseincludedecimalpoint-      else return $ dbg6 "style from format subdirective" astyle+      else return $ dbg7 "style from format subdirective" astyle     else customFailure $ parseErrorAt off $          printf "commodity directive symbol \"%s\" and format directive symbol \"%s\" should be the same" expectedsym acommodity @@ -848,7 +960,7 @@       lift skipNonNewlineSpaces1       status <- lift statusp       lift skipNonNewlineSpaces-      account <- modifiedaccountnamep+      account <- modifiedaccountnamep True       return (status, account)     let (ptype, account') = (accountNamePostingType account, textUnbracket account)     lift skipNonNewlineSpaces@@ -1109,8 +1221,8 @@    ,testGroup "directivep" [     testCase "supports !" $ do-        assertParseE directivep "!account a\n"-        assertParseE directivep "!D 1.0\n"+        assertParseE (directivep definputopts) "!account a\n"+        assertParseE (directivep definputopts) "!D 1.0\n"      ]    ,testGroup "accountdirectivep" [@@ -1144,8 +1256,8 @@      assertParse ignoredpricecommoditydirectivep "N $\n"    ,testGroup "includedirectivep" [-      testCase "include" $ assertParseErrorE includedirectivep "include nosuchfile\n" "No existing files match pattern: nosuchfile"-     ,testCase "glob" $ assertParseErrorE includedirectivep "include nosuchfile*\n" "No existing files match pattern: nosuchfile*"+      testCase "include" $ assertParseErrorE (includedirectivep definputopts) "include nosuchfile\n" "No files were matched by glob pattern: nosuchfile"+     ,testCase "glob" $ assertParseErrorE (includedirectivep definputopts) "include nosuchfile*\n" "No files were matched by glob pattern: nosuchfile*"      ]    ,testCase "marketpricedirectivep" $ assertParseEq marketpricedirectivep@@ -1172,12 +1284,12 @@       assertParse endtagdirectivep "end apply tag \n"    ,testGroup "journalp" [-    testCase "empty file" $ assertParseEqE journalp "" nulljournal+    testCase "empty file" $ assertParseEqE (journalp definputopts) "" nulljournal     ]     -- these are defined here rather than in Common so they can use journalp   ,testCase "parseAndFinaliseJournal" $ do-      ej <- runExceptT $ parseAndFinaliseJournal journalp definputopts "" "2019-1-1\n"+      ej <- runExceptT $ parseAndFinaliseJournal (journalp definputopts) definputopts "" "2019-1-1\n"       let Right j = ej       assertEqual "" [""] $ journalFilePaths j 
Hledger/Read/RulesReader.hs view
@@ -22,6 +22,7 @@ {-# LANGUAGE ViewPatterns         #-} {-# OPTIONS_GHC -Wno-unrecognised-pragmas #-} {-# LANGUAGE LambdaCase #-}+{-# LANGUAGE TupleSections #-}  --- ** exports module Hledger.Read.RulesReader (@@ -44,7 +45,9 @@ --- ** imports import Prelude hiding (Applicative(..)) import Control.Applicative (Applicative(..))-import Control.Monad              (unless, when, void)+import Control.Concurrent (forkIO)+import Control.DeepSeq (deepseq)+import Control.Monad (unless, void, when) import Control.Monad.Except       (ExceptT(..), liftEither, throwError) import qualified Control.Monad.Fail as Fail import Control.Monad.IO.Class     (MonadIO, liftIO)@@ -52,9 +55,16 @@ import Control.Monad.Trans.Class  (lift) import Data.Char                  (toLower, isDigit, isSpace, isAlphaNum, ord) import Data.Bifunctor             (first)-import Data.Encoding              (encodingFromStringExplicit)-import Data.Functor               ((<&>))+import qualified Data.ByteString as B+import qualified Data.ByteString.Lazy as BL+import qualified Data.Csv as Cassava+import qualified Data.Csv.Parser.Megaparsec as CassavaMegaparsec+import Data.Encoding (encodingFromStringExplicit)+import Data.Either (fromRight)+import Data.Functor ((<&>)) import Data.List (elemIndex, mapAccumL, nub, sortOn)+-- import Data.List (elemIndex, mapAccumL, nub, sortOn, isPrefixOf, sortBy)+-- import Data.Ord (Down(..), comparing) #if !MIN_VERSION_base(4,20,0) import Data.List (foldl') #endif@@ -67,14 +77,14 @@ import qualified Data.Text.Encoding as T import qualified Data.Text.IO as T import Data.Time ( Day, TimeZone, UTCTime, LocalTime, ZonedTime(ZonedTime),-  defaultTimeLocale, getCurrentTimeZone, localDay, parseTimeM, utcToLocalTime, localTimeToUTC, zonedTimeToUTC)+  defaultTimeLocale, getCurrentTimeZone, localDay, parseTimeM, utcToLocalTime, localTimeToUTC, zonedTimeToUTC, utctDay) import Safe (atMay, headMay, lastMay, readMay)-import System.FilePath ((</>), takeDirectory, takeExtension, stripExtension, takeFileName)-import System.IO       (Handle, hClose)-import qualified Data.Csv as Cassava-import qualified Data.Csv.Parser.Megaparsec as CassavaMegaparsec-import qualified Data.ByteString as B-import qualified Data.ByteString.Lazy as BL+import System.Directory (createDirectoryIfMissing, doesFileExist, getHomeDirectory, getModificationTime, removeFile)+-- import System.Directory (createDirectoryIfMissing, doesFileExist, getHomeDirectory, getModificationTime, listDirectory, renameFile, doesDirectoryExist)+import System.Exit      (ExitCode(..))+import System.FilePath (stripExtension, takeBaseName, takeDirectory, takeExtension, takeFileName, (<.>), (</>))+import System.IO       (Handle, hClose, hPutStrLn, stderr, hGetContents')+import System.Process  (CreateProcess(..), StdStream(CreatePipe), shell, waitForProcess, withCreateProcess) import Data.Foldable (asum, toList) import Text.Megaparsec hiding (match, parse) import Text.Megaparsec.Char (char, newline, string, digitChar)@@ -84,9 +94,6 @@ import Hledger.Utils import Hledger.Read.Common (aliasesFromOpts, Reader(..), InputOpts(..), amountp, statusp, journalFinalise, accountnamep, transactioncommentp, postingcommentp ) import Hledger.Write.Csv-import System.Directory (doesFileExist, getHomeDirectory)-import Data.Either (fromRight)-import Control.DeepSeq (deepseq)  --- ** doctest setup -- $setup@@ -101,7 +108,7 @@   {rFormat     = Rules   ,rExtensions = ["rules"]   ,rReadFn     = parse-  ,rParser     = error' "sorry, rules files can't be included"  -- PARTIAL:+  ,rParser     = const $ fail "sorry, rules files can't be included yet"   }  isFileName f = takeFileName f == f@@ -110,50 +117,264 @@   home <- getHomeDirectory   return $ home </> "Downloads"  -- XXX --- | Parse and post-process a "Journal" from the given rules file path, or give an error.--- A data file is inferred from the @source@ rule, otherwise from a similarly-named file--- in the same directory.--- The source rule can specify a glob pattern and supports ~ for home directory.--- If it is a bare filename it will be relative to the defaut download directory--- on this system. If is a relative file path it will be relative to the rules--- file's directory. When a glob pattern matches multiple files, the alphabetically--- last is used. (Eg in case of multiple numbered downloads, the highest-numbered--- will be used.)--- The provided handle, or a --rules option, are ignored by this reader.--- Balance assertions are not checked.+-- | Read, parse and post-process a "Journal" from the given rules file, or give an error.+-- This particular reader also provides some extra features like data cleaning/generating commands and data archiving.+--+-- The provided input file handle, and the --rules option, are ignored by this reader.+-- Instead, a data file (or data-generating command) is usually specified by the @source@ rule.+-- If there's no source rule, the data file is assumed to be named like the rules file without .rules, in the same directory.+--+-- The source rule supports ~ for home directory: @source ~/Downloads/foo.csv@.+-- If the argument is a bare filename, its directory is assumed to be ~/Downloads: @source foo.csv@.+-- Otherwise if it is a relative path, it is assumed to be relative to the rules file's directory: @source new/foo.csv@.+--+-- The source rule can specify a glob pattern: @source foo*.csv@.+-- If the glob pattern matches multiple files, the newest (last modified) file is used (with one exception, described below).+--+-- The source rule can specify a data-cleaning command, after a @|@ separator: @source foo*.csv | sed -e 's/USD/$/g'@.+-- This command is executed by the user's default shell, receives the data file's content on stdin,+-- and should output CSV data suitable for the conversion rules.+-- A # character can be used to comment out the data-cleaning command: @source foo*.csv  # | ...@.+--+-- Or the source rule can specify just a data-generating command, with no file pattern: @source | foo-csv.sh@.+-- In this case the command receives no input; it should output CSV data suitable for the conversion rules.+--+-- If the archive rule is present:+-- After successfully reading the data file or data command and converting to a journal, while doing a non-dry-run import:+-- the data will be archived in an auto-created data/ directory next to the rules file,+-- with a name based on the rules file and the data file's modification date and extension+-- (or for a data-generating command, the current date and the ".csv" extension).+-- And import will prefer the oldest file matched by a glob pattern (not the newest).+--+-- Balance assertions are not checked by this reader.+-- parse :: InputOpts -> FilePath -> Handle -> ExceptT String IO Journal-parse iopts f h = do-  lift $ hClose h -- We don't need it-  rules <- readRulesFile $ dbg4 "reading rules file" f-  -- XXX higher-than usual debug level for file reading to bypass excessive noise from elsewhere, normally 6 or 7-  mdatafile <- liftIO $ do-    dldir <- getDownloadDir-    let rulesdir = takeDirectory f-    let msource = T.unpack <$> getDirective "source" rules-    fs <- case msource of-            Just src -> expandGlob dir (dbg4 "source" src) >>= sortByModTime <&> dbg4 ("matched files"<>desc<>", newest first")-              where (dir,desc) = if isFileName src then (dldir," in download directory") else (rulesdir,"")-            Nothing  -> return [maybe err (dbg4 "inferred source") $ dataFileFor f]  -- shouldn't fail, f has .rules extension-              where err = error' $ "could not infer a data file for " <> f-    return $ dbg4 "data file" $ headMay fs+parse iopts rulesfile h = do+  lift $ hClose h -- We don't need it (XXX why ?)++  -- The rules reader does a lot; we must be organised.++  -- 1. gather contextual info+  --  gives: import flag, dryrun flag, rulesdir++  let+    args     = progArgs+    import_  = dbg2 "import" $ any (`elem` args) ["import", "imp"]+    dryrun   = dbg2 "dryrun" $ any (`elem` args) ["--dry-run", "--dry"]+    rulesdir = takeDirectory rulesfile++  -- 2. parse the source and archive rules+  --  needs: rules file+  --  gives: file pattern, data cleaning/generating command, archive flag++  -- XXX higher-than usual logging priority for file reading (normally 6 or 7), to bypass excessive noise from elsewhere+  rules <- readRulesFile $ dbg1 "reading rules file" rulesfile+  let+    msourcearg = getDirective "source" rules+      -- Nothing -> error' $ rulesfile ++ " source rule must specify a file pattern or a command"+    -- Surrounding whitespace is removed from the whole source argument and from each part of it.+    -- A # before | makes the rest of line a comment.+    -- A # after | is left for the shell to interpret; it could be part of the command or the start of a comment.+    stripspaces = T.strip+    stripcommentandspaces = stripspaces . T.takeWhile (/= '#')+    mpatandcmd = T.breakOn "|" . stripspaces <$> msourcearg+    mpat = dbg2 "file pattern" $  -- a non-empty file pattern, or nothing+      case T.unpack . stripcommentandspaces . fst <$> mpatandcmd of+        Just s | not $ null s -> Just s+        _ -> Nothing+    mcmd = dbg2 "data command" $  -- a non-empty command, or nothing+      mpatandcmd >>= \sc ->+        let c = T.unpack . stripspaces . T.drop 1 . snd $ sc+        in if null c then Nothing else Just c++    archive = isJust (getDirective "archive" rules)++  -- 3. find the file to be read, if any+  --  needs: file pattern, data command, import flag, archive flag, downloads dir+  --  gives: data file, data file description++  (mdatafile, datafiledesc) <- dbg2 "data file found ?" <$> case (mpat, mcmd) of+    (Nothing, Nothing) -> error' $ "to make " ++ rulesfile ++ " readable,\n please add a 'source' rule with a non-empty file pattern or command"+    (Nothing, Just _) -> return (Nothing, "")+    (Just pat, _) -> do+      dldir <- liftIO getDownloadDir  -- look here for the data file if it's specified without a directory+      let+        (startdir, dirdesc)+          | isFileName pat = (dldir,    " in download directory")+          | otherwise      = (rulesdir, "")+      fs <- liftIO $+        expandGlob startdir pat+        >>= sortByModTime+        <&> dbg2 ("matched files"<>dirdesc<>", oldest first")+      return $+        if import_ && archive+        then (headMay fs, " oldest file")+        else (lastMay fs, " newest file")+    +  -- 4. log which file we are reading/importing/cleaning/generating+  --  needs: data file, data file description, import flag++  case (mdatafile, datafiledesc) of+    (Just f, desc) -> dbg1IO ("trying to " ++ (if import_ then "import" else "read") ++ desc) f+    (Nothing, _)   -> return ()++  -- 5. read raw, cleaned or generated data+  --  needs: file pattern, data file, data command+  --  gives: clean data (possibly empty)++  mexistingdatafile <- maybe (return Nothing) (\f -> liftIO $ do+    exists <- doesFileExist f+    return $ if exists then Just f else Nothing+    ) $ mdatafile+  cleandata <- dbg1With (\t -> "read "++(show $ length $ T.lines t)++" lines") <$> case (mpat, mexistingdatafile, mcmd) of++    -- file pattern, but no file found+    (Just _, Nothing, _) -> -- trace "file pattern, but no file found" $+      return ""++    -- file found, and maybe a data cleaning command+    (_, Just f,  mc) ->  -- trace "file found" $ +      liftIO $ do+        raw <- openFileOrStdin f >>= readHandlePortably+        maybe (return raw) (\c -> runCommandAsFilter rulesfile (dbg0Msg ("running: "++c) c) raw) mc++    -- no file pattern, but a data generating command+    (Nothing, _, Just cmd) -> -- trace "data generating command" $+      liftIO $ runCommand rulesfile $ dbg0Msg ("running: " ++ cmd) cmd++    -- neither a file pattern nor a data generating command+    (Nothing, _, Nothing) -> -- trace "no file pattern or data generating command" $+      error' $ rulesfile ++ " source rule must specify a file pattern or a command"++  -- 6. convert the clean data to a (possibly empty) journal+  --  needs: clean data, rules, rules file, data file if any+  --  gives: journal++  j <- do+    cleandatah <- liftIO $ inputToHandle cleandata+    readJournalFromCsv (Just $ Left rules) (fromMaybe "(cmd)" mdatafile) cleandatah Nothing+    -- apply any command line account aliases. Can fail with a bad replacement pattern.+    >>= liftEither . journalApplyAliases (aliasesFromOpts iopts)+        -- journalFinalise assumes the journal's items are+        -- reversed, as produced by JournalReader's parser.+        -- But here they are already properly ordered. So we'd+        -- better preemptively reverse them once more. XXX inefficient+        . journalReverse+    >>= journalFinalise iopts{balancingopts_=(balancingopts_ iopts){ignore_assertions_=True}} rulesfile ""++  -- 7. if non-empty, successfully read and converted, and we're doing a non-dry-run archiving import: archive the data+  --  needs: import/archive/dryrun flags, rules directory, rules file, data file if any, clean data++  when (not (T.null cleandata) && import_ && archive && not dryrun) $+    liftIO $ saveToArchive (rulesdir </> "data") rulesfile mdatafile cleandata++  return j++-- | For the given rules file, run the given shell command, in the rules file's directory.+-- If the command fails, raise an error and show its error output;+-- otherwise return its output, and show any error output as a warning.+runCommand :: FilePath -> String -> IO Text+runCommand rulesfile cmd = do+  let process = (shell cmd) { cwd = Just $ takeDirectory rulesfile, std_out = CreatePipe, std_err = CreatePipe }+  withCreateProcess process $ \_ mhout mherr phandle -> do+    case (mhout, mherr) of+      (Just hout, Just herr) -> do+        out <- T.hGetContents hout+        err <- hGetContents' herr+        exitCode <- waitForProcess phandle+        case exitCode of+          ExitSuccess -> do+            unless (null err) $ warnIO err+            return out+          ExitFailure code ->+            error' $ "in " ++ rulesfile ++ ": command \"" ++ cmd ++ "\" failed with exit code " ++ show code+              ++ (if null err then "" else ":\n" ++ err)+      _ -> error' $ "in " ++ rulesfile ++ ": failed to create pipes for command execution"++-- | For the given rules file, run the given shell command, in the rules file's directory, passing the given text as input.+-- Return the output, or if the command fails, raise an informative error.+runCommandAsFilter :: FilePath -> String -> Text -> IO Text+runCommandAsFilter rulesfile cmd input = do+  let process = (shell cmd) { cwd = Just $ takeDirectory rulesfile, std_in = CreatePipe, std_out = CreatePipe, std_err = CreatePipe }+  withCreateProcess process $ \mhin mhout mherr phandle -> do+    case (mhin, mhout, mherr) of+      (Just hin, Just hout, Just herr) -> do+        forkIO $ T.hPutStr hin input >> hClose hin+        out <- T.hGetContents hout+        err <- hGetContents' herr+        exitCode <- waitForProcess phandle+        case exitCode of+          ExitSuccess -> return out+          ExitFailure code ->+            error' $ "in " ++ rulesfile ++ ": command \"" ++ cmd ++ "\" failed with exit code " ++ show code+              ++ (if null err then "" else ":\n" ++ err)+      _ -> error' $ "in " ++ rulesfile ++ ": failed to create pipes for command execution"++type DirPath = FilePath++-- | Save some successfully imported data+-- (more precisely: data that was successfully read and maybe cleaned, or that was generated, during an import)+-- to the given archive directory, autocreating that if needed, and show informational output on stderr.+-- The arguments are:+-- the archive directory,+-- the rules file (for naming),+-- the data file name, if any,+-- the data that was read, cleaned, or generated.+-- The archive file name will be RULESFILEBASENAME.DATAFILEMODDATEORCURRENTDATE.DATAFILEEXTORCSV.+-- Note for a data generating command, where there's no data file, we use the current date+-- and a .csv file extension (meaning "character-separated values" in this case).+saveToArchive :: DirPath -> FilePath -> Maybe FilePath -> Text -> IO ()+saveToArchive archivedir rulesfile mdatafile cleandata = do+  createDirectoryIfMissing True archivedir+  (_, cleanname) <- archiveFileName rulesfile mdatafile+  let cleanarchive = archivedir </> cleanname+  hPutStrLn stderr $ "archiving " <> cleanarchive+  T.writeFile cleanarchive cleandata+  maybe (return ()) removeFile mdatafile++-- | Figure out the file names to use when archiving, for the given rules file and the given data file if any.+-- The second name is for the final (possibly cleaned) data; the first name has ".orig" added,+-- and is used if both original and cleaned data are being archived. They will be like this:+-- ("RULESFILEBASENAME.orig.DATAFILEMODDATE.DATAFILEEXT", "RULESFILEBASENAME.DATAFILEMODDATE.DATAFILEEXT")+archiveFileName :: FilePath -> Maybe FilePath -> IO (String, String)+archiveFileName rulesfile mdatafile = do+  let base = takeBaseName rulesfile   case mdatafile of-    Nothing -> return nulljournal  -- data file specified by source rule was not found-    Just dat -> do-      exists <- liftIO $ doesFileExist dat-      if not (dat=="-" || exists)-      then return nulljournal      -- data file inferred from rules file name was not found-      else do-        dath <- liftIO $ openFileOrStdin dat-        readJournalFromCsv (Just $ Left rules) dat dath Nothing-        -- apply any command line account aliases. Can fail with a bad replacement pattern.-        >>= liftEither . journalApplyAliases (aliasesFromOpts iopts)-            -- journalFinalise assumes the journal's items are-            -- reversed, as produced by JournalReader's parser.-            -- But here they are already properly ordered. So we'd-            -- better preemptively reverse them once more. XXX inefficient-            . journalReverse-        >>= journalFinalise iopts{balancingopts_=(balancingopts_ iopts){ignore_assertions_=True}} f ""+    Just datafile -> do+      moddate <- (show . utctDay) <$> getModificationTime datafile+      let ext = takeExtension datafile+      return (+         base <.> "orig" <.> moddate <.> ext+        ,base            <.> moddate <.> ext+        )+    Nothing -> do+      let ext = "csv"+      curdate <- show <$> getCurrentDay+      return (+         base <.> "orig" <.> curdate <.> ext+        ,base            <.> curdate <.> ext+        ) +-- -- | In the given archive directory, if it exists, find the paths of data files saved for the given rules file.+-- -- They will be reverse sorted by name, ie newest first, assuming normal archive file names.+-- --+-- -- We don't know which extension the data files use, but we look for file names beginning with+-- -- the rules file's base name followed by .YYYY-MM-DD, which will normally be good enough.+-- --+-- archivesFor :: FilePath -> FilePath -> IO [FilePath]+-- archivesFor archivedir rulesfile = do+--   exists <- doesDirectoryExist archivedir+--   if not exists then return []+--   else do+--     let prefix = takeBaseName rulesfile <> "."+--     fs <- listDirectory archivedir+--     return $ map (archivedir </>) $ sortBy (comparing Down)+--       [f | f <- fs,+--         prefix `isPrefixOf` f,+--         let nextpart = takeWhile (/= '.') $ drop (length prefix) f,+--         isJust $ parsedate nextpart+--         ]+ --- ** reading rules files --- *** rules utilities _RULES_READING__________________________________________ = undefined@@ -392,10 +613,12 @@  RULES: RULE* -RULE: ( SOURCE | FIELD-LIST | FIELD-ASSIGNMENT | CONDITIONAL-BLOCK | SKIP | TIMEZONE | NEWEST-FIRST | INTRA-DAY-REVERSED | DATE-FORMAT | DECIMAL-MARK | COMMENT | BLANK ) NEWLINE+RULE: ( SOURCE | ARCHIVE | FIELD-LIST | FIELD-ASSIGNMENT | CONDITIONAL-BLOCK | SKIP | TIMEZONE | NEWEST-FIRST | INTRA-DAY-REVERSED | DATE-FORMAT | DECIMAL-MARK | COMMENT | BLANK ) NEWLINE  SOURCE: source SPACE FILEPATH +ARCHIVE: archive+ FIELD-LIST: fields SPACE FIELD-NAME ( SPACE? , SPACE? FIELD-NAME )*  FIELD-NAME: QUOTED-FIELD-NAME | BARE-FIELD-NAME@@ -518,6 +741,7 @@ directives :: [Text] directives =   ["source"+  ,"archive"   ,"encoding"   ,"date-format"   ,"decimal-mark"
Hledger/Read/TimeclockReader.hs view
@@ -1,17 +1,14 @@ --- * -*- outline-regexp:"--- \\*"; -*- --- ** doc -- In Emacs, use TAB on lines beginning with "-- *" to collapse/expand sections.-{-| -A reader for the timeclock file format generated by timeclock.el-(<http://www.emacswiki.org/emacs/TimeClock>). Example:+-- Keep relevant parts synced with manual:+{-| -@-i 2007\/03\/10 12:26:00 hledger-o 2007\/03\/10 17:26:02-@+A reader for the timeclock file format. -From timeclock.el 2.6:+What exactly is this format ? It was introduced in timeclock.el (<http://www.emacswiki.org/emacs/TimeClock>).+The old specification in timeclock.el 2.6 was:  @ A timeclock contains data in the form of a single entry per line.@@ -41,6 +38,92 @@      now finished.  Useful for creating summary reports. @ +Ledger's timeclock format is different, and hledger's timeclock format is different again.+For example: in a clock-in entry, after the time,++- timeclock.el's timeclock has 0-1 fields: [COMMENT]+- Ledger's timeclock has 0-2 fields:       [ACCOUNT[  PAYEE]]+- hledger's timeclock has 1-3 fields:      ACCOUNT[  DESCRIPTION[;COMMENT]]++hledger's timeclock format is:++@+# Comment lines like these, and blank lines, are ignored:+# comment line+; comment line+* comment line++# Lines beginning with b, h, or capital O are also ignored, for compatibility:+b SIMPLEDATE HH:MM[:SS][+-ZZZZ][ TEXT]+h SIMPLEDATE HH:MM[:SS][+-ZZZZ][ TEXT]+O SIMPLEDATE HH:MM[:SS][+-ZZZZ][ TEXT]++# Lines beginning with i or o are are clock-in / clock-out entries:+i SIMPLEDATE HH:MM[:SS][+-ZZZZ] ACCOUNT[  DESCRIPTION][;COMMENT]]+o SIMPLEDATE HH:MM[:SS][+-ZZZZ][ ACCOUNT][;COMMENT]+@++The date is a hledger [simple date](#simple-dates) (YYYY-MM-DD or similar).+The time parts must use two digits.+The seconds are optional.+A + or - four-digit time zone is accepted for compatibility, but currently ignored; times are always interpreted as a local time.++In clock-in entries (`i`), the account name is required.+A transaction description, separated from the account name by 2+ spaces, is optional.+A transaction comment, beginning with `;`, is also optional.++In clock-out entries (`o`) have no description, but can have a comment if you wish.+A clock-in and clock-out pair form a "transaction" posting some number of hours to an account - also known as a session.+Eg:++```timeclock+i 2015/03/30 09:00:00 session1+o 2015/03/30 10:00:00+```++```cli+$ hledger -f a.timeclock print+2015-03-30 * 09:00-10:00+    (session1)           1.00h+```++Clock-ins and clock-outs are matched by their account/session name.+If a clock-outs does not specify a name, the most recent unclosed clock-in is closed.+Also, sessions spanning more than one day are automatically split at day boundaries.+Eg, the following time log:++```timeclock+i 2015/03/30 09:00:00 some account  optional description after 2 spaces ; optional comment, tags:+o 2015/03/30 09:20:00+i 2015/03/31 22:21:45 another:account+o 2015/04/01 02:00:34+i 2015/04/02 12:00:00 another:account  ; this demonstrates multple sessions being clocked in+i 2015/04/02 13:00:00 some account+o 2015/04/02 14:00:00+o 2015/04/02 15:00:00 another:account+```++generates these transactions:++```cli+$ hledger -f t.timeclock print+2015-03-30 * optional description after 2 spaces   ; optional comment, tags:+    (some account)           0.33h++2015-03-31 * 22:21-23:59+    (another:account)           1.64h++2015-04-01 * 00:00-02:00+    (another:account)           2.01h++2015-04-02 * 12:00-15:00  ; this demonstrates multiple sessions being clocked in+    (another:account)           3.00h++2015-04-02 * 13:00-14:00+    (some account)           1.00h++```+ -}  --- ** language@@ -68,6 +151,7 @@ import           Hledger.Read.Common import           Hledger.Utils import Data.Text as T (strip)+import Data.Functor ((<&>))  --- ** doctest setup -- $setup@@ -80,7 +164,7 @@   {rFormat     = Timeclock   ,rExtensions = ["timeclock"]   ,rReadFn     = handleReadFnToTextReadFn parse-  ,rParser     = timeclockfilep definputopts+  ,rParser     = timeclockfilep   }  -- | Parse and post-process a "Journal" from timeclock.el's timeclock@@ -98,40 +182,69 @@ -- timeclockfilep args  timeclockfilep :: MonadIO m => InputOpts -> JournalParser m ParsedJournal-timeclockfilep iopts = do many timeclockitemp-                          eof-                          j@Journal{jparsetimeclockentries=es} <- get-                          -- Convert timeclock entries in this journal to transactions, closing any unfinished sessions.-                          -- Doing this here rather than in journalFinalise means timeclock sessions can't span file boundaries,-                          -- but it simplifies code above.-                          now <- liftIO getCurrentLocalTime-                          -- journalFinalise expects the transactions in reverse order, so reverse the output in either case-                          let j' = if (_oldtimeclock iopts) then -                                -- timeclockEntriesToTransactionsSingle expects the entries to be in normal order, -                                -- but they have been parsed in reverse order, so reverse them before calling-                                j{jtxns = reverse $ timeclockEntriesToTransactionsSingle now $ reverse es, jparsetimeclockentries = []}-                              else -                                -- We don't need to reverse these transactions -                                -- since they are sorted inside of timeclockEntiresToTransactions-                                j{jtxns = reverse $ timeclockEntriesToTransactions now es, jparsetimeclockentries = []}-                          return j'-    where-      -- As all ledger line types can be distinguished by the first-      -- character, excepting transactions versus empty (blank or-      -- comment-only) lines, can use choice w/o try-      timeclockitemp = choice [-                            void (lift emptyorcommentlinep)-                          , timeclockentryp >>= \e -> modify' (\j -> j{jparsetimeclockentries = e : jparsetimeclockentries j})-                          ] <?> "timeclock entry, comment line, or empty line"+timeclockfilep iopts = do+  many timeclockitemp+  eof+  j@Journal{jparsetimeclockentries=es} <- get+  -- Convert timeclock entries in this journal to transactions, closing any unfinished sessions.+  -- Doing this here rather than in journalFinalise means timeclock sessions can't span file boundaries,+  -- but it simplifies code above.+  now <- liftIO getCurrentLocalTime+  -- journalFinalise expects the transactions in reverse order, so reverse the output in either case+  let+    j' = if _oldtimeclock iopts+      then+        -- timeclockToTransactionsOld expects the entries to be in normal order, +        -- but they have been parsed in reverse order, so reverse them before calling+        j{jtxns = reverse $ timeclockToTransactionsOld now $ reverse es, jparsetimeclockentries = []}+      else+        -- We don't need to reverse these transactions +        -- since they are sorted inside of timeclockToTransactions+        j{jtxns = reverse $ timeclockToTransactions now es, jparsetimeclockentries = []}+  return j'+  where+    -- As all ledger line types can be distinguished by the first+    -- character, excepting transactions versus empty (blank or+    -- comment-only) lines, can use choice w/o try+    timeclockitemp = choice [+       void (lift emptyorcommentlinep)+      ,entryp >>= \e -> modify' (\j -> j{jparsetimeclockentries = e : jparsetimeclockentries j})+      ] <?> "timeclock entry, comment line, or empty line"+      where entryp = if _oldtimeclock iopts then oldtimeclockentryp else timeclockentryp --- | Parse a timeclock entry.-timeclockentryp :: JournalParser m TimeclockEntry-timeclockentryp = do+-- | Parse a timeclock entry (loose pre-1.50 format).+oldtimeclockentryp :: JournalParser m TimeclockEntry+oldtimeclockentryp = do   pos <- getSourcePos   code <- oneOf ("bhioO" :: [Char])   lift skipNonNewlineSpaces1   datetime <- datetimep-  account     <- fmap (fromMaybe "") $ optional $ lift skipNonNewlineSpaces1 >> modifiedaccountnamep+  account     <- fmap (fromMaybe "") $ optional $ lift skipNonNewlineSpaces1 >> modifiedaccountnamep True   description <- fmap (maybe "" T.strip) $ optional $ lift $ skipNonNewlineSpaces1 >> descriptionp   (comment, tags) <- lift transactioncommentp+  return $ TimeclockEntry pos (read [code]) datetime account description comment tags++-- | Parse a timeclock entry (more robust post-1.50 format).+timeclockentryp :: JournalParser m TimeclockEntry+timeclockentryp = do+  pos <- getSourcePos+  code <- oneOf ("iobhO" :: [Char])+  lift skipNonNewlineSpaces1+  datetime <- datetimep+  (account, description) <- case code of+    'i' -> do+      lift skipNonNewlineSpaces1+      a <- modifiedaccountnamep False+      d <- optional (lift $ skipNonNewlineSpaces1 >> descriptionp) <&> maybe "" T.strip+      return (a, d)+    'o' -> do+      -- Notice the try needed here to avoid a parse error if there's trailing spaces.+      -- Unlike descriptionp above, modifiedaccountnamep requires nonempty text.+      -- And when a parser in an optional fails after consuming input, optional doesn't backtrack,+      -- it propagates the failure.+      a <- optional (try $ lift skipNonNewlineSpaces1 >> modifiedaccountnamep False) <&> fromMaybe ""+      return (a, "")+    _ -> return ("", "")+  lift skipNonNewlineSpaces+  (comment, tags) <- lift $ optional transactioncommentp <&> fromMaybe ("",[])   return $ TimeclockEntry pos (read [code]) datetime account description comment tags
Hledger/Read/TimedotReader.hs view
@@ -74,7 +74,7 @@  -- | Parse and post-process a "Journal" from the timedot format, or give an error. parse :: InputOpts -> FilePath -> Text -> ExceptT String IO Journal-parse iopts fp t = initialiseAndParseJournal timedotp iopts fp t+parse iopts fp t = initialiseAndParseJournal (timedotp iopts) iopts fp t                    >>= liftEither . journalApplyAliases (aliasesFromOpts iopts)                    >>= journalFinalise iopts fp t @@ -106,8 +106,8 @@  timedotfilep = timedotp -- XXX rename export above -timedotp :: JournalParser m ParsedJournal-timedotp = preamblep >> many dayp >> eof >> get+timedotp :: InputOpts -> JournalParser m ParsedJournal+timedotp _ = preamblep >> many dayp >> eof >> get  preamblep :: JournalParser m () preamblep = do@@ -176,7 +176,7 @@   dp "timedotentryp"   notFollowedBy datelinep   lift $ optional $ choice [orgheadingprefixp, skipNonNewlineSpaces1]-  a <- modifiedaccountnamep+  a <- modifiedaccountnamep False   lift skipNonNewlineSpaces   taggedhours <- lift durationsp   (comment0, tags0) <-
Hledger/Reports/AccountTransactionsReport.hs view
@@ -27,7 +27,6 @@ import Data.List.Extra (nubSort) import Data.Maybe (catMaybes) import Data.Ord (Down(..), comparing)-import Data.Text (Text) import qualified Data.Text as T import Data.Time.Calendar (Day) @@ -80,7 +79,7 @@    Transaction -- the transaction, unmodified   ,Transaction -- the transaction, as seen from the current account   ,Bool        -- is this a split (more than one posting to other accounts) ?-  ,Text        -- a display string describing the other account(s), if any+  ,[AccountName] -- the other account(s), if any   ,MixedAmount -- the amount posted to the current account(s) (or total amount posted)   ,MixedAmount -- the register's running total or the current account(s)'s historical balance, after this transaction   )@@ -188,14 +187,14 @@     -- 201407: I've lost my grip on this, let's just hope for the best     -- 201606: we now calculate change and balance from filtered postings, check this still works well for all callers XXX     | null reportps = (bal, Nothing)  -- no matched postings in this transaction, skip it-    | otherwise     = (bal', Just (t, tacct{tdate=d}, numotheraccts > 1, otheracctstr, amt, bal'))+    | otherwise     = (bal', Just (t, tacct{tdate=d}, numotheraccts > 1, otheraccts, amt, bal'))     where       tacct@Transaction{tpostings=reportps} = filterTransactionPostingsExtra accttypefn reportq t  -- TODO needs to consider --date2, #1731       (thisacctps, otheracctps) = partition (matchesPosting thisacctq) reportps       numotheraccts = length $ nub $ map paccount otheracctps-      otheracctstr | thisacctq == None  = summarisePostingAccounts reportps     -- no current account ? summarise all matched postings-                   | numotheraccts == 0 = summarisePostingAccounts thisacctps   -- only postings to current account ? summarise those-                   | otherwise          = summarisePostingAccounts otheracctps  -- summarise matched postings to other account(s)+      otheraccts | thisacctq == None  = summarisePostingAccounts reportps     -- no current account ? summarise all matched postings+                 | numotheraccts == 0 = summarisePostingAccounts thisacctps   -- only postings to current account ? summarise those+                 | otherwise          = summarisePostingAccounts otheracctps  -- summarise matched postings to other account(s)       -- 202302: Impact of t on thisacct - normally the sum of thisacctps,       -- but if they are null it probably means reportq is an account filter       -- and we should sum otheracctps instead.@@ -236,9 +235,8 @@  -- | Generate a simplified summary of some postings' accounts. -- To reduce noise, if there are both real and virtual postings, show only the real ones.-summarisePostingAccounts :: [Posting] -> Text-summarisePostingAccounts ps =-    T.intercalate ", " . map accountSummarisedName . nub $ map paccount displayps+summarisePostingAccounts :: [Posting] -> [AccountName]+summarisePostingAccounts ps = map paccount displayps   where     realps = filter isReal ps     displayps | null realps = ps
Hledger/Reports/BudgetReport.hs view
@@ -9,23 +9,22 @@   BudgetReportRow,   BudgetReport,   budgetReport,-  -- * Helpers-  combineBudgetAndActual,   -- * Tests   tests_BudgetReport ) where  import Control.Applicative ((<|>))-import Data.HashMap.Strict (HashMap)-import qualified Data.HashMap.Strict as HM-import Data.List (find, partition, maximumBy, intercalate)+import Control.Monad ((>=>))+import Data.Bifunctor (bimap)+import Data.Foldable (toList)+import Data.List (find, maximumBy, intercalate) import Data.List.Extra (nubSort)-import Data.Maybe (fromMaybe, isJust)-import Data.Map (Map)-import qualified Data.Map as Map+import Data.Maybe (catMaybes, fromMaybe, isJust)+import Data.Ord (comparing) import qualified Data.Set as S import qualified Data.Text as T+import Data.These (These(..), these) import Safe (minimumDef)  import Hledger.Data@@ -33,8 +32,6 @@ import Hledger.Reports.ReportOptions import Hledger.Reports.ReportTypes import Hledger.Reports.MultiBalanceReport-import Data.Ord (comparing)-import Control.Monad ((>=>))  -- All MixedAmounts: type BudgetGoal    = Change@@ -69,7 +66,9 @@     -- Budget report demands ALTree mode to ensure subaccounts and subaccount budgets are properly handled     -- and that reports with and without --empty make sense when compared side by side     ropts = (_rsReportOpts rspec){ accountlistmode_ = ALTree }+    -- ropts = _rsReportOpts rspec     showunbudgeted = empty_ ropts+     budgetedaccts =       dbg3 "budgetedacctsinperiod" $       S.fromList $@@ -78,22 +77,60 @@       concatMap tpostings $       concatMap (\pt -> runPeriodicTransaction False pt reportspan) $       jperiodictxns j+     actualj = journalWithBudgetAccountNames budgetedaccts showunbudgeted j     budgetj = journalAddBudgetGoalTransactions bopts ropts reportspan j     priceoracle = journalPriceOracle (infer_prices_ ropts) j-    budgetgoalreport@(PeriodicReport _ budgetgoalitems budgetgoaltotals) =-        dbg5 "budgetgoalreport" $ multiBalanceReportWith rspec{_rsReportOpts=ropts{empty_=True}} budgetj priceoracle mempty-    budgetedacctsseen = S.fromList $ map prrFullName budgetgoalitems-    actualreport@(PeriodicReport actualspans _ _) =-        dbg5 "actualreport"     $ multiBalanceReportWith rspec{_rsReportOpts=ropts{empty_=True}} actualj priceoracle budgetedacctsseen-    budgetgoalreport'-      -- If no interval is specified:-      -- budgetgoalreport's span might be shorter actualreport's due to periodic txns;-      -- it should be safe to replace it with the latter, so they combine well.-      | interval_ ropts == NoInterval = PeriodicReport actualspans budgetgoalitems budgetgoaltotals-      | otherwise = budgetgoalreport-    budgetreport = combineBudgetAndActual ropts j budgetgoalreport' actualreport +    (_, actualspans) = dbg5 "actualspans" $ reportSpan actualj rspec+    (_, budgetspans) = dbg5 "budgetspans" $ reportSpan budgetj rspec+    allspans = case interval_ ropts of+        -- If no interval is specified:+        -- budgetgoalreport's span might be shorter actualreport's due to periodic txns;+        -- it should be safe to replace it with the latter, so they combine well.+        NoInterval -> actualspans+        _          -> nubSort . filter (/= nulldatespan) $ actualspans ++ budgetspans++    actualps = dbg5 "actualps" $ getPostings rspec actualj priceoracle reportspan+    budgetps = dbg5 "budgetps" $ getPostings rspec budgetj priceoracle reportspan++    actualAcct = dbg5 "actualAcct" $ generateMultiBalanceAccount rspec actualj priceoracle actualspans actualps+    budgetAcct = dbg5 "budgetAcct" $ generateMultiBalanceAccount rspec budgetj priceoracle budgetspans budgetps++    combinedAcct = dbg5 "combinedAcct" $ if null budgetps+        -- If no budget postings, just use actual account, to avoid unnecssary budget zeros+        then This <$> actualAcct+        else mergeAccounts actualAcct budgetAcct++    budgetreport = generateBudgetReport ropts allspans combinedAcct++-- | Lay out a set of postings grouped by date span into a regular matrix with rows+-- given by AccountName and columns by DateSpan, then generate a MultiBalanceReport+-- from the columns.+generateBudgetReport :: ReportOpts -> [DateSpan] -> Account (These BalanceData BalanceData) -> BudgetReport+generateBudgetReport = generatePeriodicReport makeBudgetReportRow treeActualBalance flatActualBalance+  where+    treeActualBalance = these bdincludingsubs (const nullmixedamt) (const . bdincludingsubs)+    flatActualBalance = fromMaybe nullmixedamt . fst++-- | Build a report row.+--+-- Calculate the column totals. These are always the sum of column amounts.+makeBudgetReportRow :: ReportOpts -> (BalanceData -> MixedAmount)+                    -> a -> Account (These BalanceData BalanceData) -> PeriodicReportRow a BudgetCell+makeBudgetReportRow ropts balance =+    makePeriodicReportRow (Just nullmixedamt, Nothing) avg ropts (theseToMaybe . bimap balance balance)+  where+    avg xs = ((actualtotal, budgettotal), (actualavg, budgetavg))+      where+        (actuals, budgets) = unzip $ toList xs+        (actualtotal, actualavg) = bimap Just Just . sumAndAverageMixedAmounts $ catMaybes actuals+        (budgettotal, budgetavg) = bimap Just Just . sumAndAverageMixedAmounts $ catMaybes budgets++    theseToMaybe (This a) = (Just a, Nothing)+    theseToMaybe (That b) = (Just nullmixedamt, Just b)+    theseToMaybe (These a b) = (Just a, Just b)+ -- | Use all (or all matched by --budget's argument) periodic transactions in the journal  -- to generate budget goal transactions in the specified date span (and before, to support -- --historical. The precise start date is the natural start date of the largest interval@@ -179,81 +216,6 @@         budgetedparent = find (`S.member` budgetedaccts) $ parentAccountNames a         u = unbudgetedAccountName --- | Combine a per-account-and-subperiod report of budget goals, and one--- of actual change amounts, into a budget performance report.--- The two reports should have the same report interval, but need not--- have exactly the same account rows or date columns.--- (Cells in the combined budget report can be missing a budget goal,--- an actual amount, or both.) The combined report will include:------ - consecutive subperiods at the same interval as the two reports,---   spanning the period of both reports------ - all accounts mentioned in either report, sorted by account code or---   account name or amount as appropriate.----combineBudgetAndActual :: ReportOpts -> Journal -> MultiBalanceReport -> MultiBalanceReport -> BudgetReport-combineBudgetAndActual ropts j-      (PeriodicReport budgetperiods budgetrows (PeriodicReportRow _ budgettots budgetgrandtot budgetgrandavg))-      (PeriodicReport actualperiods actualrows (PeriodicReportRow _ actualtots actualgrandtot actualgrandavg)) =-    PeriodicReport periods combinedrows totalrow-  where-    periods = nubSort . filter (/= nulldatespan) $ budgetperiods ++ actualperiods--    -- first, combine any corresponding budget goals with actual changes-    actualsplusgoals = [-        -- dbg0With (("actualsplusgoals: "<>)._brrShowDebug) $-        PeriodicReportRow acct amtandgoals totamtandgoal avgamtandgoal-      | PeriodicReportRow acct actualamts actualtot actualavg <- actualrows--      , let mbudgetgoals       = HM.lookup (displayFull acct) budgetGoalsByAcct :: Maybe ([BudgetGoal], BudgetTotal, BudgetAverage)-      , let budgetmamts        = maybe (Nothing <$ periods) (map Just . first3) mbudgetgoals :: [Maybe BudgetGoal]-      , let mbudgettot         = second3 <$> mbudgetgoals :: Maybe BudgetTotal-      , let mbudgetavg         = third3 <$> mbudgetgoals  :: Maybe BudgetAverage-      , let acctGoalByPeriod   = Map.fromList [ (p,budgetamt) | (p, Just budgetamt) <- zip budgetperiods budgetmamts ] :: Map DateSpan BudgetGoal-      , let acctActualByPeriod = Map.fromList [ (p,actualamt) | (p, Just actualamt) <- zip actualperiods (map Just actualamts) ] :: Map DateSpan Change-      , let amtandgoals        = [ (Map.lookup p acctActualByPeriod, Map.lookup p acctGoalByPeriod) | p <- periods ] :: [BudgetCell]-      , let totamtandgoal      = (Just actualtot, mbudgettot)-      , let avgamtandgoal      = (Just actualavg, mbudgetavg)-      ]-      where-        budgetGoalsByAcct :: HashMap AccountName ([BudgetGoal], BudgetTotal, BudgetAverage) =-          HM.fromList [ (displayFull acct, (amts, tot, avg))-                      | PeriodicReportRow acct amts tot avg <--                          -- dbg0With (unlines.map (("budgetgoals: "<>).prrShowDebug)) $-                          budgetrows-                      ]--    -- next, make rows for budget goals with no actual changes-    othergoals = [-        -- dbg0With (("othergoals: "<>)._brrShowDebug) $-        PeriodicReportRow acct amtandgoals totamtandgoal avgamtandgoal-      | PeriodicReportRow acct budgetgoals budgettot budgetavg <- budgetrows-      , displayFull acct `notElem` map prrFullName actualsplusgoals-      , let acctGoalByPeriod   = Map.fromList $ zip budgetperiods budgetgoals :: Map DateSpan BudgetGoal-      , let amtandgoals        = [ (Just 0, Map.lookup p acctGoalByPeriod) | p <- periods ] :: [BudgetCell]-      , let totamtandgoal      = (Just 0, Just budgettot)-      , let avgamtandgoal      = (Just 0, Just budgetavg)-      ]--    -- combine and re-sort rows-    -- TODO: add --sort-budget to sort by budget goal amount-    combinedrows :: [BudgetReportRow] =-      -- map (dbg0With (("combinedrows: "<>)._brrShowDebug)) $-      sortRowsLike (mbrsorted unbudgetedrows ++ mbrsorted rows') rows-      where-        (unbudgetedrows, rows') = partition ((==unbudgetedAccountName) . prrFullName) rows-        mbrsorted = map prrFullName . sortRows ropts j . map (fmap $ fromMaybe nullmixedamt . fst)-        rows = actualsplusgoals ++ othergoals--    totalrow = PeriodicReportRow ()-        [ (Map.lookup p totActualByPeriod, Map.lookup p totGoalByPeriod) | p <- periods ]-        ( Just actualgrandtot, budget budgetgrandtot )-        ( Just actualgrandavg, budget budgetgrandavg )-      where-        totGoalByPeriod = Map.fromList $ zip budgetperiods budgettots :: Map DateSpan BudgetTotal-        totActualByPeriod = Map.fromList $ zip actualperiods actualtots :: Map DateSpan Change-        budget b = if mixedAmountLooksZero b then Nothing else Just b  -- tests 
Hledger/Reports/MultiBalanceReport.hs view
@@ -1,8 +1,11 @@+{-# LANGUAGE CPP                 #-} {-# LANGUAGE FlexibleInstances   #-} {-# LANGUAGE OverloadedStrings   #-}-{-# LANGUAGE NamedFieldPuns   #-}+{-# LANGUAGE NamedFieldPuns      #-}+{-# LANGUAGE RankNTypes          #-} {-# LANGUAGE RecordWildCards     #-} {-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TupleSections       #-} {-|  Multi-column balance reports, used by the balance command.@@ -19,52 +22,42 @@   compoundBalanceReport,   compoundBalanceReportWith, -  sortRows,-  sortRowsLike,-   -- * Helper functions   makeReportQuery,-  getPostingsByColumn,   getPostings,-  startingPostings,-  generateMultiBalanceReport,+  generateMultiBalanceAccount,+  generatePeriodicReport,+  makePeriodicReportRow,    -- -- * Tests   tests_MultiBalanceReport ) where +#if !MIN_VERSION_base(4,18,0)+import Control.Applicative (liftA2)+#endif import Control.Monad (guard)-import Data.Bifunctor (second) import Data.Foldable (toList)-import Data.List (sortOn, transpose)+import Data.List (sortOn) import Data.List.NonEmpty (NonEmpty((:|)))-import Data.HashMap.Strict (HashMap)-import qualified Data.HashMap.Strict as HM-import Data.Map (Map)-import qualified Data.Map as M+import qualified Data.HashSet as HS+import qualified Data.IntMap.Strict as IM+import qualified Data.IntSet as IS import Data.Maybe (fromMaybe, isJust, mapMaybe) import Data.Ord (Down(..)) import Data.Semigroup (sconcat)-import Data.Set (Set)-import qualified Data.Set as Set-import Data.Time.Calendar (fromGregorian)-import Safe (lastDef, minimumMay)+import Data.These (these)+import Data.Time.Calendar (Day(..), addDays, fromGregorian)+import Data.Traversable (mapAccumL)  import Hledger.Data import Hledger.Query-import Hledger.Utils hiding (dbg3,dbg4,dbg5)-import qualified Hledger.Utils+import Hledger.Utils import Hledger.Reports.ReportOptions import Hledger.Reports.ReportTypes  --- add a prefix to this function's debug output-dbg3 s = let p = "multiBalanceReport" in Hledger.Utils.dbg3 (p++" "++s)-dbg4 s = let p = "multiBalanceReport" in Hledger.Utils.dbg4 (p++" "++s)-dbg5 s = let p = "multiBalanceReport" in Hledger.Utils.dbg5 (p++" "++s)-- -- | A multi balance report is a kind of periodic report, where the amounts -- correspond to balance changes or ending balances in a given period. It has: --@@ -86,10 +79,7 @@ type MultiBalanceReport    = PeriodicReport    DisplayName MixedAmount type MultiBalanceReportRow = PeriodicReportRow DisplayName MixedAmount --- type alias just to remind us which AccountNames might be depth-clipped, below.-type ClippedAccountName = AccountName - -- | Generate a multicolumn balance report for the matched accounts, -- showing the change of balance, accumulated balance, or historical balance -- in each of the specified periods. If the normalbalance_ option is set, it@@ -98,7 +88,7 @@ -- by the balance command (in multiperiod mode) and (via compoundBalanceReport) -- by the bs/cf/is commands. multiBalanceReport :: ReportSpec -> Journal -> MultiBalanceReport-multiBalanceReport rspec j = multiBalanceReportWith rspec j (journalPriceOracle infer j) mempty+multiBalanceReport rspec j = multiBalanceReportWith rspec j (journalPriceOracle infer j)   where infer = infer_prices_ $ _rsReportOpts rspec  -- | A helper for multiBalanceReport. This one takes some extra arguments,@@ -106,26 +96,23 @@ -- 'AccountName's which should not be elided. Commands which run multiple -- reports (bs etc.) can generate the price oracle just once for efficiency, -- passing it to each report by calling this function directly.-multiBalanceReportWith :: ReportSpec -> Journal -> PriceOracle -> Set AccountName -> MultiBalanceReport-multiBalanceReportWith rspec' j priceoracle unelidableaccts = report+multiBalanceReportWith :: ReportSpec -> Journal -> PriceOracle -> MultiBalanceReport+multiBalanceReportWith rspec' j priceoracle = report   where     -- Queries, report/column dates.-    (reportspan, colspans) = reportSpan j rspec'-    rspec = dbg3 "reportopts" $ makeReportQuery rspec' reportspan+    (reportspan, colspans) = dbg5 "multiBalanceReportWith reportSpan" $ reportSpan j rspec'+    rspec = dbg3 "multiBalanceReportWith rspec" $ makeReportQuery rspec' reportspan     -- force evaluation order to show price lookup after date spans in debug output (XXX not working)     -- priceoracle = reportspan `seq` priceoracle0 -    -- Group postings into their columns.-    colps = dbg5 "colps" $ getPostingsByColumn rspec j priceoracle colspans+    -- Get postings+    ps = dbg5 "multiBalanceReportWith ps" $ getPostings rspec j priceoracle reportspan -    -- The matched accounts with a starting balance. All of these should appear-    -- in the report, even if they have no postings during the report period.-    startbals = dbg5 "startbals" $-      startingBalances rspec j priceoracle $ startingPostings rspec j priceoracle reportspan+    -- Process changes into normal, cumulative, or historical amounts, plus value them and mark which are uninteresting+    acct = dbg5 "multiBalanceReportWith acct" $ generateMultiBalanceAccount rspec j priceoracle colspans ps      -- Generate and postprocess the report, negating balances and taking percentages if needed-    report = dbg4 "multiBalanceReportWith" $-      generateMultiBalanceReport rspec j priceoracle unelidableaccts colps startbals+    report = dbg4 "multiBalanceReportWith report" $ generateMultiBalanceReport (_rsReportOpts rspec) colspans acct  -- | Generate a compound balance report from a list of CBCSubreportSpec. This -- shares postings between the subreports.@@ -141,23 +128,18 @@ compoundBalanceReportWith rspec' j priceoracle subreportspecs = cbr   where     -- Queries, report/column dates.-    (reportspan, colspans) = reportSpan j rspec'-    rspec = dbg3 "reportopts" $ makeReportQuery rspec' reportspan--    -- Group postings into their columns.-    colps = dbg5 "colps" $ getPostingsByColumn rspec j priceoracle colspans+    (reportspan, colspans) = dbg5 "compoundBalanceReportWith reportSpan" $ reportSpan j rspec'+    rspec = dbg3 "compoundBalanceReportWith rspec" $ makeReportQuery rspec' reportspan -    -- The matched postings with a starting balance. All of these should appear-    -- in the report, even if they have no postings during the report period.-    startps = dbg5 "startps" $ startingPostings rspec j priceoracle reportspan+    -- Get postings+    ps = dbg5 "compoundBalanceReportWith ps" $ getPostings rspec j priceoracle reportspan      subreports = map generateSubreport subreportspecs       where         generateSubreport CBCSubreportSpec{..} =             ( cbcsubreporttitle             -- Postprocess the report, negating balances and taking percentages if needed-            , cbcsubreporttransform $-                generateMultiBalanceReport rspecsub j priceoracle mempty colps' startbals'+            , cbcsubreporttransform $ generateMultiBalanceReport ropts colspans acct             , cbcsubreportincreasestotal             )           where@@ -165,10 +147,10 @@             -- Add a restriction to this subreport to the report query.             -- XXX in non-thorough way, consider updateReportSpec ?             rspecsub = rspec{_rsReportOpts=ropts, _rsQuery=And [cbcsubreportquery, _rsQuery rspec]}-            -- Starting balances and column postings specific to this subreport.-            startbals' = startingBalances rspecsub j priceoracle $-              filter (matchesPostingExtra (journalAccountType j) cbcsubreportquery) startps-            colps' = map (second $ filter (matchesPostingExtra (journalAccountType j) cbcsubreportquery)) colps+            -- Match and postings for the subreport+            subreportps = filter (matchesPostingExtra (journalAccountType j) cbcsubreportquery) ps+            -- Account representing this subreport+            acct = generateMultiBalanceAccount rspecsub j priceoracle colspans subreportps      -- Sum the subreport totals by column. Handle these cases:     -- - no subreports@@ -181,48 +163,9 @@         subreportTotal (_, sr, increasestotal) =             (if increasestotal then id else fmap maNegate) $ prTotals sr -    cbr = CompoundPeriodicReport "" (map fst colps) subreports overalltotals+    cbr = CompoundPeriodicReport "" colspans subreports overalltotals --- XXX seems refactorable--- | Calculate accounts' balances on the report start date, from these postings--- which should be all postings before that date, and possibly also from account declarations.-startingBalances :: ReportSpec -> Journal -> PriceOracle -> [Posting]-                             -> HashMap AccountName Account-startingBalances rspec j priceoracle ps =-    M.findWithDefault nullacct emptydatespan-      <$> calculateReportMatrix rspec j priceoracle mempty [(emptydatespan, ps)] --- | Postings needed to calculate starting balances.------ Balances at report start date, from all earlier postings which otherwise match the query.--- These balances are unvalued.--- TODO: Do we want to check whether to bother calculating these? isHistorical--- and startDate is not nothing, otherwise mempty? This currently gives a--- failure with some totals which are supposed to be 0 being blank.-startingPostings :: ReportSpec -> Journal -> PriceOracle -> DateSpan -> [Posting]-startingPostings rspec@ReportSpec{_rsQuery=query,_rsReportOpts=ropts} j priceoracle reportspan =-    getPostings rspec' j priceoracle-  where-    rspec' = rspec{_rsQuery=startbalq,_rsReportOpts=ropts'}-    -- If we're re-valuing every period, we need to have the unvalued start-    -- balance, so we can do it ourselves later.-    ropts' = case value_ ropts of-        Just (AtEnd _) -> ropts{period_=precedingperiod, value_=Nothing}-        _              -> ropts{period_=precedingperiod}--    -- q projected back before the report start date.-    -- When there's no report start date, in case there are future txns (the hledger-ui case above),-    -- we use emptydatespan to make sure they aren't counted as starting balance.-    startbalq = dbg3 "startbalq" $ And [datelessq, precedingspanq]-    datelessq = dbg3 "datelessq" $ filterQuery (not . queryIsDateOrDate2) query--    precedingperiod = dateSpanAsPeriod . spanIntersect precedingspan .-                         periodAsDateSpan $ period_ ropts-    precedingspan = DateSpan Nothing (Exact <$> spanStart reportspan)-    precedingspanq = (if date2_ ropts then Date2 else Date) $ case precedingspan of-        DateSpan Nothing Nothing -> emptydatespan-        a -> a- -- | Remove any date queries and insert queries from the report span. -- The user's query expanded to the report span -- if there is one (otherwise any date queries are left as-is, which@@ -233,86 +176,102 @@     | otherwise = rspec{_rsQuery=query}   where     query            = simplifyQuery $ And [dateless $ _rsQuery rspec, reportspandatesq]-    reportspandatesq = dbg3 "reportspandatesq" $ dateqcons reportspan-    dateless         = dbg3 "dateless" . filterQuery (not . queryIsDateOrDate2)+    reportspandatesq = dbg3 "makeReportQuery reportspandatesq" $ dateqcons reportspan+    dateless         = dbg3 "makeReportQuery dateless" . filterQuery (not . queryIsDateOrDate2)     dateqcons        = if date2_ (_rsReportOpts rspec) then Date2 else Date --- | Group postings, grouped by their column-getPostingsByColumn :: ReportSpec -> Journal -> PriceOracle -> [DateSpan] -> [(DateSpan, [Posting])]-getPostingsByColumn rspec j priceoracle colspans =-    groupByDateSpan True getDate colspans ps-  where-    -- Postings matching the query within the report period.-    ps = dbg5 "getPostingsByColumn" $ getPostings rspec j priceoracle-    -- The date spans to be included as report columns.-    getDate = postingDateOrDate2 (whichDate (_rsReportOpts rspec))- -- | Gather postings matching the query within the report period.-getPostings :: ReportSpec -> Journal -> PriceOracle -> [Posting]-getPostings rspec@ReportSpec{_rsQuery=query, _rsReportOpts=ropts} j priceoracle =-    journalPostings $ journalValueAndFilterPostingsWith rspec' j priceoracle+getPostings :: ReportSpec -> Journal -> PriceOracle -> DateSpan -> [Posting]+getPostings rspec@ReportSpec{_rsQuery=query, _rsReportOpts=ropts} j priceoracle reportspan =+    setPostingsCount+    . journalPostings+    $ journalValueAndFilterPostingsWith rspec' j priceoracle   where-    rspec' = rspec{_rsQuery=depthless, _rsReportOpts = ropts'}+    -- If doing --count, set all posting amounts to "1".+    setPostingsCount = case balancecalc_ ropts of+        CalcPostingsCount -> map (postingTransformAmount (const $ mixed [num 1]))+        _                 -> id++    rspec' = rspec{_rsQuery=fullreportq,_rsReportOpts=ropts'}+    -- If we're re-valuing every period, we need to have the unvalued start+    -- balance, so we can do it ourselves later.     ropts' = if isJust (valuationAfterSum ropts)-        then ropts{value_=Nothing, conversionop_=Just NoConversionOp}  -- If we're valuing after the sum, don't do it now-        else ropts+        then ropts{period_=dateSpanAsPeriod fullreportspan, value_=Nothing, conversionop_=Just NoConversionOp}  -- If we're valuing after the sum, don't do it now+        else ropts{period_=dateSpanAsPeriod fullreportspan} +    -- q projected back before the report start date.+    -- When there's no report start date, in case there are future txns (the hledger-ui case above),+    -- we use emptydatespan to make sure they aren't counted as starting balance.+    fullreportq = dbg3 "getPostings fullreportq" $ And [datelessq, fullreportspanq]+    datelessq   = dbg3 "getPostings datelessq" $ filterQuery (not . queryIsDateOrDate2) depthlessq+     -- The user's query with no depth limit, and expanded to the report span     -- if there is one (otherwise any date queries are left as-is, which     -- handles the hledger-ui+future txns case above).-    depthless = dbg3 "depthless" $ filterQuery (not . queryIsDepth) query+    depthlessq = dbg3 "getPostings depthlessq" $ filterQuery (not . queryIsDepth) query --- | From set of postings, eg for a single report column, calculate the balance change in each account. --- Accounts and amounts will be depth-clipped appropriately if a depth limit is in effect.------ When --declared is used, accounts which have been declared with an account directive--- are also included, with a 0 balance change. But only leaf accounts, since non-leaf--- empty declared accounts are less useful in reports. This is primarily for hledger-ui.-acctChanges :: ReportSpec -> Journal -> [Posting] -> HashMap ClippedAccountName Account-acctChanges ReportSpec{_rsQuery=query,_rsReportOpts=ReportOpts{accountlistmode_, declared_}} j ps =-  HM.fromList [(aname a, a) | a <- accts]+    fullreportspan  = if requiresHistorical ropts then DateSpan Nothing (Exact <$> spanEnd reportspan) else reportspan+    fullreportspanq = (if date2_ ropts then Date2 else Date) $ case fullreportspan of+        DateSpan Nothing Nothing -> emptydatespan+        a -> a++-- | Generate the 'Account' for the requested multi-balance report from a list+-- of 'Posting's.+generateMultiBalanceAccount :: ReportSpec -> Journal -> PriceOracle -> [DateSpan] -> [Posting] -> Account BalanceData+generateMultiBalanceAccount rspec@ReportSpec{_rsReportOpts=ropts} j priceoracle colspans =+    -- Add declared accounts if called with --declared and --empty+    (if (declared_ ropts && empty_ ropts) then addDeclaredAccounts rspec j else id)+    -- Negate amounts if applicable+    . (if invert_ ropts then fmap (mapBalanceData maNegate) else id)+    -- Mark which accounts are boring and which are interesting+    . markAccountBoring rspec+    -- Set account declaration info (for sorting purposes)+    . mapAccounts (accountSetDeclarationInfo j)+    -- Process changes into normal, cumulative, or historical amounts, plus value them+    . calculateReportAccount rspec j priceoracle colspans+    -- Clip account names+    . map clipPosting   where-    -- With --declared, add the query-matching declared accounts-    -- (as dummy postings so they are processed like the rest).-    -- This function is used for calculating both pre-start changes and column changes,-    -- and the declared accounts are really only needed for the former, -    -- but it's harmless to have them in the column changes as well.-    ps' = ps ++ if declared_ then declaredacctps else []-      where-        declaredacctps =-          [nullposting{paccount=a}-          | a <- journalLeafAccountNamesDeclared j-          , matchesAccountExtra (journalAccountType j) (journalAccountTags j) accttypetagsq a-          ]-          where-            accttypetagsq  = dbg3 "accttypetagsq" $-              filterQueryOrNotQuery (\q -> queryIsAcct q || queryIsType q || queryIsTag q) query+    -- Clip postings to the requested depth according to the query+    clipPosting p = p{paccount = clipOrEllipsifyAccountName depthSpec $ paccount p}+    depthSpec = dbg3 "generateMultiBalanceAccount depthSpec"+              . queryDepth . filterQuery queryIsDepth $ _rsQuery rspec -    filterbydepth = case accountlistmode_ of-        ALTree -> filter (depthMatches . aname)       -- a tree - just exclude deeper accounts-        ALFlat -> clipAccountsAndAggregate depthSpec  -- a list - aggregate deeper accounts at the depth limit-                  . filter ((0<) . anumpostings)      -- and exclude empty parent accounts-      where-        depthSpec = dbg3 "depthq" . queryDepth $ filterQuery queryIsDepth query-        depthMatches name = maybe True (accountNameLevel name <=) $ getAccountNameClippedDepth depthSpec name+-- | Add declared accounts to the account tree.+addDeclaredAccounts :: Monoid a => ReportSpec -> Journal -> Account a -> Account a+addDeclaredAccounts rspec j acct =+    these id id const <$> mergeAccounts acct declaredTree+  where+    declaredTree =+        mapAccounts (\a -> a{aboring = not $ aname a `HS.member` HS.fromList declaredAccounts}) $+          accountTreeFromBalanceAndNames "root" (mempty <$ adata acct) declaredAccounts -    accts = filterbydepth $ drop 1 $ accountsFromPostings ps'+    -- With --declared, add the query-matching declared accounts (as dummy postings+    -- so they are processed like the rest).+    declaredAccounts =+      map (clipOrEllipsifyAccountName depthSpec) .+      filter (matchesAccountExtra (journalAccountType j) (journalAccountTags j) accttypetagsq) $+      journalAccountNamesDeclared j +    accttypetagsq  = dbg3 "addDeclaredAccounts accttypetagsq" .+      filterQueryOrNotQuery (\q -> queryIsAcct q || queryIsType q || queryIsTag q) $+      _rsQuery rspec++    depthSpec = queryDepth . filterQuery queryIsDepth $ _rsQuery rspec++ -- | Gather the account balance changes into a regular matrix, then -- accumulate and value amounts, as specified by the report options. -- Makes sure all report columns have an entry.-calculateReportMatrix :: ReportSpec -> Journal -> PriceOracle-                      -> HashMap ClippedAccountName Account-                      -> [(DateSpan, [Posting])]-                      -> HashMap ClippedAccountName (Map DateSpan Account)-calculateReportMatrix rspec@ReportSpec{_rsReportOpts=ropts} j priceoracle startbals colps =  -- PARTIAL:-    -- Ensure all columns have entries, including those with starting balances-    HM.mapWithKey rowbals allchanges+calculateReportAccount :: ReportSpec -> Journal -> PriceOracle -> [DateSpan] -> [Posting] -> Account BalanceData+calculateReportAccount rspec@ReportSpec{_rsReportOpts=ropts} j priceoracle colspans ps =  -- PARTIAL:+    mapPeriodData rowbals changesAcct   where     -- The valued row amounts to be displayed: per-period changes,     -- zero-based cumulative totals, or     -- starting-balance-based historical balances.-    rowbals name unvaluedChanges = dbg5 "rowbals" $ case balanceaccum_ ropts of+    rowbals :: PeriodData BalanceData -> PeriodData BalanceData+    rowbals unvaluedChanges = case balanceaccum_ ropts of         PerPeriod  -> changes         Cumulative -> cumulative         Historical -> historical@@ -320,207 +279,209 @@         -- changes to report on: usually just the valued changes themselves, but use the         -- differences in the valued historical amount for CalcValueChange and CalcGain.         changes = case balancecalc_ ropts of-            CalcChange        -> M.mapWithKey avalue unvaluedChanges-            CalcBudget        -> M.mapWithKey avalue unvaluedChanges-            CalcValueChange   -> periodChanges valuedStart historical-            CalcGain          -> periodChanges valuedStart historical-            CalcPostingsCount -> M.mapWithKey avalue unvaluedChanges+            CalcChange        -> avalue unvaluedChanges+            CalcBudget        -> avalue unvaluedChanges+            CalcValueChange   -> periodChanges historical+            CalcGain          -> periodChanges historical+            CalcPostingsCount -> avalue unvaluedChanges         -- the historical balance is the valued cumulative sum of all unvalued changes-        historical = M.mapWithKey avalue $ cumulativeSum startingBalance unvaluedChanges+        historical = avalue $ cumulativeSum unvaluedChanges         -- since this is a cumulative sum of valued amounts, it should not be valued again-        cumulative = cumulativeSum nullacct changes-        startingBalance = HM.lookupDefault nullacct name startbals-        valuedStart = avalue (DateSpan Nothing (Exact <$> historicalDate)) startingBalance+        cumulative = cumulativeSum changes{pdpre = mempty}+        avalue = periodDataValuation ropts j priceoracle colspans -    -- In each column, get each account's balance changes-    colacctchanges = dbg5 "colacctchanges" $ map (second $ acctChanges rspec j) colps :: [(DateSpan, HashMap ClippedAccountName Account)]-    -- Transpose it to get each account's balance changes across all columns-    acctchanges = dbg5 "acctchanges" $ transposeMap colacctchanges :: HashMap AccountName (Map DateSpan Account)-    -- Fill out the matrix with zeros in empty cells-    allchanges = ((<>zeros) <$> acctchanges) <> (zeros <$ startbals)+    changesAcct = dbg5With (\x -> "calculateReportAccount changesAcct\n" ++ showAccounts x) .+        mapPeriodData (padPeriodData intervalStarts) $+        accountFromPostings getIntervalStartDate ps -    avalue = acctApplyBoth . mixedAmountApplyValuationAfterSumFromOptsWith ropts j priceoracle-    acctApplyBoth f a = a{aibalance = f $ aibalance a, aebalance = f $ aebalance a}-    historicalDate = minimumMay $ mapMaybe spanStart colspans-    zeros = M.fromList [(spn, nullacct) | spn <- colspans]-    colspans = map fst colps+    getIntervalStartDate p = intToDay <$> IS.lookupLE (dayToInt $ getPostingDate p) intervalStarts+    getPostingDate = postingDateOrDate2 (whichDate (_rsReportOpts rspec)) +    intervalStarts = IS.fromList . map dayToInt $ case mapMaybe spanStart colspans of+      [] -> [nulldate]  -- Deal with the case of the empty journal+      xs -> xs+    dayToInt = fromInteger . toModifiedJulianDay+    intToDay = ModifiedJulianDay . toInteger --- | Lay out a set of postings grouped by date span into a regular matrix with rows--- given by AccountName and columns by DateSpan, then generate a MultiBalanceReport--- from the columns.-generateMultiBalanceReport :: ReportSpec -> Journal -> PriceOracle -> Set AccountName-                           -> [(DateSpan, [Posting])] -> HashMap AccountName Account-                           -> MultiBalanceReport-generateMultiBalanceReport rspec@ReportSpec{_rsReportOpts=ropts} j priceoracle unelidableaccts colps0 startbals =-    report+-- | The valuation function to use for the chosen report options.+-- This can call error in various situations.+periodDataValuation :: ReportOpts -> Journal -> PriceOracle -> [DateSpan]+                    -> PeriodData BalanceData -> PeriodData BalanceData+periodDataValuation ropts j priceoracle colspans =+    opPeriodData valueBalanceData balanceDataPeriodEnds   where-    -- If doing --count, set all posting amounts to "1".-    colps =-      if balancecalc_ ropts == CalcPostingsCount-      then map (second (map (postingTransformAmount (const $ mixed [num 1])))) colps0-      else colps0--    -- Process changes into normal, cumulative, or historical amounts, plus value them-    matrix = calculateReportMatrix rspec j priceoracle startbals colps--    -- All account names that will be displayed, possibly depth-clipped.-    displaynames = dbg5 "displaynames" $ displayedAccounts rspec unelidableaccts matrix--    -- All the rows of the report.-    rows = dbg5 "rows" . (if invert_ ropts then map (fmap maNegate) else id)  -- Negate amounts if applicable-             $ buildReportRows ropts displaynames matrix--    -- Calculate column totals-    totalsrow = dbg5 "totalsrow" $ calculateTotalsRow ropts rows $ length colps--    -- Sorted report rows.-    sortedrows = dbg5 "sortedrows" $ sortRows ropts j rows+    valueBalanceData :: Day -> BalanceData -> BalanceData+    valueBalanceData d = mapBalanceData (valueMixedAmount d) -    -- Take percentages if needed-    report = reportPercent ropts $ PeriodicReport (map fst colps) sortedrows totalsrow+    valueMixedAmount :: Day -> MixedAmount -> MixedAmount+    valueMixedAmount = mixedAmountApplyValuationAfterSumFromOptsWith ropts j priceoracle --- | Build the report rows.--- One row per account, with account name info, row amounts, row total and row average.--- Rows are unsorted.-buildReportRows :: ReportOpts-                -> HashMap AccountName DisplayName-                -> HashMap AccountName (Map DateSpan Account)-                -> [MultiBalanceReportRow]-buildReportRows ropts displaynames =-  toList . HM.mapMaybeWithKey mkRow  -- toList of HashMap's Foldable instance - does not sort consistently-  where-    mkRow name accts = do-        displayname <- HM.lookup name displaynames-        return $ PeriodicReportRow displayname rowbals rowtot rowavg+    balanceDataPeriodEnds :: PeriodData Day+    balanceDataPeriodEnds = dbg5 "balanceDataPeriodEnds" $ case colspans of  -- FIXME: Change colspans to nonempty list+        [DateSpan Nothing Nothing] -> periodDataFromList nulldate [(nulldate, nulldate)]  -- Empty journal+        h:ds                       -> periodDataFromList (makeJustFst $ boundaries h) $ map (makeJust . boundaries) (h:ds)+        []                         -> error' "balanceDataPeriodEnds: Shouldn't have empty colspans"  -- PARTIAL: Shouldn't occur       where-        rowbals = map balance $ toList accts  -- toList of Map's Foldable instance - does sort by key-        -- The total and average for the row.-        -- These are always simply the sum/average of the displayed row amounts.-        -- Total for a cumulative/historical report is always the last column.-        rowtot = case balanceaccum_ ropts of-            PerPeriod -> maSum rowbals-            _         -> lastDef nullmixedamt rowbals-        rowavg = averageMixedAmounts rowbals-    balance = case accountlistmode_ ropts of ALTree -> aibalance; ALFlat -> aebalance+        boundaries spn = (spanStart spn, spanEnd spn) --- | Calculate accounts which are to be displayed in the report,--- and their name and their indent level if displayed in tree mode.-displayedAccounts :: ReportSpec-                  -> Set AccountName-                  -> HashMap AccountName (Map DateSpan Account)-                  -> HashMap AccountName DisplayName-displayedAccounts ReportSpec{_rsQuery=query,_rsReportOpts=ropts} unelidableaccts valuedaccts-    | qdepthIsZero = HM.singleton "..." $ DisplayName "..." "..." 0-    | otherwise    = HM.mapWithKey (\a _ -> displayedName a) displayedAccts+        makeJust (Just x, Just y) = (x, addDays (-1) y)+        makeJust _    = error' "balanceDataPeriodEnds: expected all non-initial spans to have start and end dates"+        makeJustFst (Just x, _) = addDays (-1) x+        makeJustFst _ = error' "balanceDataPeriodEnds: expected initial span to have an end date"++-- | Mark which nodes of an 'Account' are boring, and so should be omitted from reports.+markAccountBoring :: ReportSpec -> Account BalanceData -> Account BalanceData+markAccountBoring ReportSpec{_rsQuery=query,_rsReportOpts=ropts}+    -- If depth 0, all accounts except the top-level account are boring+    | qdepthIsZero = markBoring False . mapAccounts (markBoring True)+    -- Otherwise the top level account is boring, and subaccounts are boring if+    -- they are both boring in and of themselves and are boring parents+    | otherwise    = markBoring True . mapAccounts (markBoringBy (liftA2 (&&) isBoring isBoringParent))   where-    displayedName name = case accountlistmode_ ropts of-        ALTree -> DisplayName name leaf (max 0 $ level - 1 - boringParents)-        ALFlat -> DisplayName name droppedName 0+    -- Accounts boring on their own+    isBoring :: Account BalanceData -> Bool+    isBoring acct = tooDeep || allZeros       where-        droppedName   = accountNameDrop (drop_ ropts) name-        leaf          = accountNameFromComponents . reverse . map accountLeafName $-                          droppedName : takeWhile notDisplayed parents-        level         = max 0 $ (accountNameLevel name) - drop_ ropts-        parents       = take (level - 1) $ parentAccountNames name-        boringParents = if no_elide_ ropts then 0 else length $ filter notDisplayed parents-        notDisplayed  = not . (`HM.member` displayedAccts)+        tooDeep = d > qdepth                                       -- Throw out anything too deep+        allZeros = isZeroRow balance amts && not keepEmptyAccount  -- Throw away everything with a zero balance in the row, unless..+        keepEmptyAccount = empty_ ropts && keepWhenEmpty acct      -- We are keeping empty rows and this row meets the criteria -    -- Accounts which are to be displayed-    displayedAccts = (if qdepthIsZero then id else HM.filterWithKey keep) valuedaccts-      where-        keep name amts = isInteresting name amts || name `HM.member` interestingParents+        amts = pdperiods $ adata acct+        d = accountNameLevel $ aname acct -    -- Accounts interesting for their own sake-    isInteresting name amts =-        d <= qdepth                                -- Throw out anything too deep-        && ( name `Set.member` unelidableaccts     -- Unelidable accounts should be kept unless too deep-           ||(empty_ ropts && keepWhenEmpty amts)  -- Keep empty accounts when called with --empty-           || not (isZeroRow balance amts)         -- Keep everything with a non-zero balance in the row-           )-      where-        d = accountNameLevel name-        qdepth = fromMaybe maxBound $ getAccountNameClippedDepth depthspec name-        keepWhenEmpty = case accountlistmode_ ropts of-            ALFlat -> const True          -- Keep all empty accounts in flat mode-            ALTree -> all (null . asubs)  -- Keep only empty leaves in tree mode+        qdepth = fromMaybe maxBound . getAccountNameClippedDepth depthspec $ aname acct         balance = maybeStripPrices . case accountlistmode_ ropts of-            ALTree | d == qdepth -> aibalance-            _                    -> aebalance-          where maybeStripPrices = if conversionop_ ropts == Just NoConversionOp then id else mixedAmountStripCosts+            ALTree | d == qdepth -> bdincludingsubs+            _                    -> bdexcludingsubs -    -- Accounts interesting because they are a fork for interesting subaccounts-    interestingParents = dbg5 "interestingParents" $ case accountlistmode_ ropts of-        ALTree -> HM.filterWithKey hasEnoughSubs numSubs-        ALFlat -> mempty+    -- Accounts which don't have enough interesting subaccounts+    isBoringParent :: Account a -> Bool+    isBoringParent acct = case accountlistmode_ ropts of+        ALTree -> notEnoughSubs || droppedAccount+        ALFlat -> True       where-        hasEnoughSubs name nsubs = nsubs >= minSubs && accountNameLevel name > drop_ ropts-        minSubs = if no_elide_ ropts then 1 else 2+        notEnoughSubs = length interestingSubs < minimumSubs+        droppedAccount = accountNameLevel (aname acct) <= drop_ ropts+        interestingSubs = filter (anyAccounts (not . aboring)) $ asubs acct+        minimumSubs = if no_elide_ ropts then 1 else 2      isZeroRow balance = all (mixedAmountLooksZero . balance)-    depthspec = queryDepth query+    keepWhenEmpty = case accountlistmode_ ropts of+        ALFlat -> any ((0<) . bdnumpostings) . pdperiods . adata  -- Keep all accounts that have postings in flat mode+        ALTree -> null . asubs                                    -- Keep only empty leaves in tree mode+    maybeStripPrices = if conversionop_ ropts == Just NoConversionOp then id else mixedAmountStripCosts+     qdepthIsZero = depthspec == DepthSpec (Just 0) []-    numSubs = subaccountTallies . HM.keys $ HM.filterWithKey isInteresting valuedaccts+    depthspec = queryDepth query --- | Sort the rows by amount or by account declaration order.-sortRows :: ReportOpts -> Journal -> [MultiBalanceReportRow] -> [MultiBalanceReportRow]-sortRows ropts j-    | sort_amount_ ropts, ALTree <- accountlistmode_ ropts = sortTreeMBRByAmount-    | sort_amount_ ropts, ALFlat <- accountlistmode_ ropts = sortFlatMBRByAmount-    | otherwise                                            = sortMBRByAccountDeclaration+    markBoring   v a = a{aboring = v}+    markBoringBy f a = a{aboring = f a}+++-- | Build a report row.+--+-- Calculate the column totals. These are always the sum of column amounts.+generateMultiBalanceReport :: ReportOpts -> [DateSpan] -> Account BalanceData -> MultiBalanceReport+generateMultiBalanceReport ropts colspans =+    reportPercent ropts . generatePeriodicReport makeMultiBalanceReportRow bdincludingsubs id ropts colspans++-- | Lay out a set of postings grouped by date span into a regular matrix with rows+-- given by AccountName and columns by DateSpan, then generate a MultiBalanceReport+-- from the columns.+generatePeriodicReport :: Show c =>+    (forall a. ReportOpts -> (BalanceData -> MixedAmount) -> a -> Account b -> PeriodicReportRow a c)+    -> (b -> MixedAmount) -> (c -> MixedAmount)+    -> ReportOpts -> [DateSpan] -> Account b -> PeriodicReport DisplayName c+generatePeriodicReport makeRow treeAmt flatAmt ropts colspans acct =+    PeriodicReport colspans (buildAndSort acct) totalsrow   where-    -- Sort the report rows, representing a tree of accounts, by row total at each level.-    -- Similar to sortMBRByAccountDeclaration/sortAccountNamesByDeclaration.-    sortTreeMBRByAmount :: [MultiBalanceReportRow] -> [MultiBalanceReportRow]-    sortTreeMBRByAmount rows = mapMaybe (`HM.lookup` rowMap) sortedanames+    -- Build report rows and sort them+    buildAndSort = dbg5 "generatePeriodicReport buildAndSort" . case accountlistmode_ ropts of+        ALTree | sort_amount_ ropts -> buildRows . sortTreeByAmount+        ALFlat | sort_amount_ ropts -> sortFlatByAmount . buildRows+        _                           -> buildRows . sortAccountTreeByDeclaration++    buildRows = buildReportRows makeRow ropts++    -- Calculate column totals from the inclusive balances of the root account+    totalsrow = dbg5 "generatePeriodicReport totalsrow" $ makeRow ropts bdincludingsubs () acct++    sortTreeByAmount = case fromMaybe NormallyPositive $ normalbalance_ ropts of+        NormallyPositive -> sortAccountTreeOn (\r -> (Down $ amt r, aname r))+        NormallyNegative -> sortAccountTreeOn (\r -> (amt r, aname r))       where-        accounttree = accountTree "root" $ map prrFullName rows-        rowMap = HM.fromList $ map (\row -> (prrFullName row, row)) rows-        -- Set the inclusive balance of an account from the rows, or sum the-        -- subaccounts if it's not present-        accounttreewithbals = mapAccounts setibalance accounttree-        setibalance a = a{aibalance = maybe (maSum . map aibalance $ asubs a) prrTotal $-                                          HM.lookup (aname a) rowMap}-        sortedaccounttree = sortAccountTreeByAmount (fromMaybe NormallyPositive $ normalbalance_ ropts) accounttreewithbals-        sortedanames = map aname $ drop 1 $ flattenAccounts sortedaccounttree+        amt = mixedAmountStripCosts . sortKey . fmap treeAmt . pdperiods . adata+        sortKey = case balanceaccum_ ropts of+          PerPeriod -> maSum+          _         -> maybe nullmixedamt snd . IM.lookupMax -    -- Sort the report rows, representing a flat account list, by row total (and then account name).-    sortFlatMBRByAmount :: [MultiBalanceReportRow] -> [MultiBalanceReportRow]-    sortFlatMBRByAmount = case fromMaybe NormallyPositive $ normalbalance_ ropts of+    sortFlatByAmount = case fromMaybe NormallyPositive $ normalbalance_ ropts of         NormallyPositive -> sortOn (\r -> (Down $ amt r, prrFullName r))         NormallyNegative -> sortOn (\r -> (amt r, prrFullName r))-      where amt = mixedAmountStripCosts . prrTotal+      where amt = mixedAmountStripCosts . flatAmt . prrTotal -    -- Sort the report rows by account declaration order then account name.-    sortMBRByAccountDeclaration :: [MultiBalanceReportRow] -> [MultiBalanceReportRow]-    sortMBRByAccountDeclaration rows = sortRowsLike sortedanames rows+-- | Build the report rows.+-- One row per account, with account name info, row amounts, row total and row average.+-- Rows are sorted according to the order in the 'Account' tree.+buildReportRows :: forall b c.+                (ReportOpts -> (BalanceData -> MixedAmount) -> DisplayName -> Account b -> PeriodicReportRow DisplayName c)+                -> ReportOpts -> Account b -> [PeriodicReportRow DisplayName c]+buildReportRows makeRow ropts = mkRows True (-drop_ ropts) 0+  where+    -- Build the row for an account at a given depth with some number of boring parents+    mkRows :: Bool -> Int -> Int -> Account b -> [PeriodicReportRow DisplayName c]+    mkRows isRoot d boringParents acct+        -- Account is a boring root account, and should be bypassed entirely+        | aboring acct && isRoot         = buildSubrows d 0+        -- Account is boring and has been dropped, so should be skipped and move up the hierarchy+        | aboring acct && d < 0          = buildSubrows (d + 1) 0+        -- Account is boring, and we can omit boring parents, so we should omit but keep track+        | aboring acct && canOmitParents = buildSubrows d (boringParents + 1)+        -- Account is not boring or otherwise should be displayed.+        | otherwise = makeRow ropts balance displayname acct : buildSubrows (d + 1) 0       where-        sortedanames = sortAccountNamesByDeclaration j (tree_ ropts) $ map prrFullName rows+        displayname = displayedName d boringParents $ aname acct+        buildSubrows i b = concatMap (mkRows False i b) $ asubs acct --- | Build the report totals row.------ Calculate the column totals. These are always the sum of column amounts.-calculateTotalsRow :: ReportOpts -> [MultiBalanceReportRow] -> Int -> PeriodicReportRow () MixedAmount-calculateTotalsRow ropts rows colcount =-    PeriodicReportRow () coltotals grandtotal grandaverage-  where-    isTopRow row = flat_ ropts || not (any (`HM.member` rowMap) parents)-      where parents = init . expandAccountName $ prrFullName row-    rowMap = HM.fromList $ map (\row -> (prrFullName row, row)) rows+    canOmitParents = flat_ ropts || not (no_elide_ ropts)+    balance = case accountlistmode_ ropts of+        ALTree -> bdincludingsubs+        ALFlat -> bdexcludingsubs -    colamts = transpose . map prrAmounts $ filter isTopRow rows+    displayedName d boringParents name+        | d == 0 && name == "root" = DisplayName "..." "..." 0+        | otherwise = case accountlistmode_ ropts of+            ALTree -> DisplayName name leaf $ max 0 d+            ALFlat -> DisplayName name droppedName 0+      where+        leaf = accountNameFromComponents+               . reverse . take (boringParents + 1) . reverse+               $ accountNameComponents droppedName+        droppedName = accountNameDrop (drop_ ropts) name -    coltotals :: [MixedAmount] = dbg5 "coltotals" $ case colamts of-      [] -> replicate colcount nullmixedamt-      _ -> map maSum colamts -    -- Calculate the grand total and average. These are always the sum/average-    -- of the column totals.+-- | Build a report row.+--+-- Calculate the column totals. These are always the sum of column amounts.+makeMultiBalanceReportRow :: ReportOpts -> (BalanceData -> MixedAmount)+                          -> a -> Account BalanceData -> PeriodicReportRow a MixedAmount+makeMultiBalanceReportRow = makePeriodicReportRow nullmixedamt sumAndAverageMixedAmounts++-- | Build a report row.+--+-- Calculate the column totals. These are always the sum of column amounts.+makePeriodicReportRow :: c -> (IM.IntMap c -> (c, c))+                      -> ReportOpts -> (b -> c)+                      -> a -> Account b -> PeriodicReportRow a c+makePeriodicReportRow nullEntry totalAndAverage ropts balance name acct =+    PeriodicReportRow name (toList rowbals) rowtotal avg+  where+    rowbals = fmap balance . pdperiods $ adata acct+    (total, avg) = totalAndAverage rowbals     -- Total for a cumulative/historical report is always the last column.-    grandtotal = case balanceaccum_ ropts of-        PerPeriod -> maSum coltotals-        _         -> lastDef nullmixedamt coltotals-    grandaverage = averageMixedAmounts coltotals+    rowtotal = case balanceaccum_ ropts of+        PerPeriod -> total+        _         -> maybe nullEntry snd $ IM.lookupMax rowbals  -- | Map the report rows to percentages if needed reportPercent :: ReportOpts -> MultiBalanceReport -> MultiBalanceReport@@ -534,31 +495,6 @@         (perdivide rowtotal $ prrTotal totalrow)         (perdivide rowavg $ prrAverage totalrow) ---- | Transpose a Map of HashMaps to a HashMap of Maps.------ Makes sure that all DateSpans are present in all rows.-transposeMap :: [(DateSpan, HashMap AccountName a)]-             -> HashMap AccountName (Map DateSpan a)-transposeMap = foldr (uncurry addSpan) mempty-  where-    addSpan spn acctmap seen = HM.foldrWithKey (addAcctSpan spn) seen acctmap--    addAcctSpan spn acct a = HM.alter f acct-      where f = Just . M.insert spn a . fromMaybe mempty---- | A sorting helper: sort a list of things (eg report rows) keyed by account name--- to match the provided ordering of those same account names.-sortRowsLike :: [AccountName] -> [PeriodicReportRow DisplayName b] -> [PeriodicReportRow DisplayName b]-sortRowsLike sortedas rows = mapMaybe (`HM.lookup` rowMap) sortedas-  where rowMap = HM.fromList $ map (\row -> (prrFullName row, row)) rows---- | Given a list of account names, find all forking parent accounts, i.e.--- those which fork between different branches-subaccountTallies :: [AccountName] -> HashMap AccountName Int-subaccountTallies = foldr incrementParent mempty . expandAccountNames-  where incrementParent a = HM.insertWith (+) (parentAccountName a) 1- -- | A helper: what percentage is the second mixed amount of the first ? -- Keeps the sign of the first amount. -- Uses unifyMixedAmount to unify each argument and then divides them.@@ -572,26 +508,13 @@     return $ mixed [per $ if aquantity b' == 0 then 0 else aquantity a' / abs (aquantity b') * 100]   where errmsg = "Cannot calculate percentages if accounts have different commodities (Hint: Try --cost, -V or similar flags.)" --- Add the values of two accounts. Should be right-biased, since it's used--- in scanl, so other properties (such as anumpostings) stay in the right place-sumAcct :: Account -> Account -> Account-sumAcct Account{aibalance=i1,aebalance=e1} a@Account{aibalance=i2,aebalance=e2} =-    a{aibalance = i1 `maPlus` i2, aebalance = e1 `maPlus` e2}---- Subtract the values in one account from another. Should be left-biased.-subtractAcct :: Account -> Account -> Account-subtractAcct a@Account{aibalance=i1,aebalance=e1} Account{aibalance=i2,aebalance=e2} =-    a{aibalance = i1 `maMinus` i2, aebalance = e1 `maMinus` e2}---- | Extract period changes from a cumulative list-periodChanges :: Account -> Map k Account -> Map k Account-periodChanges start amtmap =-    M.fromDistinctAscList . zip dates $ zipWith subtractAcct amts (start:amts)-  where (dates, amts) = unzip $ M.toAscList amtmap- -- | Calculate a cumulative sum from a list of period changes.-cumulativeSum :: Account -> Map DateSpan Account -> Map DateSpan Account-cumulativeSum start = snd . M.mapAccum (\a b -> let s = sumAcct a b in (s, s)) start+cumulativeSum :: Traversable t => t BalanceData -> t BalanceData+cumulativeSum = snd . mapAccumL (\prev new -> let z = prev <> new in (z, z)) mempty++-- | Extract period changes from a cumulative list.+periodChanges :: Traversable t => t BalanceData -> t BalanceData+periodChanges = snd . mapAccumL (\prev new -> (new, opBalanceData maMinus new prev)) mempty  -- tests 
Hledger/Reports/PostingsReport.hs view
@@ -243,10 +243,10 @@     summarypes = map (, dateSpanAsPeriod spn) $ (if showempty then id else filter (not . mixedAmountLooksZero . pamount)) summaryps     anames = nubSort $ map paccount ps     -- aggregate balances by account, like ledgerFromJournal, then do depth-clipping-    accts = accountsFromPostings ps+    accts = accountsFromPostings (const Nothing) ps     balance a = maybe nullmixedamt bal $ lookupAccount a accts       where-        bal = if isclipped a then aibalance else aebalance+        bal = (if isclipped a then bdincludingsubs else bdexcludingsubs) . pdpre . adata         isclipped a' = maybe False (accountNameLevel a' >=) mdepth  
Hledger/Reports/ReportOptions.hs view
@@ -52,6 +52,7 @@   journalApplyValuationFromOptsWith,   mixedAmountApplyValuationAfterSumFromOptsWith,   valuationAfterSum,+  requiresHistorical,   intervalFromRawOpts,   queryFromFlags,   transactionDateFn,@@ -664,11 +665,17 @@       CalcGain -> id       _        -> journalToCost costop where costop = fromMaybe NoConversionOp $ conversionop_ ropts -    -- Find the end of the period containing this posting+    -- Find the "end" valuation date for this posting.+    -- With a report interval, this is the last day of the report subperiod containing this posting;+    -- with no interval it's the last date of the overall report period+    -- (which for an end value report may have been extended to include the latest non-future P directive).+    -- To get the period's last day, we subtract one from the (exclusive) period end date.     postingperiodend  = addDays (-1) . fromMaybe err . mPeriodEnd . postingDateOrDate2 (whichDate ropts)-    mPeriodEnd = case interval_ ropts of-        NoInterval -> const . spanEnd . fst $ reportSpan j rspec-        _          -> spanEnd <=< latestSpanContaining (historical : spans)+      where+        mPeriodEnd = case interval_ ropts of+          NoInterval -> const . spanEnd . fst $ reportSpan j rspec+          _          -> spanEnd <=< latestSpanContaining (historical : spans)+     historical = DateSpan Nothing $ (fmap Exact . spanStart) =<< headMay spans     spans = snd $ reportSpanBothDates j rspec     styles = journalCommodityStyles j@@ -677,21 +684,20 @@ -- | Select the Account valuation functions required for performing valuation after summing -- amounts. Used in MultiBalanceReport to value historical and similar reports. mixedAmountApplyValuationAfterSumFromOptsWith :: ReportOpts -> Journal -> PriceOracle-                                              -> (DateSpan -> MixedAmount -> MixedAmount)+                                              -> (Day -> MixedAmount -> MixedAmount) mixedAmountApplyValuationAfterSumFromOptsWith ropts j priceoracle =     case valuationAfterSum ropts of         Just mc -> case balancecalc_ ropts of             CalcGain -> gain mc-            _        -> \spn -> valuation mc spn . costing+            _        -> \d -> valuation mc d . costing         Nothing      -> const id   where-    valuation mc spn = mixedAmountValueAtDate priceoracle styles mc (maybe err (addDays (-1)) $ spanEnd spn)-    gain mc spn = mixedAmountGainAtDate priceoracle styles mc (maybe err (addDays (-1)) $ spanEnd spn)+    valuation mc d = mixedAmountValueAtDate priceoracle styles mc d+    gain mc d = mixedAmountGainAtDate priceoracle styles mc d     costing = case fromMaybe NoConversionOp $ conversionop_ ropts of         NoConversionOp -> id         ToCost         -> styleAmounts styles . mixedAmountCost     styles = journalCommodityStyles j-    err = error' "mixedAmountApplyValuationAfterSumFromOptsWith: expected all spans to have an end date"  -- | If the ReportOpts specify that we are performing valuation after summing amounts, -- return Just of the commodity symbol we're converting to, Just Nothing for the default,@@ -699,12 +705,15 @@ -- Used for example with historical reports with --value=end. valuationAfterSum :: ReportOpts -> Maybe (Maybe CommoditySymbol) valuationAfterSum ropts = case value_ ropts of-    Just (AtEnd mc) | valueAfterSum -> Just mc-    _                               -> Nothing-  where valueAfterSum = balancecalc_  ropts == CalcValueChange-                     || balancecalc_  ropts == CalcGain-                     || balanceaccum_ ropts /= PerPeriod+    Just (AtEnd mc) | requiresHistorical ropts -> Just mc+    _                                          -> Nothing +-- | If the ReportOpts specify that we will need to consider historical+-- postings, either because this is a historical report, or because the+-- valuation strategy requires historical amounts.+requiresHistorical :: ReportOpts -> Bool+requiresHistorical ReportOpts{balanceaccum_ = accum, balancecalc_ = calc} =+    accum == Historical || calc == CalcValueChange || calc == CalcGain  -- | Convert report options to a query, ignoring any non-flag command line arguments. queryFromFlags :: ReportOpts -> Query@@ -763,49 +772,59 @@  -- Report dates. --- | The effective report span is the start and end dates specified by--- options or queries, or otherwise the earliest and latest transaction or--- posting dates in the journal. If no dates are specified by options/queries--- and the journal is empty, returns the null date span.--- Also return the intervals if they are requested.+-- | The effective report span is the start and end dates requested by options or queries.+-- If the start date is unspecified, the earliest transaction or posting date is used.+-- If the end date is unspecified, the latest transaction or posting date+-- (or non-future market price date, when doing an end value report) is used.+-- If none of these things are present, the null date span is returned.+-- The report sub-periods caused by a report interval, if any, are also returned. reportSpan :: Journal -> ReportSpec -> (DateSpan, [DateSpan]) reportSpan = reportSpanHelper False+-- Note: In end value reports, the report end date and valuation date are the same.+-- If valuation date ever needs to be different, journalApplyValuationFromOptsWith is the place. --- | Like reportSpan, but uses both primary and secondary dates when calculating--- the span.+-- | Like reportSpan, but considers both primary and secondary dates, not just one or the other. reportSpanBothDates :: Journal -> ReportSpec -> (DateSpan, [DateSpan]) reportSpanBothDates = reportSpanHelper True --- | A helper for reportSpan, which takes a Bool indicating whether to use both--- primary and secondary dates. reportSpanHelper :: Bool -> Journal -> ReportSpec -> (DateSpan, [DateSpan])-reportSpanHelper bothdates j ReportSpec{_rsQuery=query, _rsReportOpts=ropts} =-    (reportspan, intervalspans)+reportSpanHelper bothdates j ReportSpec{_rsQuery=query, _rsReportOpts=ropts, _rsDay=today} =+  (enlargedreportspan, if not (null intervalspans) then intervalspans else [enlargedreportspan])   where     -- The date span specified by -b/-e/-p options and query args if any.-    requestedspan  = dbg3 "requestedspan" $ if bothdates then queryDateSpan' query else queryDateSpan (date2_ ropts) query-    -- If we are requesting period-end valuation, the journal date span should-    -- include price directives after the last transaction-    journalspan = dbg3 "journalspan" $ if bothdates then journalDateSpanBothDates j else journalDateSpan (date2_ ropts) j-    pricespan = dbg3 "pricespan" . DateSpan Nothing $ case value_ ropts of-        Just (AtEnd _) -> fmap (Exact . addDays 1) . maximumMay . map pddate $ jpricedirectives j-        _              -> Nothing-    -- If the requested span is open-ended, close it using the journal's start and end dates.-    -- This can still be the null (open) span if the journal is empty.-    requestedspan' = dbg3 "requestedspan'" $ requestedspan `spanDefaultsFrom` (journalspan `spanExtend` pricespan)+    requestedspan = dbg3 "requestedspan" $+      if bothdates then queryDateSpan' query else queryDateSpan (date2_ ropts) query++    -- If the requested span has open ends, fill them with defaults.+    reportspan = dbg3 "reportspan" $ requestedspan `spanValidDefaultsFrom` txnsorpricespan+      where+        txnsorpricespan = dbg3 "txnsorpricespan" $ DateSpan mfirsttxn mlatesttxnorprice+          where+            DateSpan mfirsttxn mlasttxn = dbg3 "txnsspan" $+              if bothdates then journalDateSpanBothDates j else journalDateSpan (date2_ ropts) j+            mlatesttxnorprice =+              case value_ ropts of+                Just (AtEnd _) -> mlasttxn `max` mlatestnonfutureprice+                _              -> mlasttxn+              where+                mlatestnonfutureprice = dbg3 "latestnonfutureprice" $ -- #2445+                  fmap (Exact . addDays 1) . maximumMay . filter (not . (> today)) . map pddate $ jpricedirectives j+     -- The list of interval spans enclosing the requested span.     -- This list can be empty if the journal was empty,     -- or if hledger-ui has added its special date:-tomorrow to the query     -- and all txns are in the future.-    intervalspans  = dbg3 "intervalspans" $ splitSpan adjust (interval_ ropts) requestedspan'+    intervalspans = dbg3 "intervalspans" $ splitSpan adjust (interval_ ropts) reportspan       where         -- When calculating report periods, we will adjust the start date back to the nearest interval boundary         -- unless a start date was specified explicitly.         adjust = isNothing $ spanStart requestedspan+     -- The requested span enlarged to enclose a whole number of intervals.     -- This can be the null span if there were no intervals.-    reportspan = dbg3 "reportspan" $ DateSpan (fmap Exact . spanStart =<< headMay intervalspans)-                                              (fmap Exact . spanEnd =<< lastMay intervalspans)+    enlargedreportspan = dbg3 "enlargedreportspan" $+      DateSpan (fmap Exact . spanStart =<< headMay intervalspans)+               (fmap Exact . spanEnd =<< lastMay intervalspans)  reportStartDate :: Journal -> ReportSpec -> Maybe Day reportStartDate j = spanStart . fst . reportSpan j
Hledger/Utils.hs view
@@ -24,6 +24,7 @@   minimumStrict,   splitAtElement,   sumStrict,+  all1,    -- * Trees   treeLeaves,@@ -173,6 +174,12 @@ {-# INLINABLE sumStrict #-} sumStrict :: Num a => [a] -> a sumStrict = foldl' (+) 0++-- | Version of all that fails on an empty list.+{-# INLINABLE all1 #-}+all1 :: (a -> Bool) -> [a] -> Bool+all1 _ [] = False+all1 p as = all p as  -- Trees 
Hledger/Utils/IO.hs view
@@ -23,6 +23,7 @@   error',   usageError,   warn,+  warnIO,   ansiFormatError,   ansiFormatWarning,   printError,@@ -52,6 +53,7 @@    -- * Command line parsing   progArgs,+  getFlag,   getOpt,   parseYN,   parseYNA,@@ -130,7 +132,6 @@ import           Data.Functor ((<&>)) import           Data.List hiding (uncons) import           Data.Maybe (isJust, catMaybes)-import           Data.Ord (comparing, Down (Down)) import qualified Data.Text as T import           Data.Text.Encoding.Error (UnicodeException) import qualified Data.Text.IO as T@@ -160,6 +161,7 @@ import           Text.Pretty.Simple (CheckColorTty(..), OutputOptions(..), defaultOutputOptionsDarkBg, defaultOutputOptionsNoColor, pShowOpt, pPrintOpt)  import Hledger.Utils.Text (WideBuilder(WideBuilder))+import Control.Monad.IO.Class (MonadIO, liftIO)   -- Pretty showing/printing@@ -217,17 +219,21 @@ ansiFormatError :: String -> String ansiFormatError = (<> sgrresetall) . ((sgrbrightred <> sgrbold) <>) --- | Show a message, with "Warning:" label, on stderr before returning the given value.--- Also do some ANSI styling of the first line when allowed (using unsafe IO).--- Currently we use this very sparingly in hledger; we prefer to either quietly work,--- or loudly raise an error. (Varying output can make scripting harder.)+-- | Show a warning message on stderr before returning the given value.+-- Like trace, but prepends a "Warning:" label, and does some ANSI styling of the first line when allowed (using unsafe IO).+-- Currently we use this very sparingly in hledger; we prefer to either quietly work, or loudly raise an error.+-- Varying output can make scripting harder. But on stderr, it shouldn't cause much hassle. warn :: String -> a -> a-warn msg = trace msg'-  where-    msg' =-      (if useColorOnStderrUnsafe then modifyFirstLine ansiFormatWarning else id) $-      "Warning: "<> msg+warn = trace . formatWarning +-- | Like warn, but take extra care to sequence properly in IO.+warnIO :: MonadIO m => String -> m ()+warnIO = liftIO . traceIO . formatWarning++formatWarning =+  (if useColorOnStderrUnsafe then modifyFirstLine ansiFormatWarning else id) .+  ("Warning: " <>)+ -- | Apply standard ANSI SGR formatting (yellow, bold) suitable for console warning text. ansiFormatWarning :: String -> String ansiFormatWarning = (<> sgrresetall) . ((sgrbrightyellow <> sgrbold) <>)@@ -238,9 +244,8 @@ modifyFirstLine :: (String -> String) -> String -> String modifyFirstLine f s = intercalate "\n" $ map f l <> ls where (l,ls) = splitAt 1 $ lines s  -- total -{- | Print an error message to stderr, with a consistent "programname: " prefix,-and applying ANSI styling (bold bright red) to the first line if that is supported and allowed.--}+-- | Print an error message to stderr, with a consistent "programname: " prefix,+-- and applying ANSI styling (bold bright red) to the first line if that is supported and allowed. printError :: String -> IO () printError msg = do   progname <- getProgName@@ -256,9 +261,8 @@         <> (if "Error:" `isPrefixOf` msg then "" else "Error: ")   hPutStrLn stderr $ style $ prefix <> msg -{- | Print an error message with printError,-then exit the program with a non-zero exit code.--}+-- | Print an error message with printError,+-- then exit the program with a non-zero exit code. exitWithErrorMessage :: String -> IO () exitWithErrorMessage msg = printError msg >> exitFailure @@ -415,11 +419,11 @@ expandGlob :: FilePath -> FilePath -> IO [FilePath] expandGlob curdir p = expandPath curdir p >>= glob <&> sort  -- PARTIAL: --- | Given a list of existing file paths, sort them by modification time, most recent first.+-- | Given a list of existing file paths, sort them by modification time (from oldest to newest). sortByModTime :: [FilePath] -> IO [FilePath] sortByModTime fs = do   ftimes <- forM fs $ \f -> do {t <- getModificationTime f; return (t,f)}-  return $ map snd $ sortBy (comparing Data.Ord.Down) ftimes+  return $ map snd $ sort ftimes  -- | Like readFilePortably, but read all of the file before proceeding. readFileStrictly :: FilePath -> IO T.Text@@ -502,6 +506,15 @@ --  a few cases involving --color (see useColorOnStdoutUnsafe) --  --debug +-- | Given one or more long or short flag names,+-- report whether this flag is present in the command line.+-- Concatenated short flags (-a -b written as -ab) are not supported.+getFlag :: [String] -> IO Bool+getFlag names = do+  let flags = map toFlag names+  args <- getArgs+  return $ any (`elem` args) flags+ -- | Given one or more long or short option names, read the rightmost value of this option from the command line arguments. -- If the value is missing raise an error. -- Concatenated short flags (-a -b written as -ab) are not supported.@@ -614,7 +627,6 @@ --   --chop-long-lines --   --hilite-unread --   --ignore-case---   --mouse --   --no-init --   --quit-at-eof --   --quit-if-one-screen@@ -636,7 +648,6 @@        "--chop-long-lines"       ,"--hilite-unread"       ,"--ignore-case"-      ,"--mouse"       ,"--no-init"       ,"--quit-at-eof"       ,"--quit-if-one-screen"@@ -765,7 +776,7 @@  -- | Detect whether ANSI should be used on stdout using useColorOnStdoutUnsafe, -- and if so prepend and append the given SGR codes to a string.--- Currently used in a few places (the commands list, the demo command, the recentassertions error message);+-- Currently used in a few places (the commands list, the recentassertions error message, add, demo); -- see useColorOnStdoutUnsafe's limitations. ansiWrapUnsafe :: SGRString -> SGRString -> String -> String ansiWrapUnsafe pre post s = if useColorOnStdoutUnsafe then pre<>s<>post else s
Hledger/Utils/Parse.hs view
@@ -101,10 +101,9 @@ import Data.List import Data.Text (Text) import Text.Megaparsec.Char--- import Text.Megaparsec.Debug (dbg)  -- from megaparsec 9.3++-- import Text.Megaparsec.Debug (dbg)  import Control.Monad.Except (ExceptT, MonadError, catchError, throwError)--- import Control.Monad.State.Strict (StateT, evalStateT) import Control.Monad.Trans.Class (lift) import qualified Data.List.NonEmpty as NE import Data.Monoid (Alt(..))@@ -165,13 +164,11 @@ parsewith p = runParser p ""  -- | Run a text parser in the identity monad. See also: parseWithState.-runTextParser, rtp-  :: TextParser Identity a -> Text -> Either HledgerParseErrors a+runTextParser, rtp :: TextParser Identity a -> Text -> Either HledgerParseErrors a runTextParser = parsewith rtp = runTextParser -parsewithString-  :: Parsec e String a -> String -> Either (ParseErrorBundle String e) a+parsewithString :: Parsec e String a -> String -> Either (ParseErrorBundle String e) a parsewithString p = runParser p ""  -- | Run a stateful parser with some initial state on a text.@@ -192,20 +189,16 @@   -> (Either (ParseErrorBundle s e) a) parseWithState' ctx p = runParser (evalStateT p ctx) "" -fromparse-  :: (Show t, Show (Token t), Show e) => Either (ParseErrorBundle t e) a -> a+fromparse :: (Show t, Show (Token t), Show e) => Either (ParseErrorBundle t e) a -> a fromparse = either parseerror id  parseerror :: (Show t, Show (Token t), Show e) => ParseErrorBundle t e -> a parseerror e = errorWithoutStackTrace $ showParseError e  -- PARTIAL: -showParseError-  :: (Show t, Show (Token t), Show e)-  => ParseErrorBundle t e -> String+showParseError :: (Show t, Show (Token t), Show e) => ParseErrorBundle t e -> String showParseError e = "parse error at " ++ show e -showDateParseError-  :: (Show t, Show (Token t), Show e) => ParseErrorBundle t e -> String+showDateParseError :: (Show t, Show (Token t), Show e) => ParseErrorBundle t e -> String showDateParseError e = printf "date parse error (%s)" (intercalate ", " $ tailErr $ lines $ show e)  -- PARTIAL tailError won't be null because showing a parse error  isNewline :: Char -> Bool @@ -295,7 +288,6 @@ -- | Fail at a specific source position, given by the raw offset from the -- start of the input stream (the number of tokens processed at that -- point).- parseErrorAt :: Int -> String -> HledgerParseErrorData parseErrorAt offset = ErrorFailAt offset (offset+1) @@ -305,7 +297,6 @@ -- -- Note that care must be taken to ensure that the specified interval does -- not span multiple lines of the input source. This will not be checked.- parseErrorAtRegion   :: Int    -- ^ Start offset   -> Int    -- ^ End end offset@@ -325,12 +316,10 @@ -- data type is to preserve the content and source position of the excerpt -- so that parse errors raised during "re-parsing" may properly reference -- the original source.- data SourceExcerpt = SourceExcerpt Int  -- Offset of beginning of excerpt                                    Text -- Fragment of source file  -- | Get the raw text of a source excerpt.- getExcerptText :: SourceExcerpt -> Text getExcerptText (SourceExcerpt _ txt) = txt @@ -454,22 +443,17 @@ -- (1) it should be possible to convert any parse error into a "final" -- parse error, -- (2) it should be possible to take a parse error thrown from an include--- file and re-throw it in the parent file, and+-- file and re-throw it in the context of the parent file, and -- (3) the pretty-printing of "final" parse errors should be consistent--- with that of ordinary parse errors, but should also report a stack of--- files for errors thrown from include files.+-- with that of ordinary parse errors, but should also report the stack of+-- parent files when errors are thrown from included files. -- -- In order to pretty-print a "final" parse error (goal 3), it must be -- bundled with include filepaths and its full source text. When a "final" -- parse error is thrown from within a parser, we do not have access to--- the full source, so we must hold the parse error until it can be joined--- with its source (and include filepaths, if it was thrown from an--- include file) by the parser's caller.------ A parse error with include filepaths and its full source text is--- represented by the 'FinalParseErrorBundle' type, while a parse error in--- need of either include filepaths, full source text, or both is--- represented by the 'FinalParseError' type.+-- the full source, so we must hold the parse error ('FinalParseError') +-- until it can be combined with the full source (and any parent file paths)+-- by the parser's caller ('FinalParseErrorBundle').  data FinalParseError' e   -- a parse error thrown as a "final" parse error@@ -502,7 +486,6 @@ -- Megaparsec's 'ParseErrorBundle' type already bundles a parse error with -- its full source text and filepath, so we just add a stack of include -- files.- data FinalParseErrorBundle' e = FinalParseErrorBundle'   { finalErrorBundle :: ParseErrorBundle Text e   , includeFileStack :: [FilePath]@@ -514,12 +497,10 @@ --- * Constructing and throwing final parse errors  -- | Convert a "regular" parse error into a "final" parse error.- finalError :: ParseError Text e -> FinalParseError' e finalError = FinalError  -- | Like megaparsec's 'fancyFailure', but as a "final" parse error.- finalFancyFailure   :: (MonadParsec e s m, MonadError (FinalParseError' e) m)   => S.Set (ErrorFancy e) -> m a@@ -528,25 +509,19 @@   throwError $ FinalError $ FancyError offset errSet  -- | Like 'fail', but as a "final" parse error.--finalFail-  :: (MonadParsec e s m, MonadError (FinalParseError' e) m) => String -> m a+finalFail :: (MonadParsec e s m, MonadError (FinalParseError' e) m) => String -> m a finalFail = finalFancyFailure . S.singleton . ErrorFail  -- | Like megaparsec's 'customFailure', but as a "final" parse error.--finalCustomFailure-  :: (MonadParsec e s m, MonadError (FinalParseError' e) m) => e -> m a+finalCustomFailure :: (MonadParsec e s m, MonadError (FinalParseError' e) m) => e -> m a finalCustomFailure = finalFancyFailure . S.singleton . ErrorCustom   --- * Pretty-printing "final" parse errors  -- | Pretty-print a "final" parse error: print the stack of include files,--- then apply the pretty-printer for parse error bundles. Note that--- 'attachSource' must be used on a "final" parse error before it can be--- pretty-printed.-+-- then apply the pretty-printer for parse error bundles.+-- Note that 'attachSource' must be used on a "final" parse error before it can be pretty-printed. finalErrorBundlePretty :: FinalParseErrorBundle' HledgerParseErrorData -> String finalErrorBundlePretty bundle =      concatMap showIncludeFilepath (includeFileStack bundle)@@ -554,12 +529,9 @@   where     showIncludeFilepath path = "in file included from " <> path <> ",\n" --- | Supply a filepath and source text to a "final" parse error so that it--- can be pretty-printed. You must ensure that you provide the appropriate--- source text and filepath.--attachSource-  :: FilePath -> Text -> FinalParseError' e -> FinalParseErrorBundle' e+-- | Attach a filepath and source text to a "final" parse error so that it can be pretty-printed.+-- You must ensure that you provide the appropriate source text and filepath.+attachSource :: FilePath -> Text -> FinalParseError' e -> FinalParseErrorBundle' e attachSource filePath sourceText finalParseError = case finalParseError of    -- A parse error thrown directly with the 'FinalError' constructor@@ -586,9 +558,9 @@  --- * Handling parse errors from include files with "final" parse errors --- | Parse a file with the given parser and initial state, discarding the--- final state and re-throwing any parse errors as "final" parse errors.-+-- | Parse an include file with the given parser and initial state,+-- discarding the resulting state,+-- and re-throwing any parse errors as final parse errors with the file's info attached. parseIncludeFile   :: Monad m   => StateT st (ParsecT HledgerParseErrorData Text (ExceptT FinalParseError m)) a@@ -596,26 +568,22 @@   -> FilePath   -> Text   -> StateT st (ParsecT HledgerParseErrorData Text (ExceptT FinalParseError m)) a-parseIncludeFile parser initialState filepath text =-  catchError parser' handler+parseIncludeFile parser initialState filepath text = catchError parser' handler   where     parser' = do-      eResult <- lift $ lift $-                  runParserT (evalStateT parser initialState) filepath text+      eResult <- lift $ lift $ runParserT (evalStateT parser initialState) filepath text       case eResult of         Left parseErrorBundle -> throwError $ FinalBundle parseErrorBundle         Right result -> pure result-     -- Attach source and filepath of the include file to its parse errors     handler e = throwError $ FinalBundleWithStack $ attachSource filepath text e   --- * Helpers --- Like megaparsec's 'initialState', but instead for 'PosState'. Used when--- constructing 'ParseErrorBundle's. The values for "tab width" and "line--- prefix" are taken from 'initialState'.-+-- | Like megaparsec's 'initialState', but instead for 'PosState'.+-- Used when constructing 'ParseErrorBundle's.+-- The values for "tab width" and "line prefix" are taken from 'initialState'. initialPosState :: FilePath -> Text -> PosState Text initialPosState filePath sourceText = PosState   { pstateInput      = sourceText
Hledger/Utils/Regex.hs view
@@ -78,8 +78,7 @@ import Text.Regex.TDFA (   Regex, CompOption(..), defaultCompOpt, defaultExecOpt,   makeRegexOptsM, AllMatches(getAllMatches), match, MatchText,-  RegexLike(..), RegexMaker(..), RegexOptions(..), RegexContext(..),-  (=~)+  RegexLike(..), RegexMaker(..), RegexOptions(..), RegexContext(..)   )  @@ -179,8 +178,8 @@ -- Regex to a Text. regexMatchTextGroups :: Regexp -> Text -> [Text] regexMatchTextGroups r txt = let-    pat = reString r-    (_,_,_,matches) = txt =~ pat :: (Text,Text,Text,[Text])+    pat = reCompiled r+    (_,_,_,matches) = match pat txt :: (Text,Text,Text,[Text])     in matches  --------------------------------------------------------------------------------
hledger-lib.cabal view
@@ -1,11 +1,11 @@ cabal-version: 2.2 --- This file has been generated from package.yaml by hpack version 0.38.0.+-- This file has been generated from package.yaml by hpack version 0.38.2. -- -- see: https://github.com/sol/hpack  name:           hledger-lib-version:        1.43.2+version:        1.50 synopsis:       A library providing the core functionality of hledger description:    This library contains hledger's core functionality.                 It is used by most hledger* packages so that they support the same@@ -34,7 +34,7 @@ license-file:   LICENSE build-type:     Simple tested-with:-    ghc==8.10.7, ghc==9.0.2, ghc==9.2.8, ghc==9.4.8, ghc==9.6.7, ghc==9.8.4, ghc==9.10.2, ghc==9.12.1+    ghc==9.6.7, ghc==9.8.4, ghc==9.10.2, ghc==9.12.2 extra-source-files:     CHANGES.md     README.md@@ -115,6 +115,8 @@       Text.Tabular.AsciiWide       Text.WideString   other-modules:+      Hledger.Data.BalanceData+      Hledger.Data.PeriodData       Paths_hledger_lib   autogen-modules:       Paths_hledger_lib@@ -128,7 +130,7 @@     , aeson-pretty     , ansi-terminal >=0.9     , array-    , base >=4.14 && <4.22+    , base >=4.18 && <4.22     , blaze-html     , blaze-markup >=0.5.1     , bytestring@@ -163,6 +165,7 @@     , template-haskell     , terminal-size >=0.3.3     , text >=1.2.4.1+    , these >=1.0.0     , time >=1.5     , timeit     , transformers >=0.2@@ -186,7 +189,7 @@     , aeson-pretty     , ansi-terminal >=0.9     , array-    , base >=4.14 && <4.22+    , base >=4.18 && <4.22     , blaze-html     , blaze-markup >=0.5.1     , bytestring@@ -222,6 +225,7 @@     , template-haskell     , terminal-size >=0.3.3     , text >=1.2.4.1+    , these >=1.0.0     , time >=1.5     , timeit     , transformers >=0.2@@ -247,7 +251,7 @@     , aeson-pretty     , ansi-terminal >=0.9     , array-    , base >=4.14 && <4.22+    , base >=4.18 && <4.22     , blaze-html     , blaze-markup >=0.5.1     , bytestring@@ -283,6 +287,7 @@     , template-haskell     , terminal-size >=0.3.3     , text >=1.2.4.1+    , these >=1.0.0     , time >=1.5     , timeit     , transformers >=0.2