packages feed

hledger-lib 0.17 → 0.18

raw patch · 25 files changed

+3636/−1473 lines, 25 filesdep +csvdep +shakespeare-textPVP ok

version bump matches the API change (PVP)

Dependencies added: csv, shakespeare-text

API changes (from Hackage documentation)

- Hledger.Data.Dates: d :: GenParser Char st SmartDate
- Hledger.Data.Dates: dateSpanAsText :: DateSpan -> String
- Hledger.Data.Dates: dateperiodexpr :: Day -> GenParser Char st (Interval, DateSpan)
- Hledger.Data.Dates: doubledatespan :: Day -> GenParser Char st DateSpan
- Hledger.Data.Dates: failIfInvalidYear, failIfInvalidDay, failIfInvalidMonth :: Monad m => String -> m ()
- Hledger.Data.Dates: fromdatespan :: Day -> GenParser Char st DateSpan
- Hledger.Data.Dates: intervalanddateperiodexpr :: Day -> GenParser Char st (Interval, DateSpan)
- Hledger.Data.Dates: intervalperiodexpr :: GenParser Char st (Interval, DateSpan)
- Hledger.Data.Dates: justdatespan :: Day -> GenParser Char st DateSpan
- Hledger.Data.Dates: lastthisnextthing :: GenParser Char st SmartDate
- Hledger.Data.Dates: md :: GenParser Char st SmartDate
- Hledger.Data.Dates: mon :: GenParser Char st SmartDate
- Hledger.Data.Dates: month :: GenParser Char st SmartDate
- Hledger.Data.Dates: parsedateM :: String -> Maybe Day
- Hledger.Data.Dates: parsedatetime :: String -> LocalTime
- Hledger.Data.Dates: parsedatetimeM :: String -> Maybe LocalTime
- Hledger.Data.Dates: parsetimewith :: ParseTime t => String -> String -> t -> t
- Hledger.Data.Dates: periodexpr :: Day -> GenParser Char st (Interval, DateSpan)
- Hledger.Data.Dates: periodexprdatespan :: Day -> GenParser Char st DateSpan
- Hledger.Data.Dates: reportinginterval :: GenParser Char st Interval
- Hledger.Data.Dates: showDay :: Day -> String
- Hledger.Data.Dates: smartdateonly :: GenParser Char st SmartDate
- Hledger.Data.Dates: spanFromSmartDate :: Day -> SmartDate -> DateSpan
- Hledger.Data.Dates: spanFromSmartDateString :: Day -> String -> DateSpan
- Hledger.Data.Dates: splitspan :: (Day -> Day) -> (Day -> Day) -> DateSpan -> [DateSpan]
- Hledger.Data.Dates: todatespan :: Day -> GenParser Char st DateSpan
- Hledger.Data.Dates: today, tomorrow, yesterday :: GenParser Char st SmartDate
- Hledger.Data.Dates: validYear, validDay, validMonth :: String -> Bool
- Hledger.Data.Dates: y :: GenParser Char st SmartDate
- Hledger.Data.Dates: ym :: GenParser Char st SmartDate
- Hledger.Data.Dates: ymd :: GenParser Char st SmartDate
- Hledger.Data.Dates: yyyymmdd :: GenParser Char st SmartDate
- Hledger.Data.Journal: amountCommodities :: Amount -> [Commodity]
- Hledger.Data.Journal: calculateBalances :: Tree AccountName -> (AccountName -> [Posting]) -> Tree (AccountName, (MixedAmount, MixedAmount))
- Hledger.Data.Journal: filterJournalPostings2 :: Matcher -> Journal -> Journal
- Hledger.Data.Journal: filterJournalPostingsByAccount :: [String] -> Journal -> Journal
- Hledger.Data.Journal: filterJournalPostingsByClearedStatus :: Maybe Bool -> Journal -> Journal
- Hledger.Data.Journal: filterJournalPostingsByDepth :: Maybe Int -> Journal -> Journal
- Hledger.Data.Journal: filterJournalPostingsByEmpty :: Bool -> Journal -> Journal
- Hledger.Data.Journal: filterJournalPostingsByRealness :: Bool -> Journal -> Journal
- Hledger.Data.Journal: filterJournalTransactions2 :: Matcher -> Journal -> Journal
- Hledger.Data.Journal: filterJournalTransactionsByAccount :: [String] -> Journal -> Journal
- Hledger.Data.Journal: filterJournalTransactionsByClearedStatus :: Maybe Bool -> Journal -> Journal
- Hledger.Data.Journal: filterJournalTransactionsByDate :: DateSpan -> Journal -> Journal
- Hledger.Data.Journal: filterJournalTransactionsByDepth :: Maybe Int -> Journal -> Journal
- Hledger.Data.Journal: filterJournalTransactionsByDescription :: [String] -> Journal -> Journal
- Hledger.Data.Journal: journalAccountNameTree :: Journal -> Tree AccountName
- Hledger.Data.Journal: journalAmountCommodities :: Journal -> [Commodity]
- Hledger.Data.Journal: journalApplyHistoricalPrices :: Journal -> Journal
- Hledger.Data.Journal: journalCloseTimeLogEntries :: LocalTime -> Journal -> Journal
- Hledger.Data.Journal: journalHistoricalPriceFor :: Journal -> Day -> Commodity -> Maybe MixedAmount
- Hledger.Data.Journal: mainfile :: Journal -> (FilePath, String)
- Hledger.Data.Journal: nullfilterspec :: FilterSpec
- Hledger.Data.Journal: postingsByAccount :: [Posting] -> Map AccountName [Posting]
- Hledger.Data.Ledger: account :: Ledger -> AccountName -> Account
- Hledger.Data.Ledger: accountnames :: Ledger -> [AccountName]
- Hledger.Data.Ledger: accounts :: Ledger -> [Account]
- Hledger.Data.Ledger: accountsmatching :: [String] -> Ledger -> [Account]
- Hledger.Data.Ledger: accounttree :: Int -> Ledger -> Tree Account
- Hledger.Data.Ledger: accounttreeat :: Ledger -> Account -> Maybe (Tree Account)
- Hledger.Data.Ledger: commodities :: Ledger -> Map String Commodity
- Hledger.Data.Ledger: journalToLedger2 :: Matcher -> Journal -> Ledger
- Hledger.Data.Ledger: ledgeramounts :: Ledger -> [MixedAmount]
- Hledger.Data.Ledger: postings :: Ledger -> [Posting]
- Hledger.Data.Ledger: rawdatespan :: Ledger -> DateSpan
- Hledger.Data.Ledger: subaccounts :: Ledger -> Account -> [Account]
- Hledger.Data.Ledger: topaccounts :: Ledger -> [Account]
- Hledger.Data.Matching: MatchAcct :: String -> Matcher
- Hledger.Data.Matching: MatchAnd :: [Matcher] -> Matcher
- Hledger.Data.Matching: MatchAny :: Matcher
- Hledger.Data.Matching: MatchDate :: DateSpan -> Matcher
- Hledger.Data.Matching: MatchDepth :: Int -> Matcher
- Hledger.Data.Matching: MatchDesc :: String -> Matcher
- Hledger.Data.Matching: MatchEDate :: DateSpan -> Matcher
- Hledger.Data.Matching: MatchEmpty :: Bool -> Matcher
- Hledger.Data.Matching: MatchNone :: Matcher
- Hledger.Data.Matching: MatchNot :: Matcher -> Matcher
- Hledger.Data.Matching: MatchOr :: [Matcher] -> Matcher
- Hledger.Data.Matching: MatchReal :: Bool -> Matcher
- Hledger.Data.Matching: MatchStatus :: Bool -> Matcher
- Hledger.Data.Matching: QueryOptInAcct :: AccountName -> QueryOpt
- Hledger.Data.Matching: QueryOptInAcctOnly :: AccountName -> QueryOpt
- Hledger.Data.Matching: compareMaybeDates :: Maybe Day -> Maybe Day -> Ordering
- Hledger.Data.Matching: data Matcher
- Hledger.Data.Matching: data QueryOpt
- Hledger.Data.Matching: earliestMaybeDate :: [Maybe Day] -> Maybe Day
- Hledger.Data.Matching: inAccount :: [QueryOpt] -> Maybe (AccountName, Bool)
- Hledger.Data.Matching: inAccountMatcher :: [QueryOpt] -> Maybe Matcher
- Hledger.Data.Matching: instance Eq Matcher
- Hledger.Data.Matching: instance Eq QueryOpt
- Hledger.Data.Matching: instance Show Matcher
- Hledger.Data.Matching: instance Show QueryOpt
- Hledger.Data.Matching: latestMaybeDate :: [Maybe Day] -> Maybe Day
- Hledger.Data.Matching: matcherIsStartDateOnly :: Bool -> Matcher -> Bool
- Hledger.Data.Matching: matcherStartDate :: Bool -> Matcher -> Maybe Day
- Hledger.Data.Matching: matchesAccount :: Matcher -> AccountName -> Bool
- Hledger.Data.Matching: matchesPosting :: Matcher -> Posting -> Bool
- Hledger.Data.Matching: matchesTransaction :: Matcher -> Transaction -> Bool
- Hledger.Data.Matching: negateMatcher :: Matcher -> Matcher
- Hledger.Data.Matching: parseBool :: String -> Bool
- Hledger.Data.Matching: parseMatcher :: Day -> String -> Either Matcher QueryOpt
- Hledger.Data.Matching: parseQuery :: Day -> String -> (Matcher, [QueryOpt])
- Hledger.Data.Matching: parseStatus :: String -> Bool
- Hledger.Data.Matching: postingEffectiveDate :: Posting -> Maybe Day
- Hledger.Data.Matching: tests_Hledger_Data_Matching :: Test
- Hledger.Data.Matching: truestrings :: [String]
- Hledger.Data.Matching: words'' :: [String] -> String -> [String]
- Hledger.Data.Transaction: nonzerobalanceerror :: Transaction -> String
- Hledger.Data.Transaction: settxn :: Transaction -> Posting -> Posting
- Hledger.Data.Transaction: showTransaction' :: Bool -> Transaction -> String
- Hledger.Data.Types: FilterSpec :: DateSpan -> Maybe Bool -> Bool -> Bool -> [String] -> [String] -> Maybe Int -> FilterSpec
- Hledger.Data.Types: accountmap :: Ledger -> Map AccountName Account
- Hledger.Data.Types: accountnametree :: Ledger -> Tree AccountName
- Hledger.Data.Types: acctpats :: FilterSpec -> [String]
- Hledger.Data.Types: cleared :: FilterSpec -> Maybe Bool
- Hledger.Data.Types: data FilterSpec
- Hledger.Data.Types: datespan :: FilterSpec -> DateSpan
- Hledger.Data.Types: depth :: FilterSpec -> Maybe Int
- Hledger.Data.Types: descpats :: FilterSpec -> [String]
- Hledger.Data.Types: empty :: FilterSpec -> Bool
- Hledger.Data.Types: instance Show FilterSpec
- Hledger.Data.Types: journal :: Ledger -> Journal
- Hledger.Data.Types: pmetadata :: Posting -> [(String, String)]
- Hledger.Data.Types: real :: FilterSpec -> Bool
- Hledger.Data.Types: tmetadata :: Transaction -> [(String, String)]
- Hledger.Read: ensureJournalFile :: FilePath -> IO ()
- Hledger.Read: journalFromPathAndString :: Maybe String -> FilePath -> String -> IO (Either String Journal)
- Hledger.Read: journaldefaultfilename :: [Char]
- Hledger.Read: journalenvvar :: [Char]
- Hledger.Read: ledgeraccountname :: GenParser Char st AccountName
- Hledger.Read: myJournal :: IO Journal
- Hledger.Read: myJournalPath :: IO String
- Hledger.Read: requireJournalFile :: FilePath -> IO ()
- Hledger.Read: someamount :: GenParser Char JournalContext MixedAmount
- Hledger.Read.JournalReader: emptyLine :: GenParser Char JournalContext ()
- Hledger.Read.JournalReader: journalAddFile :: (FilePath, String) -> Journal -> Journal
- Hledger.Read.JournalReader: journalFile :: GenParser Char JournalContext (JournalUpdate, JournalContext)
- Hledger.Read.JournalReader: ledgerDefaultYear :: GenParser Char JournalContext JournalUpdate
- Hledger.Read.JournalReader: ledgerDirective :: GenParser Char JournalContext JournalUpdate
- Hledger.Read.JournalReader: ledgerHistoricalPrice :: GenParser Char JournalContext HistoricalPrice
- Hledger.Read.JournalReader: ledgeraccountname :: GenParser Char st AccountName
- Hledger.Read.JournalReader: ledgerdatetime :: GenParser Char JournalContext LocalTime
- Hledger.Read.JournalReader: someamount :: GenParser Char JournalContext MixedAmount
- Hledger.Read.Utils: addAccountAlias :: (AccountName, AccountName) -> GenParser tok JournalContext ()
- Hledger.Read.Utils: clearAccountAliases :: GenParser tok JournalContext ()
- Hledger.Read.Utils: expandPath :: MonadIO m => SourcePos -> FilePath -> m FilePath
- Hledger.Read.Utils: fileSuffix :: FilePath -> String
- Hledger.Read.Utils: getAccountAliases :: GenParser tok JournalContext [(AccountName, AccountName)]
- Hledger.Read.Utils: getCommodity :: GenParser tok JournalContext (Maybe Commodity)
- Hledger.Read.Utils: getParentAccount :: GenParser tok JournalContext String
- Hledger.Read.Utils: getYear :: GenParser tok JournalContext (Maybe Integer)
- Hledger.Read.Utils: juSequence :: [JournalUpdate] -> JournalUpdate
- Hledger.Read.Utils: parseJournalWith :: (GenParser Char JournalContext (JournalUpdate, JournalContext)) -> FilePath -> String -> ErrorT String IO Journal
- Hledger.Read.Utils: popParentAccount :: GenParser tok JournalContext ()
- Hledger.Read.Utils: pushParentAccount :: String -> GenParser tok JournalContext ()
- Hledger.Read.Utils: setCommodity :: Commodity -> GenParser tok JournalContext ()
- Hledger.Read.Utils: setYear :: Integer -> GenParser tok JournalContext ()
- Hledger.Reports: accountsReport2 :: ReportOpts -> Matcher -> Journal -> AccountsReport
- Hledger.Reports: optsToFilterSpec :: ReportOpts -> Day -> FilterSpec
- Hledger.Reports: patterns_ :: ReportOpts -> [String]
- Hledger.Utils: fromPlatformString :: PlatformString -> String
- Hledger.Utils: tfilter :: (Test -> Bool) -> Test -> Test
- Hledger.Utils: tflatten :: Test -> [Test]
- Hledger.Utils: tname :: Test -> String
- Hledger.Utils: toPlatformString :: String -> PlatformString
- Hledger.Utils: type PlatformString = String
- Hledger.Utils.UTF8: appendFile :: FilePath -> String -> IO ()
- Hledger.Utils.UTF8: getContents :: IO String
- Hledger.Utils.UTF8: hGetContents :: Handle -> IO String
- Hledger.Utils.UTF8: hPutStr :: Handle -> String -> IO ()
- Hledger.Utils.UTF8: hPutStrLn :: Handle -> String -> IO ()
- Hledger.Utils.UTF8: putStr :: String -> IO ()
- Hledger.Utils.UTF8: putStrLn :: String -> IO ()
- Hledger.Utils.UTF8: readFile :: FilePath -> IO String
- Hledger.Utils.UTF8: writeFile :: FilePath -> String -> IO ()
+ Hledger: tests_Hledger :: Test
+ Hledger.Data.Account: nullacct :: Account
+ Hledger.Data.Account: tests_Hledger_Data_Account :: Test
+ Hledger.Data.AccountName: accountNameTreeFrom1 :: [AccountName] -> Tree [Char]
+ Hledger.Data.AccountName: accountNameTreeFrom2 :: [AccountName] -> Tree [Char]
+ Hledger.Data.AccountName: accountNameTreeFrom3 :: [AccountName] -> Tree [Char]
+ Hledger.Data.AccountName: acctsepchar :: Char
+ Hledger.Data.AccountName: emptyTree :: Tree' a
+ Hledger.Data.AccountName: nullaccountnametree :: Tree [Char]
+ Hledger.Data.AccountName: tests_Hledger_Data_AccountName :: Test
+ Hledger.Data.Amount: missingmixedamt :: MixedAmount
+ Hledger.Data.Commodity: defaultcommodities :: [Commodity]
+ Hledger.Data.Commodity: dollar :: Commodity
+ Hledger.Data.Commodity: dollars :: Quantity -> Amount
+ Hledger.Data.Commodity: euro :: Commodity
+ Hledger.Data.Commodity: euros :: Quantity -> Amount
+ Hledger.Data.Commodity: hour :: Commodity
+ Hledger.Data.Commodity: hours :: Quantity -> Amount
+ Hledger.Data.Commodity: nonsimplecommoditychars :: [Char]
+ Hledger.Data.Commodity: pound :: Commodity
+ Hledger.Data.Commodity: pounds :: Quantity -> Amount
+ Hledger.Data.Commodity: quoteCommoditySymbolIfNeeded :: [Char] -> [Char]
+ Hledger.Data.Commodity: tests_Hledger_Data_Commodity :: Test
+ Hledger.Data.Commodity: unknown :: Commodity
+ Hledger.Data.Dates: datesepchar :: ParsecT [Char] u Identity Char
+ Hledger.Data.Dates: datesepchars :: [Char]
+ Hledger.Data.Dates: failIfInvalidYear :: Monad m => String -> m ()
+ Hledger.Data.Dates: orDatesFrom :: DateSpan -> DateSpan -> DateSpan
+ Hledger.Data.Dates: spanIntersect :: DateSpan -> DateSpan -> DateSpan
+ Hledger.Data.Dates: spanUnion :: DateSpan -> DateSpan -> DateSpan
+ Hledger.Data.Dates: spansIntersect :: [DateSpan] -> DateSpan
+ Hledger.Data.Dates: spansUnion :: [DateSpan] -> DateSpan
+ Hledger.Data.Dates: tests_Hledger_Data_Dates :: Test
+ Hledger.Data.FormatStrings: AccountField :: HledgerFormatField
+ Hledger.Data.FormatStrings: DefaultDateField :: HledgerFormatField
+ Hledger.Data.FormatStrings: DepthSpacerField :: HledgerFormatField
+ Hledger.Data.FormatStrings: DescriptionField :: HledgerFormatField
+ Hledger.Data.FormatStrings: FieldNo :: Int -> HledgerFormatField
+ Hledger.Data.FormatStrings: FormatField :: Bool -> (Maybe Int) -> (Maybe Int) -> HledgerFormatField -> FormatString
+ Hledger.Data.FormatStrings: FormatLiteral :: String -> FormatString
+ Hledger.Data.FormatStrings: TotalField :: HledgerFormatField
+ Hledger.Data.FormatStrings: data FormatString
+ Hledger.Data.FormatStrings: data HledgerFormatField
+ Hledger.Data.FormatStrings: formatStrings :: GenParser Char st [FormatString]
+ Hledger.Data.FormatStrings: formatValue :: Bool -> Maybe Int -> Maybe Int -> String -> String
+ Hledger.Data.FormatStrings: parseFormatString :: String -> Either String [FormatString]
+ Hledger.Data.FormatStrings: tests :: Test
+ Hledger.Data.Journal: journalAssetAccountQuery :: Journal -> Query
+ Hledger.Data.Journal: journalBalanceSheetAccountQuery :: Journal -> Query
+ Hledger.Data.Journal: journalCashAccountQuery :: Journal -> Query
+ Hledger.Data.Journal: journalEquityAccountQuery :: Journal -> Query
+ Hledger.Data.Journal: journalExpenseAccountQuery :: Journal -> Query
+ Hledger.Data.Journal: journalIncomeAccountQuery :: Journal -> Query
+ Hledger.Data.Journal: journalLiabilityAccountQuery :: Journal -> Query
+ Hledger.Data.Journal: journalProfitAndLossAccountQuery :: Journal -> Query
+ Hledger.Data.Journal: samplejournal :: Journal
+ Hledger.Data.Journal: tests_Hledger_Data_Journal :: Test
+ Hledger.Data.Ledger: ledgerCommodities :: Ledger -> Map String Commodity
+ Hledger.Data.Ledger: tests_Hledger_Data_Ledger :: Test
+ Hledger.Data.Ledger: tests_journalToLedger :: [Test]
+ Hledger.Data.Posting: postingAllTags :: Posting -> [Tag]
+ Hledger.Data.Posting: postingTagsAsLines :: Posting -> [String]
+ Hledger.Data.Posting: showComment :: String -> String
+ Hledger.Data.Posting: tagsAsLines :: [(String, String)] -> [String]
+ Hledger.Data.Posting: tests_Hledger_Data_Posting :: Test
+ Hledger.Data.Posting: transactionAllTags :: Transaction -> [Tag]
+ Hledger.Data.TimeLog: tests_Hledger_Data_TimeLog :: Test
+ Hledger.Data.Transaction: tests_Hledger_Data_Transaction :: Test
+ Hledger.Data.Types: AccountField :: HledgerFormatField
+ Hledger.Data.Types: CsvRules :: Maybe FieldPosition -> Maybe String -> Maybe FieldPosition -> Maybe FieldPosition -> [FormatString] -> Maybe FieldPosition -> Maybe FieldPosition -> Maybe FieldPosition -> Maybe FieldPosition -> Maybe String -> Maybe FieldPosition -> Maybe FieldPosition -> Maybe FieldPosition -> AccountName -> [AccountRule] -> CsvRules
+ Hledger.Data.Types: DefaultDateField :: HledgerFormatField
+ Hledger.Data.Types: DepthSpacerField :: HledgerFormatField
+ Hledger.Data.Types: DescriptionField :: HledgerFormatField
+ Hledger.Data.Types: FieldNo :: Int -> HledgerFormatField
+ Hledger.Data.Types: FormatField :: Bool -> (Maybe Int) -> (Maybe Int) -> HledgerFormatField -> FormatString
+ Hledger.Data.Types: FormatLiteral :: String -> FormatString
+ Hledger.Data.Types: TotalField :: HledgerFormatField
+ Hledger.Data.Types: account2Field :: CsvRules -> Maybe FieldPosition
+ Hledger.Data.Types: accountField :: CsvRules -> Maybe FieldPosition
+ Hledger.Data.Types: accountRules :: CsvRules -> [AccountRule]
+ Hledger.Data.Types: amountField :: CsvRules -> Maybe FieldPosition
+ Hledger.Data.Types: amountInField :: CsvRules -> Maybe FieldPosition
+ Hledger.Data.Types: amountOutField :: CsvRules -> Maybe FieldPosition
+ Hledger.Data.Types: baseAccount :: CsvRules -> AccountName
+ Hledger.Data.Types: baseCurrency :: CsvRules -> Maybe String
+ Hledger.Data.Types: codeField :: CsvRules -> Maybe FieldPosition
+ Hledger.Data.Types: currencyField :: CsvRules -> Maybe FieldPosition
+ Hledger.Data.Types: data CsvRules
+ Hledger.Data.Types: data FormatString
+ Hledger.Data.Types: data HledgerFormatField
+ Hledger.Data.Types: dateField :: CsvRules -> Maybe FieldPosition
+ Hledger.Data.Types: dateFormat :: CsvRules -> Maybe String
+ Hledger.Data.Types: descriptionField :: CsvRules -> [FormatString]
+ Hledger.Data.Types: effectiveDateField :: CsvRules -> Maybe FieldPosition
+ Hledger.Data.Types: instance Eq CsvRules
+ Hledger.Data.Types: instance Eq FormatString
+ Hledger.Data.Types: instance Eq HledgerFormatField
+ Hledger.Data.Types: instance Show CsvRules
+ Hledger.Data.Types: instance Show FormatString
+ Hledger.Data.Types: instance Show HledgerFormatField
+ Hledger.Data.Types: instance Show Reader
+ Hledger.Data.Types: ledgerAccountMap :: Ledger -> Map AccountName Account
+ Hledger.Data.Types: ledgerAccountNameTree :: Ledger -> Tree AccountName
+ Hledger.Data.Types: ledgerJournal :: Ledger -> Journal
+ Hledger.Data.Types: ptags :: Posting -> [Tag]
+ Hledger.Data.Types: statusField :: CsvRules -> Maybe FieldPosition
+ Hledger.Data.Types: ttags :: Transaction -> [Tag]
+ Hledger.Data.Types: type AccountRule = ([(String, Maybe String)], AccountName)
+ Hledger.Data.Types: type FieldPosition = Int
+ Hledger.Data.Types: type Format = String
+ Hledger.Data.Types: type ParseRules = CsvRules
+ Hledger.Data.Types: type Quantity = Double
+ Hledger.Data.Types: type Tag = (String, String)
+ Hledger.Query: Acct :: String -> Query
+ Hledger.Query: And :: [Query] -> Query
+ Hledger.Query: Any :: Query
+ Hledger.Query: Date :: DateSpan -> Query
+ Hledger.Query: Depth :: Int -> Query
+ Hledger.Query: Desc :: String -> Query
+ Hledger.Query: EDate :: DateSpan -> Query
+ Hledger.Query: Empty :: Bool -> Query
+ Hledger.Query: None :: Query
+ Hledger.Query: Not :: Query -> Query
+ Hledger.Query: Or :: [Query] -> Query
+ Hledger.Query: QueryOptInAcct :: AccountName -> QueryOpt
+ Hledger.Query: QueryOptInAcctOnly :: AccountName -> QueryOpt
+ Hledger.Query: Real :: Bool -> Query
+ Hledger.Query: Status :: Bool -> Query
+ Hledger.Query: Tag :: String -> (Maybe String) -> Query
+ Hledger.Query: data Query
+ Hledger.Query: data QueryOpt
+ Hledger.Query: filterQuery :: (Query -> Bool) -> Query -> Query
+ Hledger.Query: inAccount :: [QueryOpt] -> Maybe (AccountName, Bool)
+ Hledger.Query: inAccountQuery :: [QueryOpt] -> Maybe Query
+ Hledger.Query: instance Eq Query
+ Hledger.Query: instance Eq QueryOpt
+ Hledger.Query: instance Show Query
+ Hledger.Query: instance Show QueryOpt
+ Hledger.Query: matchesAccount :: Query -> AccountName -> Bool
+ Hledger.Query: matchesPosting :: Query -> Posting -> Bool
+ Hledger.Query: matchesTransaction :: Query -> Transaction -> Bool
+ Hledger.Query: parseQuery :: Day -> String -> (Query, [QueryOpt])
+ Hledger.Query: queryDateSpan :: Bool -> Query -> DateSpan
+ Hledger.Query: queryDepth :: Query -> Int
+ Hledger.Query: queryEmpty :: Query -> Bool
+ Hledger.Query: queryIsDate :: Query -> Bool
+ Hledger.Query: queryIsDepth :: Query -> Bool
+ Hledger.Query: queryIsNull :: Query -> Bool
+ Hledger.Query: queryIsStartDateOnly :: Bool -> Query -> Bool
+ Hledger.Query: queryStartDate :: Bool -> Query -> Maybe Day
+ Hledger.Query: simplifyQuery :: Query -> Query
+ Hledger.Query: tests_Hledger_Query :: Test
+ Hledger.Read: accountname :: GenParser Char st AccountName
+ Hledger.Read: amount :: GenParser Char JournalContext MixedAmount
+ Hledger.Read: amount' :: String -> MixedAmount
+ Hledger.Read: defaultJournal :: IO Journal
+ Hledger.Read: defaultJournalPath :: IO String
+ Hledger.Read: ensureJournalFileExists :: FilePath -> IO ()
+ Hledger.Read: readJournal' :: String -> IO Journal
+ Hledger.Read: requireJournalFileExists :: FilePath -> IO ()
+ Hledger.Read: samplejournal :: IO Journal
+ Hledger.Read.CsvReader: reader :: Reader
+ Hledger.Read.CsvReader: tests_Hledger_Read_CsvReader :: Test
+ Hledger.Read.JournalReader: accountname :: GenParser Char st AccountName
+ Hledger.Read.JournalReader: amount :: GenParser Char JournalContext MixedAmount
+ Hledger.Read.JournalReader: amount' :: String -> MixedAmount
+ Hledger.Read.JournalReader: datetime :: GenParser Char JournalContext LocalTime
+ Hledger.Read.JournalReader: defaultyeardirective :: GenParser Char JournalContext JournalUpdate
+ Hledger.Read.JournalReader: directive :: GenParser Char JournalContext JournalUpdate
+ Hledger.Read.JournalReader: emptyline :: GenParser Char JournalContext ()
+ Hledger.Read.JournalReader: getParentAccount :: GenParser tok JournalContext String
+ Hledger.Read.JournalReader: historicalpricedirective :: GenParser Char JournalContext HistoricalPrice
+ Hledger.Read.JournalReader: journal :: GenParser Char JournalContext (JournalUpdate, JournalContext)
+ Hledger.Read.JournalReader: parseJournalWith :: (GenParser Char JournalContext (JournalUpdate, JournalContext)) -> FilePath -> String -> ErrorT String IO Journal
+ Hledger.Reports: queryFromOpts :: Day -> ReportOpts -> Query
+ Hledger.Reports: queryOptsFromOpts :: Day -> ReportOpts -> [QueryOpt]
+ Hledger.Reports: query_ :: ReportOpts -> String
+ Hledger.Utils: branches :: Tree a -> Forest a
+ Hledger.Utils: containsRegex :: String -> String -> Bool
+ Hledger.Utils: elideLeft :: Int -> [Char] -> [Char]
+ Hledger.Utils: elideRight :: Int -> [Char] -> [Char]
+ Hledger.Utils: escapeQuotes :: String -> String
+ Hledger.Utils: escapeSingleQuotes :: String -> String
+ Hledger.Utils: expandPath :: MonadIO m => SourcePos -> FilePath -> m FilePath
+ Hledger.Utils: filterTests :: (Test -> Bool) -> Test -> Test
+ Hledger.Utils: firstJust :: Eq a => [Maybe a] -> Maybe a
+ Hledger.Utils: flattenTests :: Test -> [Test]
+ Hledger.Utils: fromSystemString :: SystemString -> String
+ Hledger.Utils: isDoubleQuoted :: [Char] -> Bool
+ Hledger.Utils: isSingleQuoted :: [Char] -> Bool
+ Hledger.Utils: lowercase :: [Char] -> [Char]
+ Hledger.Utils: lstrip :: [Char] -> [Char]
+ Hledger.Utils: regexMatch :: String -> String -> Maybe (RegexResult, MatchList)
+ Hledger.Utils: regexMatchCI :: String -> String -> Maybe (RegexResult, MatchList)
+ Hledger.Utils: root :: Tree a -> a
+ Hledger.Utils: rstrip :: [Char] -> [Char]
+ Hledger.Utils: singleQuoteIfNeeded :: [Char] -> [Char]
+ Hledger.Utils: strip :: [Char] -> [Char]
+ Hledger.Utils: subs :: Tree a -> Forest a
+ Hledger.Utils: testName :: Test -> String
+ Hledger.Utils: toSystemString :: String -> SystemString
+ Hledger.Utils: type SystemString = String
+ Hledger.Utils: uppercase :: [Char] -> [Char]
+ Hledger.Utils: whitespacechars :: [Char]
+ Hledger.Utils.UTF8IOCompat: appendFile :: FilePath -> String -> IO ()
+ Hledger.Utils.UTF8IOCompat: error' :: String -> a
+ Hledger.Utils.UTF8IOCompat: fromSystemString :: SystemString -> String
+ Hledger.Utils.UTF8IOCompat: getContents :: IO String
+ Hledger.Utils.UTF8IOCompat: hGetContents :: Handle -> IO String
+ Hledger.Utils.UTF8IOCompat: hPutStr :: Handle -> String -> IO ()
+ Hledger.Utils.UTF8IOCompat: hPutStrLn :: Handle -> String -> IO ()
+ Hledger.Utils.UTF8IOCompat: putStr :: String -> IO ()
+ Hledger.Utils.UTF8IOCompat: putStrLn :: String -> IO ()
+ Hledger.Utils.UTF8IOCompat: readFile :: FilePath -> IO String
+ Hledger.Utils.UTF8IOCompat: toSystemString :: String -> SystemString
+ Hledger.Utils.UTF8IOCompat: type SystemString = String
+ Hledger.Utils.UTF8IOCompat: userError' :: String -> IOError
+ Hledger.Utils.UTF8IOCompat: writeFile :: FilePath -> String -> IO ()
- Hledger.Data.Amount: missingamt :: MixedAmount
+ Hledger.Data.Amount: missingamt :: Amount
- Hledger.Data.Journal: filterJournalPostings :: FilterSpec -> Journal -> Journal
+ Hledger.Data.Journal: filterJournalPostings :: Query -> Journal -> Journal
- Hledger.Data.Journal: filterJournalTransactions :: FilterSpec -> Journal -> Journal
+ Hledger.Data.Journal: filterJournalTransactions :: Query -> Journal -> Journal
- Hledger.Data.Ledger: journalToLedger :: FilterSpec -> Journal -> Ledger
+ Hledger.Data.Ledger: journalToLedger :: Query -> Journal -> Ledger
- Hledger.Data.Types: Amount :: Commodity -> Double -> Maybe Price -> Amount
+ Hledger.Data.Types: Amount :: Commodity -> Quantity -> Maybe Price -> Amount
- Hledger.Data.Types: Posting :: Bool -> AccountName -> MixedAmount -> String -> PostingType -> [(String, String)] -> Maybe Transaction -> Posting
+ Hledger.Data.Types: Posting :: Bool -> AccountName -> MixedAmount -> String -> PostingType -> [Tag] -> Maybe Transaction -> Posting
- Hledger.Data.Types: Reader :: String -> (FilePath -> String -> Bool) -> (FilePath -> String -> ErrorT String IO Journal) -> Reader
+ Hledger.Data.Types: Reader :: Format -> (FilePath -> String -> Bool) -> (Maybe FilePath -> FilePath -> String -> ErrorT String IO Journal) -> Reader
- Hledger.Data.Types: Transaction :: Day -> Maybe Day -> Bool -> String -> String -> String -> [(String, String)] -> [Posting] -> String -> Transaction
+ Hledger.Data.Types: Transaction :: Day -> Maybe Day -> Bool -> String -> String -> String -> [Tag] -> [Posting] -> String -> Transaction
- Hledger.Data.Types: quantity :: Amount -> Double
+ Hledger.Data.Types: quantity :: Amount -> Quantity
- Hledger.Data.Types: rFormat :: Reader -> String
+ Hledger.Data.Types: rFormat :: Reader -> Format
- Hledger.Data.Types: rParser :: Reader -> FilePath -> String -> ErrorT String IO Journal
+ Hledger.Data.Types: rParser :: Reader -> Maybe FilePath -> FilePath -> String -> ErrorT String IO Journal
- Hledger.Read: readJournal :: Maybe String -> String -> IO (Either String Journal)
+ Hledger.Read: readJournal :: Maybe Format -> Maybe FilePath -> Maybe FilePath -> String -> IO (Either String Journal)
- Hledger.Read: readJournalFile :: Maybe String -> FilePath -> IO (Either String Journal)
+ Hledger.Read: readJournalFile :: Maybe Format -> Maybe FilePath -> FilePath -> IO (Either String Journal)
- Hledger.Reports: ReportOpts :: Maybe Day -> Maybe Day -> Maybe (Interval, DateSpan) -> Bool -> Bool -> Bool -> Maybe Int -> Maybe DisplayExp -> Bool -> Bool -> Bool -> Bool -> Bool -> Int -> Bool -> Bool -> Bool -> Bool -> Bool -> Bool -> Maybe FormatStr -> [String] -> ReportOpts
+ Hledger.Reports: ReportOpts :: Maybe Day -> Maybe Day -> Maybe (Interval, DateSpan) -> Bool -> Bool -> Bool -> Maybe Int -> Maybe DisplayExp -> Bool -> Bool -> Bool -> Bool -> Bool -> Int -> Bool -> Bool -> Bool -> Bool -> Bool -> Bool -> Maybe FormatStr -> String -> ReportOpts
- Hledger.Reports: accountTransactionsReport :: ReportOpts -> Journal -> Matcher -> Matcher -> TransactionsReport
+ Hledger.Reports: accountTransactionsReport :: ReportOpts -> Journal -> Query -> Query -> TransactionsReport
- Hledger.Reports: accountsReport :: ReportOpts -> FilterSpec -> Journal -> AccountsReport
+ Hledger.Reports: accountsReport :: ReportOpts -> Query -> Journal -> AccountsReport
- Hledger.Reports: entriesReport :: ReportOpts -> FilterSpec -> Journal -> EntriesReport
+ Hledger.Reports: entriesReport :: ReportOpts -> Query -> Journal -> EntriesReport
- Hledger.Reports: journalTransactionsReport :: ReportOpts -> Journal -> Matcher -> TransactionsReport
+ Hledger.Reports: journalTransactionsReport :: ReportOpts -> Journal -> Query -> TransactionsReport
- Hledger.Reports: postingsReport :: ReportOpts -> FilterSpec -> Journal -> PostingsReport
+ Hledger.Reports: postingsReport :: ReportOpts -> Query -> Journal -> PostingsReport

Files

Hledger.hs view
@@ -1,11 +1,24 @@ module Hledger (                 module Hledger.Data+               ,module Hledger.Query                ,module Hledger.Read                ,module Hledger.Reports                ,module Hledger.Utils+               ,tests_Hledger ) where+import Test.HUnit+ import Hledger.Data-import Hledger.Read+import Hledger.Query+import Hledger.Read hiding (samplejournal) import Hledger.Reports import Hledger.Utils++tests_Hledger = TestList+    [+     tests_Hledger_Data+    ,tests_Hledger_Query+    ,tests_Hledger_Read+    ,tests_Hledger_Reports+    ]
Hledger/Data.hs view
@@ -15,7 +15,6 @@                module Hledger.Data.Dates,                module Hledger.Data.Journal,                module Hledger.Data.Ledger,-               module Hledger.Data.Matching,                module Hledger.Data.Posting,                module Hledger.Data.TimeLog,                module Hledger.Data.Transaction,@@ -32,7 +31,6 @@ import Hledger.Data.Dates import Hledger.Data.Journal import Hledger.Data.Ledger-import Hledger.Data.Matching import Hledger.Data.Posting import Hledger.Data.TimeLog import Hledger.Data.Transaction@@ -47,7 +45,6 @@     ,tests_Hledger_Data_Dates     ,tests_Hledger_Data_Journal     ,tests_Hledger_Data_Ledger-    ,tests_Hledger_Data_Matching     ,tests_Hledger_Data_Posting     ,tests_Hledger_Data_TimeLog     ,tests_Hledger_Data_Transaction
Hledger/Data/Account.hs view
@@ -20,7 +20,7 @@   instance Show Account where-    show (Account a ts b) = printf "Account %s with %d txns and %s balance" a (length ts) (showMixedAmount b)+    show (Account a ps b) = printf "Account %s with %d postings and %s balance" a (length ps) (showMixedAmountDebug b)  instance Eq Account where     (==) (Account n1 t1 b1) (Account n2 t2 b2) = n1 == n2 && t1 == t2 && b1 == b2
Hledger/Data/AccountName.hs view
@@ -183,7 +183,7 @@ accountNameToAccountRegex "" = "" accountNameToAccountRegex a = printf "^%s(:|$)" a --- | Convert an account name to a regular expression matching it and its subaccounts.+-- | Convert an account name to a regular expression matching it but not its subaccounts. accountNameToAccountOnlyRegex :: String -> String accountNameToAccountOnlyRegex "" = "" accountNameToAccountOnlyRegex a = printf "^%s$" a
Hledger/Data/Amount.hs view
@@ -44,6 +44,7 @@ module Hledger.Data.Amount (   -- * Amount   nullamt,+  missingamt,   amountWithCommodity,   canonicaliseAmountCommodity,   setAmountPrecision,@@ -58,7 +59,7 @@   maxprecisionwithpoint,   -- * MixedAmount   nullmixedamt,-  missingamt,+  missingmixedamt,   amounts,   normaliseMixedAmountPreservingFirstPrice,   canonicaliseMixedAmountCommodity,@@ -96,7 +97,7 @@ ------------------------------------------------------------------------------- -- Amount -instance Show Amount where show = showAmount+instance Show Amount where show = showAmountDebug  instance Num Amount where     abs (Amount c q p) = Amount c (abs q) p@@ -147,12 +148,14 @@  -- | Does this amount appear to be zero when displayed with its given precision ? isZeroAmount :: Amount -> Bool-isZeroAmount = null . filter (`elem` "123456789") . showAmountWithoutPriceOrCommodity+isZeroAmount a --  a==missingamt = False+               | otherwise     = (null . filter (`elem` "123456789") . showAmountWithoutPriceOrCommodity) a  -- | Is this amount "really" zero, regardless of the display precision ? -- Since we are using floating point, for now just test to some high precision. isReallyZeroAmount :: Amount -> Bool-isReallyZeroAmount = null . filter (`elem` "123456789") . printf ("%."++show zeroprecision++"f") . quantity+isReallyZeroAmount a --  a==missingamt = False+                     | otherwise     = (null . filter (`elem` "123456789") . printf ("%."++show zeroprecision++"f") . quantity) a     where zeroprecision = 8  -- | Get the string representation of an amount, based on its commodity's@@ -166,8 +169,9 @@  -- | Get the unambiguous string representation of an amount, for debugging. showAmountDebug :: Amount -> String+showAmountDebug (Amount (Commodity {symbol="AUTO"}) _ _) = "(missing)" showAmountDebug (Amount c q pri) = printf "Amount {commodity = %s, quantity = %s, price = %s}"-                                   (show c) (show q) (maybe "" showPriceDebug pri)+                                   (show c) (show q) (maybe "Nothing" showPriceDebug pri)  -- | Get the string representation of an amount, without any \@ price. showAmountWithoutPrice :: Amount -> String@@ -189,7 +193,7 @@ -- display settings. String representations equivalent to zero are -- converted to just \"0\". showAmount :: Amount -> String-showAmount (Amount (Commodity {symbol="AUTO"}) _ _) = "" -- can appear in an error message+showAmount (Amount (Commodity {symbol="AUTO"}) _ _) = "" showAmount a@(Amount (Commodity {symbol=sym,side=side,spaced=spaced}) _ pri) =     case side of       L -> printf "%s%s%s%s" sym' space quantity' price@@ -257,7 +261,7 @@ ------------------------------------------------------------------------------- -- MixedAmount -instance Show MixedAmount where show = showMixedAmount+instance Show MixedAmount where show = showMixedAmountDebug  instance Num MixedAmount where     fromInteger i = Mixed [Amount (comm "") (fromInteger i) Nothing]@@ -272,22 +276,31 @@ nullmixedamt = Mixed []  -- | A temporary value for parsed transactions which had no amount specified.-missingamt :: MixedAmount-missingamt = Mixed [Amount unknown{symbol="AUTO"} 0 Nothing]+missingamt :: Amount+missingamt = Amount unknown{symbol="AUTO"} 0 Nothing --- | Simplify a mixed amount's component amounts: combine amounts with--- the same commodity and price. Also remove any zero amounts and+missingmixedamt :: MixedAmount+missingmixedamt = Mixed [missingamt]++-- | Simplify a mixed amount's component amounts: combine amounts with the+-- same commodity and price. Also remove any zero or missing amounts and -- replace an empty amount list with a single zero amount. normaliseMixedAmountPreservingPrices :: MixedAmount -> MixedAmount normaliseMixedAmountPreservingPrices (Mixed as) = Mixed as''     where       as'' = if null nonzeros then [nullamt] else nonzeros-      (_,nonzeros) = partition (\a -> isReallyZeroAmount a && Mixed [a] /= missingamt) as'+      (_,nonzeros) = partition isReallyZeroAmount $ filter (/= missingamt) as'       as' = map sumAmountsUsingFirstPrice $ group $ sort as       sort = sortBy (\a1 a2 -> compare (sym a1,price a1) (sym a2,price a2))       group = groupBy (\a1 a2 -> sym a1 == sym a2 && price a1 == price a2)       sym = symbol . commodity +tests_normaliseMixedAmountPreservingPrices = [+  "normaliseMixedAmountPreservingPrices" ~: do+   -- assertEqual "" (Mixed [dollars 2]) (normaliseMixedAmountPreservingPrices $ Mixed [dollars 0, dollars 2])+   assertEqual "" (Mixed [nullamt]) (normaliseMixedAmountPreservingPrices $ Mixed [dollars 0, missingamt])+ ]+ -- | Simplify a mixed amount's component amounts: combine amounts with -- the same commodity, using the first amount's price for subsequent -- amounts in each commodity (ie, this function alters the amount and@@ -297,7 +310,7 @@ normaliseMixedAmountPreservingFirstPrice (Mixed as) = Mixed as''     where        as'' = if null nonzeros then [nullamt] else nonzeros-      (_,nonzeros) = partition (\a -> isReallyZeroAmount a && Mixed [a] /= missingamt) as'+      (_,nonzeros) = partition (\a -> isReallyZeroAmount a && a /= missingamt) as'       as' = map sumAmountsUsingFirstPrice $ group $ sort as       sort = sortBy (\a1 a2 -> compare (sym a1) (sym a2))       group = groupBy (\a1 a2 -> sym a1 == sym a2)@@ -362,7 +375,7 @@ -- its component amounts. NB a mixed amount can have an empty amounts -- list in which case it shows as \"\". showMixedAmount :: MixedAmount -> String-showMixedAmount m = vConcatRightAligned $ map show $ amounts $ normaliseMixedAmountPreservingFirstPrice m+showMixedAmount m = vConcatRightAligned $ map showAmount $ amounts $ normaliseMixedAmountPreservingFirstPrice m  -- | Set the display precision in the amount's commodities. setMixedAmountPrecision :: Int -> MixedAmount -> MixedAmount@@ -377,8 +390,9 @@  -- | Get an unambiguous string representation of a mixed amount for debugging. showMixedAmountDebug :: MixedAmount -> String-showMixedAmountDebug m = printf "Mixed [%s]" as-    where as = intercalate "\n       " $ map showAmountDebug $ amounts $ normaliseMixedAmountPreservingFirstPrice m+showMixedAmountDebug m | m == missingmixedamt = "(missing)"+                       | otherwise       = printf "Mixed [%s]" as+    where as = intercalate "\n       " $ map showAmountDebug $ amounts m -- normaliseMixedAmountPreservingFirstPrice m  -- | Get the string representation of a mixed amount, but without -- any \@ prices.@@ -387,7 +401,7 @@     where       (Mixed as) = normaliseMixedAmountPreservingFirstPrice $ stripPrices m       stripPrices (Mixed as) = Mixed $ map stripprice as where stripprice a = a{price=Nothing}-      width = maximum $ map (length . show) as+      width = maximum $ map (length . showAmount) as       showfixedwidth = printf (printf "%%%ds" width) . showAmountWithoutPrice  -- | Replace a mixed amount's commodity with the canonicalised version from@@ -398,7 +412,9 @@ ------------------------------------------------------------------------------- -- misc -tests_Hledger_Data_Amount = TestList [+tests_Hledger_Data_Amount = TestList $+     tests_normaliseMixedAmountPreservingPrices+  ++ [    -- Amount @@ -461,7 +477,7 @@     showMixedAmount (Mixed [(dollars 1){price=Just $ UnitPrice $ Mixed [euros 2]}]) `is` "$1.00 @ €2.00"     showMixedAmount (Mixed [dollars 0]) `is` "0"     showMixedAmount (Mixed []) `is` "0"-    showMixedAmount missingamt `is` ""+    showMixedAmount missingmixedamt `is` ""    ,"showMixedAmountWithoutPrice" ~: do     let a = (dollars 1){price=Just $ UnitPrice $ Mixed [euros 2]}
Hledger/Data/Dates.hs view
@@ -20,7 +20,38 @@  -- XXX fromGregorian silently clips bad dates, use fromGregorianValid instead ? -module Hledger.Data.Dates+module Hledger.Data.Dates (+  -- * Misc date handling utilities+  getCurrentDay,+  getCurrentMonth,+  getCurrentYear,+  nulldate,+  spanContainsDate,+  parsedate,+  showDate,+  elapsedSeconds,+  prevday,+  parsePeriodExpr,+  nulldatespan,+  tests_Hledger_Data_Dates,+  failIfInvalidYear,+  datesepchar,+  datesepchars,+  spanIntersect,+  spansIntersect,+  spanUnion,+  spansUnion,+  orDatesFrom,+  smartdate,+  splitSpan,+  fixSmartDate,+  fixSmartDateStr,+  fixSmartDateStrEither,+  fixSmartDateStrEither',+  daysInSpan,+  maybePeriod,+  mkdatespan,+) where  import Control.Monad@@ -116,12 +147,28 @@     where a = if isJust a1 then a1 else a2           b = if isJust b1 then b1 else b2 +-- | Calculate the intersection of a number of datespans.+spansIntersect [] = nulldatespan+spansIntersect [d] = d+spansIntersect (d:ds) = d `spanIntersect` (spansIntersect ds)+ -- | Calculate the intersection of two datespans. spanIntersect (DateSpan b1 e1) (DateSpan b2 e2) = DateSpan b e     where       b = latest b1 b2       e = earliest e1 e2 +-- | Calculate the union of a number of datespans.+spansUnion [] = nulldatespan+spansUnion [d] = d+spansUnion (d:ds) = d `spanUnion` (spansUnion ds)++-- | Calculate the union of two datespans.+spanUnion (DateSpan b1 e1) (DateSpan b2 e2) = DateSpan b e+    where+      b = earliest b1 b2+      e = latest e1 e2+ latest d Nothing = d latest Nothing d = d latest (Just d1) (Just d2) = Just $ max d1 d2@@ -139,18 +186,18 @@ maybePeriod refdate = either (const Nothing) Just . parsePeriodExpr refdate  -- | Show a DateSpan as a human-readable pseudo-period-expression string.-dateSpanAsText :: DateSpan -> String-dateSpanAsText (DateSpan Nothing Nothing)   = "all"-dateSpanAsText (DateSpan Nothing (Just e))  = printf "to %s" (show e)-dateSpanAsText (DateSpan (Just b) Nothing)  = printf "from %s" (show b)-dateSpanAsText (DateSpan (Just b) (Just e)) = printf "%s to %s" (show b) (show e)+-- dateSpanAsText :: DateSpan -> String+-- dateSpanAsText (DateSpan Nothing Nothing)   = "all"+-- dateSpanAsText (DateSpan Nothing (Just e))  = printf "to %s" (show e)+-- dateSpanAsText (DateSpan (Just b) Nothing)  = printf "from %s" (show b)+-- dateSpanAsText (DateSpan (Just b) (Just e)) = printf "%s to %s" (show b) (show e)      -- | Convert a single smart date string to a date span using the provided -- reference date, or raise an error.-spanFromSmartDateString :: Day -> String -> DateSpan-spanFromSmartDateString refdate s = spanFromSmartDate refdate sdate-    where-      sdate = fromparse $ parsewith smartdateonly s+-- spanFromSmartDateString :: Day -> String -> DateSpan+-- spanFromSmartDateString refdate s = spanFromSmartDate refdate sdate+--     where+--       sdate = fromparse $ parsewith smartdateonly s  spanFromSmartDate :: Day -> SmartDate -> DateSpan spanFromSmartDate refdate sdate = DateSpan (Just b) (Just e)@@ -183,8 +230,8 @@       span (y,m,"")              = (startofmonth day, nextmonth day) where day = fromGregorian (read y) (read m) 1       span (y,m,d)               = (day, nextday day) where day = fromGregorian (read y) (read m) (read d) -showDay :: Day -> String-showDay day = printf "%04d/%02d/%02d" y m d where (y,m,d) = toGregorian day+-- showDay :: Day -> String+-- showDay day = printf "%04d/%02d/%02d" y m d where (y,m,d) = toGregorian day  -- | Convert a smart date string to an explicit yyyy\/mm\/dd string using -- the provided reference date, or raise an error.@@ -280,16 +327,12 @@ ---------------------------------------------------------------------- -- parsing -firstJust ms = case dropWhile (==Nothing) ms of-    [] -> Nothing-    (md:_) -> md---- | Parse a couple of date-time string formats to a time type.-parsedatetimeM :: String -> Maybe LocalTime-parsedatetimeM s = firstJust [-    parseTime defaultTimeLocale "%Y/%m/%d %H:%M:%S" s,-    parseTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" s-    ]+-- -- | Parse a couple of date-time string formats to a time type.+-- parsedatetimeM :: String -> Maybe LocalTime+-- parsedatetimeM s = firstJust [+--     parseTime defaultTimeLocale "%Y/%m/%d %H:%M:%S" s,+--     parseTime defaultTimeLocale "%Y-%m-%d %H:%M:%S" s+--     ]  -- | Parse a couple of date string formats to a time type. parsedateM :: String -> Maybe Day@@ -298,10 +341,10 @@      parseTime defaultTimeLocale "%Y-%m-%d" s       ] --- | Parse a date-time string to a time type, or raise an error.-parsedatetime :: String -> LocalTime-parsedatetime s = fromMaybe (error' $ "could not parse timestamp \"" ++ s ++ "\"")-                            (parsedatetimeM s)+-- -- | Parse a date-time string to a time type, or raise an error.+-- parsedatetime :: String -> LocalTime+-- parsedatetime s = fromMaybe (error' $ "could not parse timestamp \"" ++ s ++ "\"")+--                             (parsedatetimeM s)  -- | Parse a date string to a time type, or raise an error. parsedate :: String -> Day@@ -410,8 +453,8 @@ months         = ["january","february","march","april","may","june",                   "july","august","september","october","november","december"] monthabbrevs   = ["jan","feb","mar","apr","may","jun","jul","aug","sep","oct","nov","dec"]-weekdays       = ["monday","tuesday","wednesday","thursday","friday","saturday","sunday"]-weekdayabbrevs = ["mon","tue","wed","thu","fri","sat","sun"]+-- weekdays       = ["monday","tuesday","wednesday","thursday","friday","saturday","sunday"]+-- weekdayabbrevs = ["mon","tue","wed","thu","fri","sat","sun"]  monthIndex s = maybe 0 (+1) $ lowercase s `elemIndex` months monIndex s   = maybe 0 (+1) $ lowercase s `elemIndex` monthabbrevs
+ Hledger/Data/FormatStrings.hs view
@@ -0,0 +1,118 @@+module Hledger.Data.FormatStrings (+          parseFormatString+        , formatStrings+        , formatValue+        , FormatString(..)+        , HledgerFormatField(..)+        , tests+        ) where++import Numeric+import Data.Char (isPrint)+import Data.Maybe+import Test.HUnit+import Text.ParserCombinators.Parsec+import Text.Printf++import Hledger.Data.Types+++formatValue :: Bool -> Maybe Int -> Maybe Int -> String -> String+formatValue leftJustified min max value = printf formatS value+    where+      l = if leftJustified then "-" else ""+      min' = maybe "" show min+      max' = maybe "" (\i -> "." ++ (show i)) max+      formatS = "%" ++ l ++ min' ++ max' ++ "s"++parseFormatString :: String -> Either String [FormatString]+parseFormatString input = case (runParser formatStrings () "(unknown)") input of+    Left y -> Left $ show y+    Right x -> Right x++{-+Parsers+-}++field :: GenParser Char st HledgerFormatField+field = do+        try (string "account" >> return AccountField)+    <|> try (string "depth_spacer" >> return DepthSpacerField)+    <|> try (string "date" >> return DescriptionField)+    <|> try (string "description" >> return DescriptionField)+    <|> try (string "total" >> return TotalField)+    <|> try (many1 digit >>= (\s -> return $ FieldNo $ read s))++formatField :: GenParser Char st FormatString+formatField = do+    char '%'+    leftJustified <- optionMaybe (char '-')+    minWidth <- optionMaybe (many1 $ digit)+    maxWidth <- optionMaybe (do char '.'; many1 $ digit) -- TODO: Can this be (char '1') *> (many1 digit)+    char '('+    f <- field+    char ')'+    return $ FormatField (isJust leftJustified) (parseDec minWidth) (parseDec maxWidth) f+    where+      parseDec s = case s of+        Just text -> Just m where ((m,_):_) = readDec text+        _ -> Nothing++formatLiteral :: GenParser Char st FormatString+formatLiteral = do+    s <- many1 c+    return $ FormatLiteral s+    where+      isPrintableButNotPercentage x = isPrint x && (not $ x == '%')+      c =     (satisfy isPrintableButNotPercentage <?> "printable character")+          <|> try (string "%%" >> return '%')++formatString :: GenParser Char st FormatString+formatString =+        formatField+    <|> formatLiteral++formatStrings :: GenParser Char st [FormatString]+formatStrings = many formatString++testFormat :: FormatString -> String -> String -> Assertion+testFormat fs value expected = assertEqual name expected actual+    where+        (name, actual) = case fs of+            FormatLiteral l -> ("literal", formatValue False Nothing Nothing l)+            FormatField leftJustify min max _ -> ("field", formatValue leftJustify min max value)++testParser :: String -> [FormatString] -> Assertion+testParser s expected = case (parseFormatString s) of+    Left  error -> assertFailure $ show error+    Right actual -> assertEqual ("Input: " ++ s) expected actual++tests = test [ formattingTests ++ parserTests ]++formattingTests = [+      testFormat (FormatLiteral " ")                                ""            " "+    , testFormat (FormatField False Nothing Nothing DescriptionField)    "description" "description"+    , testFormat (FormatField False (Just 20) Nothing DescriptionField)  "description" "         description"+    , testFormat (FormatField False Nothing (Just 20) DescriptionField)  "description" "description"+    , testFormat (FormatField True Nothing (Just 20) DescriptionField)   "description" "description"+    , testFormat (FormatField True (Just 20) Nothing DescriptionField)   "description" "description         "+    , testFormat (FormatField True (Just 20) (Just 20) DescriptionField) "description" "description         "+    , testFormat (FormatField True Nothing (Just 3) DescriptionField)    "description" "des"+    ]++parserTests = [+      testParser ""                             []+    , testParser "D"                            [FormatLiteral "D"]+    , testParser "%(date)"                      [FormatField False Nothing Nothing DescriptionField]+    , testParser "%(total)"                     [FormatField False Nothing Nothing TotalField]+    , testParser "Hello %(date)!"               [FormatLiteral "Hello ", FormatField False Nothing Nothing DescriptionField, FormatLiteral "!"]+    , testParser "%-(date)"                     [FormatField True Nothing Nothing DescriptionField]+    , testParser "%20(date)"                    [FormatField False (Just 20) Nothing DescriptionField]+    , testParser "%.10(date)"                   [FormatField False Nothing (Just 10) DescriptionField]+    , testParser "%20.10(date)"                 [FormatField False (Just 20) (Just 10) DescriptionField]+    , testParser "%20(account) %.10(total)\n"   [ FormatField False (Just 20) Nothing AccountField+                                                , FormatLiteral " "+                                                , FormatField False Nothing (Just 10) TotalField+                                                , FormatLiteral "\n"+                                                ]+  ]
Hledger/Data/Journal.hs view
@@ -1,15 +1,59 @@ {-| -A 'Journal' is a set of 'Transaction's and related data, usually parsed-from a hledger/ledger journal file or timelog. This is the primary hledger-data object.+A 'Journal' is a set of transactions, plus optional related data.  This is+hledger's primary data object. It is usually parsed from a journal file or+other data format (see "Hledger.Read").  -} -module Hledger.Data.Journal+module Hledger.Data.Journal (+  -- * Parsing helpers+  addHistoricalPrice,+  addModifierTransaction,+  addPeriodicTransaction,+  addTimeLogEntry,+  addTransaction,+  journalApplyAliases,+  journalBalanceTransactions,+  journalCanonicaliseAmounts,+  journalConvertAmountsToCost,+  journalFinalise,+  journalSelectingDate,+  -- * Filtering+  filterJournalPostings,+  filterJournalTransactions,+  -- * Querying+  journalAccountInfo,+  journalAccountNames,+  journalAccountNamesUsed,+  journalAmountAndPriceCommodities,+  journalAmounts,+  journalCanonicalCommodities,+  journalDateSpan,+  journalFilePath,+  journalFilePaths,+  journalPostings,+  -- * Standard account types+  journalBalanceSheetAccountQuery,+  journalProfitAndLossAccountQuery,+  journalIncomeAccountQuery,+  journalExpenseAccountQuery,+  journalAssetAccountQuery,+  journalLiabilityAccountQuery,+  journalEquityAccountQuery,+  journalCashAccountQuery,+  -- * Misc+  groupPostings,+  matchpats,+  nullctx,+  nulljournal,+  -- * Tests+  samplejournal,+  tests_Hledger_Data_Journal,+) where import Data.List-import Data.Map (findWithDefault, (!))+import Data.Map (findWithDefault, (!), toAscList) import Data.Ord import Data.Time.Calendar import Data.Time.LocalTime@@ -23,13 +67,14 @@ import Hledger.Utils import Hledger.Data.Types import Hledger.Data.AccountName+import Hledger.Data.Account() import Hledger.Data.Amount-import Hledger.Data.Commodity (canonicaliseCommodities)-import Hledger.Data.Dates (nulldatespan)-import Hledger.Data.Transaction (journalTransactionWithDate,balanceTransaction)+import Hledger.Data.Commodity+import Hledger.Data.Dates+import Hledger.Data.Transaction import Hledger.Data.Posting import Hledger.Data.TimeLog-import Hledger.Data.Matching+import Hledger.Query   instance Show Journal where@@ -43,17 +88,17 @@              -- ++ (show $ journalTransactions l)              where accounts = flatten $ journalAccountNameTree j -showJournalDebug j = unlines [-                      show j-                     ,show (jtxns j)-                     ,show (jmodifiertxns j)-                     ,show (jperiodictxns j)-                     ,show $ open_timelog_entries j-                     ,show $ historical_prices j-                     ,show $ final_comment_lines j-                     ,show $ jContext j-                     ,show $ map fst $ files j-                     ]+-- showJournalDebug j = unlines [+--                       show j+--                      ,show (jtxns j)+--                      ,show (jmodifiertxns j)+--                      ,show (jperiodictxns j)+--                      ,show $ open_timelog_entries j+--                      ,show $ historical_prices j+--                      ,show $ final_comment_lines j+--                      ,show $ jContext j+--                      ,show $ map fst $ files j+--                      ]  nulljournal :: Journal nulljournal = Journal { jmodifiertxns = []@@ -70,17 +115,6 @@ nullctx :: JournalContext nullctx = Ctx { ctxYear = Nothing, ctxCommodity = Nothing, ctxAccount = [], ctxAliases = [] } -nullfilterspec :: FilterSpec-nullfilterspec = FilterSpec {-     datespan=nulldatespan-    ,cleared=Nothing-    ,real=False-    ,empty=False-    ,acctpats=[]-    ,descpats=[]-    ,depth=Nothing-    }- journalFilePath :: Journal -> FilePath journalFilePath = fst . mainfile @@ -108,6 +142,7 @@ journalPostings :: Journal -> [Posting] journalPostings = concatMap tpostings . jtxns +-- | All account names used in this journal. journalAccountNamesUsed :: Journal -> [AccountName] journalAccountNamesUsed = sort . accountNamesFromPostings . journalPostings @@ -117,6 +152,55 @@ journalAccountNameTree :: Journal -> Tree AccountName journalAccountNameTree = accountNameTreeFrom . journalAccountNames +-- standard account types++-- | A query for Profit & Loss accounts in this journal.+-- Cf <http://en.wikipedia.org/wiki/Chart_of_accounts#Profit_.26_Loss_accounts>.+journalProfitAndLossAccountQuery  :: Journal -> Query+journalProfitAndLossAccountQuery j = Or [journalIncomeAccountQuery j+                                               ,journalExpenseAccountQuery j+                                               ]++-- | A query for Income (Revenue) accounts in this journal.+-- This is currently hard-coded to the case-insensitive regex @^(income|revenue)s?(:|$)@.+journalIncomeAccountQuery  :: Journal -> Query+journalIncomeAccountQuery _ = Acct "^(income|revenue)s?(:|$)"++-- | A query for Expense accounts in this journal.+-- This is currently hard-coded to the case-insensitive regex @^expenses?(:|$)@.+journalExpenseAccountQuery  :: Journal -> Query+journalExpenseAccountQuery _ = Acct "^expenses?(:|$)"++-- | A query for Asset, Liability & Equity accounts in this journal.+-- Cf <http://en.wikipedia.org/wiki/Chart_of_accounts#Balance_Sheet_Accounts>.+journalBalanceSheetAccountQuery  :: Journal -> Query+journalBalanceSheetAccountQuery j = Or [journalAssetAccountQuery j+                                              ,journalLiabilityAccountQuery j+                                              ,journalEquityAccountQuery j+                                              ]++-- | A query for Asset accounts in this journal.+-- This is currently hard-coded to the case-insensitive regex @^assets?(:|$)@.+journalAssetAccountQuery  :: Journal -> Query+journalAssetAccountQuery _ = Acct "^assets?(:|$)"++-- | A query for Liability accounts in this journal.+-- This is currently hard-coded to the case-insensitive regex @^liabilit(y|ies)(:|$)@.+journalLiabilityAccountQuery  :: Journal -> Query+journalLiabilityAccountQuery _ = Acct "^liabilit(y|ies)(:|$)"++-- | A query for Equity accounts in this journal.+-- This is currently hard-coded to the case-insensitive regex @^equity(:|$)@.+journalEquityAccountQuery  :: Journal -> Query+journalEquityAccountQuery _ = Acct "^equity(:|$)"++-- | A query for Cash (-equivalent) accounts in this journal (ie,+-- accounts which appear on the cashflow statement.)  This is currently+-- hard-coded to be all the Asset accounts except for those containing the+-- case-insensitive regex @(receivable|A/R)@.+journalCashAccountQuery  :: Journal -> Query+journalCashAccountQuery j = And [journalAssetAccountQuery j, Not $ Acct "(receivable|A/R)"]+ -- Various kinds of filtering on journals. We do it differently depending -- on the command. @@ -125,15 +209,16 @@  -- | Keep only postings matching the query expression. -- This can leave unbalanced transactions.-filterJournalPostings2 :: Matcher -> Journal -> Journal-filterJournalPostings2 m j@Journal{jtxns=ts} = j{jtxns=map filtertransactionpostings ts}+filterJournalPostings :: Query -> Journal -> Journal+filterJournalPostings q j@Journal{jtxns=ts} = j{jtxns=map filtertransactionpostings ts}     where-      filtertransactionpostings t@Transaction{tpostings=ps} = t{tpostings=filter (m `matchesPosting`) ps}+      filtertransactionpostings t@Transaction{tpostings=ps} = t{tpostings=filter (q `matchesPosting`) ps}  -- | Keep only transactions matching the query expression.-filterJournalTransactions2 :: Matcher -> Journal -> Journal-filterJournalTransactions2 m j@Journal{jtxns=ts} = j{jtxns=filter (m `matchesTransaction`) ts}+filterJournalTransactions :: Query -> Journal -> Journal+filterJournalTransactions q j@Journal{jtxns=ts} = j{jtxns=filter (q `matchesTransaction`) ts} +{- ------------------------------------------------------------------------------- -- filtering V1 @@ -147,10 +232,12 @@                                     ,acctpats=apats                                     ,descpats=dpats                                     ,depth=depth+                                    ,fMetadata=md                                     } =     filterJournalTransactionsByClearedStatus cleared .     filterJournalPostingsByDepth depth .     filterJournalTransactionsByAccount apats .+    filterJournalTransactionsByMetadata md .     filterJournalTransactionsByDescription dpats .     filterJournalTransactionsByDate datespan @@ -164,15 +251,22 @@                                 ,acctpats=apats                                 ,descpats=dpats                                 ,depth=depth+                                ,fMetadata=md                                 } =     filterJournalPostingsByRealness real .     filterJournalPostingsByClearedStatus cleared .     filterJournalPostingsByEmpty empty .     filterJournalPostingsByDepth depth .     filterJournalPostingsByAccount apats .+    filterJournalTransactionsByMetadata md .     filterJournalTransactionsByDescription dpats .     filterJournalTransactionsByDate datespan +-- | Keep only transactions whose metadata matches all metadata specifications.+filterJournalTransactionsByMetadata :: [(String,String)] -> Journal -> Journal+filterJournalTransactionsByMetadata pats j@Journal{jtxns=ts} = j{jtxns=filter matchmd ts}+    where matchmd t = all (`elem` tmetadata t) pats+ -- | Keep only transactions whose description matches the description patterns. filterJournalTransactionsByDescription :: [String] -> Journal -> Journal filterJournalTransactionsByDescription pats j@Journal{jtxns=ts} = j{jtxns=filter matchdesc ts}@@ -202,21 +296,21 @@ -- | Strip out any virtual postings, if the flag is true, otherwise do -- no filtering. filterJournalPostingsByRealness :: Bool -> Journal -> Journal-filterJournalPostingsByRealness False l = l+filterJournalPostingsByRealness False j = j filterJournalPostingsByRealness True j@Journal{jtxns=ts} = j{jtxns=map filterpostings ts}     where filterpostings t@Transaction{tpostings=ps} = t{tpostings=filter isReal ps}  -- | Strip out any postings with zero amount, unless the flag is true. filterJournalPostingsByEmpty :: Bool -> Journal -> Journal-filterJournalPostingsByEmpty True l = l+filterJournalPostingsByEmpty True j = j filterJournalPostingsByEmpty False j@Journal{jtxns=ts} = j{jtxns=map filterpostings ts}     where filterpostings t@Transaction{tpostings=ps} = t{tpostings=filter (not . isEmptyPosting) ps} --- | Keep only transactions which affect accounts deeper than the specified depth.-filterJournalTransactionsByDepth :: Maybe Int -> Journal -> Journal-filterJournalTransactionsByDepth Nothing j = j-filterJournalTransactionsByDepth (Just d) j@Journal{jtxns=ts} =-    j{jtxns=(filter (any ((<= d+1) . accountNameLevel . paccount) . tpostings) ts)}+-- -- | Keep only transactions which affect accounts deeper than the specified depth.+-- filterJournalTransactionsByDepth :: Maybe Int -> Journal -> Journal+-- filterJournalTransactionsByDepth Nothing j = j+-- filterJournalTransactionsByDepth (Just d) j@Journal{jtxns=ts} =+--     j{jtxns=(filter (any ((<= d+1) . accountNameLevel . paccount) . tpostings) ts)}  -- | Strip out any postings to accounts deeper than the specified depth -- (and any transactions which have no postings as a result).@@ -227,6 +321,12 @@     where filtertxns t@Transaction{tpostings=ps} =               t{tpostings=filter ((<= d) . accountNameLevel . paccount) ps} +-- | Keep only postings which affect accounts matched by the account patterns.+-- This can leave transactions unbalanced.+filterJournalPostingsByAccount :: [String] -> Journal -> Journal+filterJournalPostingsByAccount apats j@Journal{jtxns=ts} = j{jtxns=map filterpostings ts}+    where filterpostings t@Transaction{tpostings=ps} = t{tpostings=filter (matchpats apats . paccount) ps}+ -- | Keep only transactions which affect accounts matched by the account patterns. -- More precisely: each positive account pattern excludes transactions -- which do not contain a posting to a matched account, and each negative@@ -241,11 +341,7 @@       amatch pat a = regexMatchesCI (abspat pat) a       (negatives,positives) = partition isnegativepat apats --- | Keep only postings which affect accounts matched by the account patterns.--- This can leave transactions unbalanced.-filterJournalPostingsByAccount :: [String] -> Journal -> Journal-filterJournalPostingsByAccount apats j@Journal{jtxns=ts} = j{jtxns=map filterpostings ts}-    where filterpostings t@Transaction{tpostings=ps} = t{tpostings=filter (matchpats apats . paccount) ps}+-}  -- | Convert this journal's transactions' primary date to either the -- actual or effective date.@@ -261,7 +357,9 @@       fixtransaction t@Transaction{tpostings=ps} = t{tpostings=map fixposting ps}       fixposting p@Posting{paccount=a} = p{paccount=accountNameApplyAliases aliases a} --- | Do post-parse processing on a journal, to make it ready for use.+-- | Do post-parse processing on a journal to make it ready for use: check+-- all transactions balance, canonicalise amount formats, close any open+-- timelog entries and so on. journalFinalise :: ClockTime -> LocalTime -> FilePath -> String -> JournalContext -> Journal -> Either String Journal journalFinalise tclock tlocal path txt ctx j@Journal{files=fs} =     journalBalanceTransactions $@@ -293,25 +391,25 @@       fixcommodity c@Commodity{symbol=s} = findWithDefault c s canonicalcommoditymap       canonicalcommoditymap = journalCanonicalCommodities j --- | Apply this journal's historical price records to unpriced amounts where possible.-journalApplyHistoricalPrices :: Journal -> Journal-journalApplyHistoricalPrices j@Journal{jtxns=ts} = j{jtxns=map fixtransaction ts}-    where-      fixtransaction t@Transaction{tdate=d, tpostings=ps} = t{tpostings=map fixposting ps}-       where-        fixposting p@Posting{pamount=a} = p{pamount=fixmixedamount a}-        fixmixedamount (Mixed as) = Mixed $ map fixamount as-        fixamount = fixprice-        fixprice a@Amount{price=Just _} = a-        fixprice a@Amount{commodity=c} = a{price=maybe Nothing (Just . UnitPrice) $ journalHistoricalPriceFor j d c}+-- -- | Apply this journal's historical price records to unpriced amounts where possible.+-- journalApplyHistoricalPrices :: Journal -> Journal+-- journalApplyHistoricalPrices j@Journal{jtxns=ts} = j{jtxns=map fixtransaction ts}+--     where+--       fixtransaction t@Transaction{tdate=d, tpostings=ps} = t{tpostings=map fixposting ps}+--        where+--         fixposting p@Posting{pamount=a} = p{pamount=fixmixedamount a}+--         fixmixedamount (Mixed as) = Mixed $ map fixamount as+--         fixamount = fixprice+--         fixprice a@Amount{price=Just _} = a+--         fixprice a@Amount{commodity=c} = a{price=maybe Nothing (Just . UnitPrice) $ journalHistoricalPriceFor j d c} --- | Get the price for a commodity on the specified day from the price database, if known.--- Does only one lookup step, ie will not look up the price of a price.-journalHistoricalPriceFor :: Journal -> Day -> Commodity -> Maybe MixedAmount-journalHistoricalPriceFor j d Commodity{symbol=s} = do-  let ps = reverse $ filter ((<= d).hdate) $ filter ((s==).hsymbol) $ sortBy (comparing hdate) $ historical_prices j-  case ps of (HistoricalPrice{hamount=a}:_) -> Just a-             _ -> Nothing+-- -- | Get the price for a commodity on the specified day from the price database, if known.+-- -- Does only one lookup step, ie will not look up the price of a price.+-- journalHistoricalPriceFor :: Journal -> Day -> Commodity -> Maybe MixedAmount+-- journalHistoricalPriceFor j d Commodity{symbol=s} = do+--   let ps = reverse $ filter ((<= d).hdate) $ filter ((s==).hsymbol) $ sortBy (comparing hdate) $ historical_prices j+--   case ps of (HistoricalPrice{hamount=a}:_) -> Just a+--              _ -> Nothing  -- | Close any open timelog sessions in this journal using the provided current time. journalCloseTimeLogEntries :: LocalTime -> Journal -> Journal@@ -360,6 +458,8 @@     where       ts = sortBy (comparing tdate) $ jtxns j +-- Misc helpers+ -- | Check if a set of hledger account/description filter patterns matches the -- given account name or entry description.  Patterns are case-insensitive -- regular expressions. Prefixed with not:, they become anti-patterns.@@ -386,13 +486,168 @@       amap = Map.fromList [(a, acctinfo a) | a <- flatten ant]       acctinfo a = Account a (psof a) (inclbalof a) +tests_journalAccountInfo = [+ "journalAccountInfo" ~: do+   let (t,m) = journalAccountInfo samplejournal+   assertEqual "account tree"+    (Node "top" [+      Node "assets" [+       Node "assets:bank" [+        Node "assets:bank:checking" [],+        Node "assets:bank:saving" []+        ],+       Node "assets:cash" []+       ],+      Node "expenses" [+       Node "expenses:food" [],+       Node "expenses:supplies" []+       ],+      Node "income" [+       Node "income:gifts" [],+       Node "income:salary" []+       ],+      Node "liabilities" [+       Node "liabilities:debts" []+       ]+      ]+     )+    t+   mapM_ +         (\(e,a) -> assertEqual "" e a)+         (zip [+               ("assets",Account "assets" [] (Mixed [dollars (-1)]))+              ,("assets:bank",Account "assets:bank" [] (Mixed [dollars 1]))+              ,("assets:bank:checking",Account "assets:bank:checking" [+                  Posting {+                    pstatus=False,+                    paccount="assets:bank:checking",+                    pamount=(Mixed [dollars 1]),+                    pcomment="",+                    ptype=RegularPosting,+                    ptags=[],+                    ptransaction=Nothing+                  },+                  Posting {+                    pstatus=False,+                    paccount="assets:bank:checking",+                    pamount=(Mixed [dollars 1]),+                    pcomment="",+                    ptype=RegularPosting,+                    ptags=[],+                    ptransaction=Nothing+                  },+                  Posting {+                    pstatus=False,+                    paccount="assets:bank:checking",+                    pamount=(Mixed [dollars (-1)]),+                    pcomment="",+                    ptype=RegularPosting,+                    ptags=[],+                    ptransaction=Nothing+                  },+                  Posting {+                    pstatus=False,+                    paccount="assets:bank:checking",+                    pamount=(Mixed [dollars (-1)]),+                    pcomment="",+                    ptype=RegularPosting,+                    ptags=[],+                    ptransaction=Nothing+                  }+                  ] (Mixed [nullamt]))+              ,("assets:bank:saving",Account "assets:bank:saving" [+                  Posting {+                    pstatus=False,+                    paccount="assets:bank:saving",+                    pamount=(Mixed [dollars 1]),+                    pcomment="",+                    ptype=RegularPosting,+                    ptags=[],+                    ptransaction=Nothing+                  }+                  ] (Mixed [dollars 1]))+              ,("assets:cash",Account "assets:cash" [+                  Posting {+                    pstatus=False,+                    paccount="assets:cash",+                    pamount=(Mixed [dollars (-2)]),+                    pcomment="",+                    ptype=RegularPosting,+                    ptags=[],+                    ptransaction=Nothing+                  }+                ] (Mixed [dollars (-2)]))+              ,("expenses",Account "expenses" [] (Mixed [dollars 2]))+              ,("expenses:food",Account "expenses:food" [+                  Posting {+                    pstatus=False,+                    paccount="expenses:food",+                    pamount=(Mixed [dollars 1]),+                    pcomment="",+                    ptype=RegularPosting,+                    ptags=[],+                    ptransaction=Nothing+                  }+                ] (Mixed [dollars 1]))+              ,("expenses:supplies",Account "expenses:supplies" [+                  Posting {+                    pstatus=False,+                    paccount="expenses:supplies",+                    pamount=(Mixed [dollars 1]),+                    pcomment="",+                    ptype=RegularPosting,+                    ptags=[],+                    ptransaction=Nothing+                  }+                ] (Mixed [dollars 1]))+              ,("income",Account "income" [] (Mixed [dollars (-2)]))+              ,("income:gifts",Account "income:gifts" [+                  Posting {+                    pstatus=False,+                    paccount="income:gifts",+                    pamount=(Mixed [dollars (-1)]),+                    pcomment="",+                    ptype=RegularPosting,+                    ptags=[],+                    ptransaction=Nothing+                  }+                ] (Mixed [dollars (-1)]))+              ,("income:salary",Account "income:salary" [+                  Posting {+                    pstatus=False,+                    paccount="income:salary",+                    pamount=(Mixed [dollars (-1)]),+                    pcomment="",+                    ptype=RegularPosting,+                    ptags=[],+                    ptransaction=Nothing+                  }+                  ] (Mixed [dollars (-1)]))+              ,("liabilities",Account "liabilities" [] (Mixed [dollars 1]))+              ,("liabilities:debts",Account "liabilities:debts" [+                  Posting {+                    pstatus=False,+                    paccount="liabilities:debts",+                    pamount=(Mixed [dollars 1]),+                    pcomment="",+                    ptype=RegularPosting,+                    ptags=[],+                    ptransaction=Nothing+                  }+                ] (Mixed [dollars 1]))+              ,("top",Account "top" [] (Mixed [nullamt]))+             ]+             (toAscList m)+         )+ ]+ -- | Given a list of postings, return an account name tree and three query -- functions that fetch postings, subaccount-excluding-balance and -- subaccount-including-balance by account name. groupPostings :: [Posting] -> (Tree AccountName,-                             (AccountName -> [Posting]),-                             (AccountName -> MixedAmount),-                             (AccountName -> MixedAmount))+                               (AccountName -> [Posting]),+                               (AccountName -> MixedAmount),+                               (AccountName -> MixedAmount)) groupPostings ps = (ant, psof, exclbalof, inclbalof)     where       anames = sort $ nub $ map paccount ps@@ -426,9 +681,215 @@       m' = Map.fromList [(paccount $ head g, g) | g <- groupedps]  -- debug helpers-traceAmountPrecision a = trace (show $ map (precision . commodity) $ amounts a) a-tracePostingsCommodities ps = trace (show $ map ((map (precision . commodity) . amounts) . pamount) ps) ps+-- traceAmountPrecision a = trace (show $ map (precision . commodity) $ amounts a) a+-- tracePostingsCommodities ps = trace (show $ map ((map (precision . commodity) . amounts) . pamount) ps) ps -tests_Hledger_Data_Journal = TestList [- ]+-- tests +-- A sample journal for testing, similar to data/sample.journal:+--+-- 2008/01/01 income+--     assets:bank:checking  $1+--     income:salary+--+-- 2008/06/01 gift+--     assets:bank:checking  $1+--     income:gifts+--+-- 2008/06/02 save+--     assets:bank:saving  $1+--     assets:bank:checking+--+-- 2008/06/03 * eat & shop+--     expenses:food      $1+--     expenses:supplies  $1+--     assets:cash+--+-- 2008/12/31 * pay off+--     liabilities:debts  $1+--     assets:bank:checking+--+Right samplejournal = journalBalanceTransactions $ Journal+          [] +          [] +          [+           txnTieKnot $ Transaction {+             tdate=parsedate "2008/01/01",+             teffectivedate=Nothing,+             tstatus=False,+             tcode="",+             tdescription="income",+             tcomment="",+             ttags=[],+             tpostings=[+              Posting {+                pstatus=False,+                paccount="assets:bank:checking",+                pamount=(Mixed [dollars 1]),+                pcomment="",+                ptype=RegularPosting,+                ptags=[],+                ptransaction=Nothing+              },+              Posting {+                pstatus=False,+                paccount="income:salary",+                pamount=(missingmixedamt),+                pcomment="",+                ptype=RegularPosting,+                ptags=[],+                ptransaction=Nothing+              }+             ],+             tpreceding_comment_lines=""+           }+          ,+           txnTieKnot $ Transaction {+             tdate=parsedate "2008/06/01",+             teffectivedate=Nothing,+             tstatus=False,+             tcode="",+             tdescription="gift",+             tcomment="",+             ttags=[],+             tpostings=[+              Posting {+                pstatus=False,+                paccount="assets:bank:checking",+                pamount=(Mixed [dollars 1]),+                pcomment="",+                ptype=RegularPosting,+                ptags=[],+                ptransaction=Nothing+              },+              Posting {+                pstatus=False,+                paccount="income:gifts",+                pamount=(missingmixedamt),+                pcomment="",+                ptype=RegularPosting,+                ptags=[],+                ptransaction=Nothing+              }+             ],+             tpreceding_comment_lines=""+           }+          ,+           txnTieKnot $ Transaction {+             tdate=parsedate "2008/06/02",+             teffectivedate=Nothing,+             tstatus=False,+             tcode="",+             tdescription="save",+             tcomment="",+             ttags=[],+             tpostings=[+              Posting {+                pstatus=False,+                paccount="assets:bank:saving",+                pamount=(Mixed [dollars 1]),+                pcomment="",+                ptype=RegularPosting,+                ptags=[],+                ptransaction=Nothing+              },+              Posting {+                pstatus=False,+                paccount="assets:bank:checking",+                pamount=(Mixed [dollars (-1)]),+                pcomment="",+                ptype=RegularPosting,+                ptags=[],+                ptransaction=Nothing+              }+             ],+             tpreceding_comment_lines=""+           }+          ,+           txnTieKnot $ Transaction {+             tdate=parsedate "2008/06/03",+             teffectivedate=Nothing,+             tstatus=True,+             tcode="",+             tdescription="eat & shop",+             tcomment="",+             ttags=[],+             tpostings=[+              Posting {+                pstatus=False,+                paccount="expenses:food",+                pamount=(Mixed [dollars 1]),+                pcomment="",+                ptype=RegularPosting,+                ptags=[],+                ptransaction=Nothing+              },+              Posting {+                pstatus=False,+                paccount="expenses:supplies",+                pamount=(Mixed [dollars 1]),+                pcomment="",+                ptype=RegularPosting,+                ptags=[],+                ptransaction=Nothing+              },+              Posting {+                pstatus=False,+                paccount="assets:cash",+                pamount=(missingmixedamt),+                pcomment="",+                ptype=RegularPosting,+                ptags=[],+                ptransaction=Nothing+              }+             ],+             tpreceding_comment_lines=""+           }+          ,+           txnTieKnot $ Transaction {+             tdate=parsedate "2008/12/31",+             teffectivedate=Nothing,+             tstatus=False,+             tcode="",+             tdescription="pay off",+             tcomment="",+             ttags=[],+             tpostings=[+              Posting {+                pstatus=False,+                paccount="liabilities:debts",+                pamount=(Mixed [dollars 1]),+                pcomment="",+                ptype=RegularPosting,+                ptags=[],+                ptransaction=Nothing+              },+              Posting {+                pstatus=False,+                paccount="assets:bank:checking",+                pamount=(Mixed [dollars (-1)]),+                pcomment="",+                ptype=RegularPosting,+                ptags=[],+                ptransaction=Nothing+              }+             ],+             tpreceding_comment_lines=""+           }+          ]+          []+          []+          ""+          nullctx+          []+          (TOD 0 0)++tests_Hledger_Data_Journal = TestList $+    tests_journalAccountInfo+  -- [+  -- "query standard account types" ~:+  --  do+  --   let j = journal1+  --   journalBalanceSheetAccountNames j `is` ["assets","assets:a","equity","equity:q","equity:q:qq","liabilities","liabilities:l"]+  --   journalProfitAndLossAccountNames j `is` ["expenses","expenses:e","income","income:i"]+ -- ]
Hledger/Data/Ledger.hs view
@@ -20,48 +20,46 @@ import Hledger.Data.AccountName import Hledger.Data.Journal import Hledger.Data.Posting-import Hledger.Data.Matching+import Hledger.Query   instance Show Ledger where     show l = printf "Ledger with %d transactions, %d accounts\n%s"-             (length (jtxns $ journal l) +-              length (jmodifiertxns $ journal l) +-              length (jperiodictxns $ journal l))-             (length $ accountnames l)-             (showtree $ accountnametree l)+             (length (jtxns $ ledgerJournal l) ++              length (jmodifiertxns $ ledgerJournal l) ++              length (jperiodictxns $ ledgerJournal l))+             (length $ ledgerAccountNames l)+             (showtree $ ledgerAccountNameTree l)  nullledger :: Ledger nullledger = Ledger{-      journal = nulljournal,-      accountnametree = nullaccountnametree,-      accountmap = fromList []+      ledgerJournal = nulljournal,+      ledgerAccountNameTree = nullaccountnametree,+      ledgerAccountMap = fromList []     }  -- | Filter a journal's transactions as specified, and then process them -- to derive a ledger containing all balances, the chart of accounts, -- canonicalised commodities etc.-journalToLedger :: FilterSpec -> Journal -> Ledger-journalToLedger fs j = nullledger{journal=j',accountnametree=t,accountmap=m}-    where j' = filterJournalPostings fs{depth=Nothing} j-          (t, m) = journalAccountInfo j'---- | Filter a journal's transactions as specified, and then process them--- to derive a ledger containing all balances, the chart of accounts,--- canonicalised commodities etc.--- Like journalToLedger but uses the new matchers.-journalToLedger2 :: Matcher -> Journal -> Ledger-journalToLedger2 m j = nullledger{journal=j',accountnametree=t,accountmap=amap}-    where j' = filterJournalPostings2 m j+journalToLedger :: Query -> Journal -> Ledger+journalToLedger q j = nullledger{ledgerJournal=j',ledgerAccountNameTree=t,ledgerAccountMap=amap}+    where j' = filterJournalPostings q j           (t, amap) = journalAccountInfo j' +tests_journalToLedger = [+ "journalToLedger" ~: do+  assertEqual "" (0) (length $ ledgerPostings $ journalToLedger Any nulljournal)+  assertEqual "" (11) (length $ ledgerPostings $ journalToLedger Any samplejournal)+  assertEqual "" (6) (length $ ledgerPostings $ journalToLedger (Depth 2) samplejournal)+ ]+ -- | List a ledger's account names. ledgerAccountNames :: Ledger -> [AccountName]-ledgerAccountNames = drop 1 . flatten . accountnametree+ledgerAccountNames = drop 1 . flatten . ledgerAccountNameTree  -- | Get the named account from a ledger. ledgerAccount :: Ledger -> AccountName -> Account-ledgerAccount l a = findWithDefault nullacct a $ accountmap l+ledgerAccount l a = findWithDefault nullacct a $ ledgerAccountMap l  -- | List a ledger's accounts, in tree order ledgerAccounts :: Ledger -> [Account]@@ -77,20 +75,20 @@  -- | Accounts in ledger whose name matches the pattern, in tree order. ledgerAccountsMatching :: [String] -> Ledger -> [Account]-ledgerAccountsMatching pats = filter (matchpats pats . aname) . accounts+ledgerAccountsMatching pats = filter (matchpats pats . aname) . ledgerAccounts  -- | List a ledger account's immediate subaccounts ledgerSubAccounts :: Ledger -> Account -> [Account] ledgerSubAccounts l Account{aname=a} = -    map (ledgerAccount l) $ filter (`isSubAccountNameOf` a) $ accountnames l+    map (ledgerAccount l) $ filter (`isSubAccountNameOf` a) $ ledgerAccountNames l  -- | List a ledger's postings, in the order parsed. ledgerPostings :: Ledger -> [Posting]-ledgerPostings = journalPostings . journal+ledgerPostings = journalPostings . ledgerJournal  -- | Get a ledger's tree of accounts to the specified depth. ledgerAccountTree :: Int -> Ledger -> Tree Account-ledgerAccountTree depth l = treemap (ledgerAccount l) $ treeprune depth $ accountnametree l+ledgerAccountTree depth l = treemap (ledgerAccount l) $ treeprune depth $ ledgerAccountNameTree l  -- | Get a ledger's tree of accounts rooted at the specified account. ledgerAccountTreeAt :: Ledger -> Account -> Maybe (Tree Account)@@ -101,47 +99,10 @@ ledgerDateSpan :: Ledger -> DateSpan ledgerDateSpan = postingsDateSpan . ledgerPostings --- | Convenience aliases.-accountnames :: Ledger -> [AccountName]-accountnames = ledgerAccountNames--account :: Ledger -> AccountName -> Account-account = ledgerAccount--accounts :: Ledger -> [Account]-accounts = ledgerAccounts--topaccounts :: Ledger -> [Account]-topaccounts = ledgerTopAccounts--accountsmatching :: [String] -> Ledger -> [Account]-accountsmatching = ledgerAccountsMatching--subaccounts :: Ledger -> Account -> [Account]-subaccounts = ledgerSubAccounts--postings :: Ledger -> [Posting]-postings = ledgerPostings--commodities :: Ledger -> Map String Commodity-commodities = journalCanonicalCommodities . journal--accounttree :: Int -> Ledger -> Tree Account-accounttree = ledgerAccountTree--accounttreeat :: Ledger -> Account -> Maybe (Tree Account)-accounttreeat = ledgerAccountTreeAt---- datespan :: Ledger -> DateSpan--- datespan = ledgerDateSpan--rawdatespan :: Ledger -> DateSpan-rawdatespan = journalDateSpan . journal--ledgeramounts :: Ledger -> [MixedAmount]-ledgeramounts = journalAmounts . journal+-- | All commodities used in this ledger, as a map keyed by symbol.+ledgerCommodities :: Ledger -> Map String Commodity+ledgerCommodities = journalCanonicalCommodities . ledgerJournal -tests_Hledger_Data_Ledger = TestList- [- ]+tests_Hledger_Data_Ledger = TestList $+    tests_journalToLedger 
− Hledger/Data/Matching.hs
@@ -1,324 +0,0 @@-{-|--More generic matching, done in one step, unlike FilterSpec and filterJournal*. -Currently used only by hledger-web.---}--module Hledger.Data.Matching-where-import Data.Either-import Data.List--- import Data.Map (findWithDefault, (!))-import Data.Maybe--- import Data.Ord-import Data.Time.Calendar--- import Data.Time.LocalTime--- import Data.Tree-import Safe (readDef, headDef)--- import System.Time (ClockTime(TOD))-import Test.HUnit-import Text.ParserCombinators.Parsec--- import Text.Printf--- import qualified Data.Map as Map--import Hledger.Utils-import Hledger.Data.Types-import Hledger.Data.AccountName-import Hledger.Data.Amount--- import Hledger.Data.Commodity (canonicaliseCommodities)-import Hledger.Data.Dates-import Hledger.Data.Posting-import Hledger.Data.Transaction--- import Hledger.Data.TimeLog---- | A matcher is a single, or boolean composition of, search criteria,--- which can be used to match postings, transactions, accounts and more.--- Currently used by hledger-web, will likely replace FilterSpec at some point.-data Matcher = MatchAny              -- ^ always match-             | MatchNone             -- ^ never match-             | MatchNot Matcher      -- ^ negate this match-             | MatchOr [Matcher]     -- ^ match if any of these match-             | MatchAnd [Matcher]    -- ^ match if all of these match-             | MatchDesc String      -- ^ match if description matches this regexp-             | MatchAcct String      -- ^ match postings whose account matches this regexp-             | MatchDate DateSpan    -- ^ match if actual date in this date span-             | MatchEDate DateSpan   -- ^ match if effective date in this date span-             | MatchStatus Bool      -- ^ match if cleared status has this value-             | MatchReal Bool        -- ^ match if "realness" (involves a real non-virtual account ?) has this value-             | MatchEmpty Bool       -- ^ match if "emptiness" (from the --empty command-line flag) has this value.-                                     --   Currently this means a posting with zero amount.-             | MatchDepth Int        -- ^ match if account depth is less than or equal to this value-    deriving (Show, Eq)---- | A query option changes a query's/report's behaviour and output in some way.---- XXX could use regular CliOpts ?-data QueryOpt = QueryOptInAcctOnly AccountName  -- ^ show an account register focussed on this account-              | QueryOptInAcct AccountName      -- ^ as above but include sub-accounts in the account register-           -- | QueryOptCostBasis      -- ^ show amounts converted to cost where possible-           -- | QueryOptEffectiveDate  -- ^ show effective dates instead of actual dates-    deriving (Show, Eq)---- | The account we are currently focussed on, if any, and whether subaccounts are included.--- Just looks at the first query option.-inAccount :: [QueryOpt] -> Maybe (AccountName,Bool)-inAccount [] = Nothing-inAccount (QueryOptInAcctOnly a:_) = Just (a,False)-inAccount (QueryOptInAcct a:_) = Just (a,True)---- | A matcher for the account(s) we are currently focussed on, if any.--- Just looks at the first query option.-inAccountMatcher :: [QueryOpt] -> Maybe Matcher-inAccountMatcher [] = Nothing-inAccountMatcher (QueryOptInAcctOnly a:_) = Just $ MatchAcct $ accountNameToAccountOnlyRegex a-inAccountMatcher (QueryOptInAcct a:_) = Just $ MatchAcct $ accountNameToAccountRegex a---- -- | A matcher restricting the account(s) to be shown in the sidebar, if any.--- -- Just looks at the first query option.--- showAccountMatcher :: [QueryOpt] -> Maybe Matcher--- showAccountMatcher (QueryOptInAcctSubsOnly a:_) = Just $ MatchAcct True $ accountNameToAccountRegex a--- showAccountMatcher _ = Nothing---- | Convert a query expression containing zero or more space-separated--- terms to a matcher and zero or more query options. A query term is either:------ 1. a search criteria, used to match transactions. This is usually a prefixed pattern such as:---    acct:REGEXP---    date:PERIODEXP---    not:desc:REGEXP------ 2. a query option, which changes behaviour in some way. There is currently one of these:---    inacct:FULLACCTNAME - should appear only once------ Multiple search criteria are AND'ed together.--- When a pattern contains spaces, it or the whole term should be enclosed in single or double quotes.--- A reference date is required to interpret relative dates in period expressions.----parseQuery :: Day -> String -> (Matcher,[QueryOpt])-parseQuery d s = (m,qopts)-  where-    terms = words'' prefixes s-    (matchers, qopts) = partitionEithers $ map (parseMatcher d) terms-    m = case matchers of []      -> MatchAny-                         (m':[]) -> m'-                         ms      -> MatchAnd ms---- | Quote-and-prefix-aware version of words - don't split on spaces which--- are inside quotes, including quotes which may have one of the specified--- prefixes in front, and maybe an additional not: prefix in front of that.-words'' :: [String] -> String -> [String]-words'' prefixes = fromparse . parsewith maybeprefixedquotedphrases -- XXX-    where-      maybeprefixedquotedphrases = choice' [prefixedQuotedPattern, quotedPattern, pattern] `sepBy` many1 spacenonewline-      prefixedQuotedPattern = do-        not' <- fromMaybe "" `fmap` (optionMaybe $ string "not:")-        let allowednexts | null not' = prefixes-                         | otherwise = prefixes ++ [""]-        next <- choice' $ map string allowednexts-        let prefix = not' ++ next-        p <- quotedPattern-        return $ prefix ++ stripquotes p-      quotedPattern = do-        p <- between (oneOf "'\"") (oneOf "'\"") $ many $ noneOf "'\""-        return $ stripquotes p-      pattern = many (noneOf " \n\r\"")---- -- | Parse the query string as a boolean tree of match patterns.--- parseMatcher :: String -> Matcher--- parseMatcher s = either (const (MatchAny)) id $ runParser matcher () "" $ lexmatcher s---- lexmatcher :: String -> [String]--- lexmatcher s = words' s---- matcher :: GenParser String () Matcher--- matcher = undefined---- keep synced with patterns below, excluding "not"-prefixes = map (++":") [-            "inacct","inacctonly",-            "desc","acct","date","edate","status","real","empty","depth"-           ]-defaultprefix = "acct"---- | Parse a single query term as either a matcher or a query option.-parseMatcher :: Day -> String -> Either Matcher QueryOpt-parseMatcher _ ('i':'n':'a':'c':'c':'t':'o':'n':'l':'y':':':s) = Right $ QueryOptInAcctOnly s-parseMatcher _ ('i':'n':'a':'c':'c':'t':':':s) = Right $ QueryOptInAcct s-parseMatcher d ('n':'o':'t':':':s) = case parseMatcher d s of-                                       Left m  -> Left $ MatchNot m-                                       Right _ -> Left MatchAny -- not:somequeryoption will be ignored-parseMatcher _ ('d':'e':'s':'c':':':s) = Left $ MatchDesc s-parseMatcher _ ('a':'c':'c':'t':':':s) = Left $ MatchAcct s-parseMatcher d ('d':'a':'t':'e':':':s) =-        case parsePeriodExpr d s of Left _ -> Left MatchNone -- XXX should warn-                                    Right (_,span) -> Left $ MatchDate span-parseMatcher d ('e':'d':'a':'t':'e':':':s) =-        case parsePeriodExpr d s of Left _ -> Left MatchNone -- XXX should warn-                                    Right (_,span) -> Left $ MatchEDate span-parseMatcher _ ('s':'t':'a':'t':'u':'s':':':s) = Left $ MatchStatus $ parseStatus s-parseMatcher _ ('r':'e':'a':'l':':':s) = Left $ MatchReal $ parseBool s-parseMatcher _ ('e':'m':'p':'t':'y':':':s) = Left $ MatchEmpty $ parseBool s-parseMatcher _ ('d':'e':'p':'t':'h':':':s) = Left $ MatchDepth $ readDef 0 s-parseMatcher _ "" = Left $ MatchAny-parseMatcher d s = parseMatcher d $ defaultprefix++":"++s---- | Parse the boolean value part of a "status:" matcher, allowing "*" as--- another way to spell True, similar to the journal file format.-parseStatus :: String -> Bool-parseStatus s = s `elem` (truestrings ++ ["*"])---- | Parse the boolean value part of a "status:" matcher. A true value can--- be spelled as "1", "t" or "true".-parseBool :: String -> Bool-parseBool s = s `elem` truestrings--truestrings :: [String]-truestrings = ["1","t","true"]---- | Convert a match expression to its inverse.-negateMatcher :: Matcher -> Matcher-negateMatcher =  MatchNot---- | Does the match expression match this posting ?-matchesPosting :: Matcher -> Posting -> Bool-matchesPosting (MatchNot m) p = not $ matchesPosting m p-matchesPosting (MatchAny) _ = True-matchesPosting (MatchNone) _ = False-matchesPosting (MatchOr ms) p = any (`matchesPosting` p) ms-matchesPosting (MatchAnd ms) p = all (`matchesPosting` p) ms-matchesPosting (MatchDesc r) p = regexMatchesCI r $ maybe "" tdescription $ ptransaction p-matchesPosting (MatchAcct r) p = regexMatchesCI r $ paccount p-matchesPosting (MatchDate span) p =-    case d of Just d'  -> spanContainsDate span d'-              Nothing -> False-    where d = maybe Nothing (Just . tdate) $ ptransaction p-matchesPosting (MatchEDate span) p =-    case postingEffectiveDate p of Just d  -> spanContainsDate span d-                                   Nothing -> False-matchesPosting (MatchStatus v) p = v == postingCleared p-matchesPosting (MatchReal v) p = v == isReal p-matchesPosting (MatchEmpty v) Posting{pamount=a} = v == isZeroMixedAmount a-matchesPosting _ _ = False---- | Does the match expression match this transaction ?-matchesTransaction :: Matcher -> Transaction -> Bool-matchesTransaction (MatchNot m) t = not $ matchesTransaction m t-matchesTransaction (MatchAny) _ = True-matchesTransaction (MatchNone) _ = False-matchesTransaction (MatchOr ms) t = any (`matchesTransaction` t) ms-matchesTransaction (MatchAnd ms) t = all (`matchesTransaction` t) ms-matchesTransaction (MatchDesc r) t = regexMatchesCI r $ tdescription t-matchesTransaction m@(MatchAcct _) t = any (m `matchesPosting`) $ tpostings t-matchesTransaction (MatchDate span) t = spanContainsDate span $ tdate t-matchesTransaction (MatchEDate span) t = spanContainsDate span $ transactionEffectiveDate t-matchesTransaction (MatchStatus v) t = v == tstatus t-matchesTransaction (MatchReal v) t = v == hasRealPostings t-matchesTransaction _ _ = False--postingEffectiveDate :: Posting -> Maybe Day-postingEffectiveDate p = maybe Nothing (Just . transactionEffectiveDate) $ ptransaction p---- | Does the match expression match this account ?--- A matching in: clause is also considered a match.-matchesAccount :: Matcher -> AccountName -> Bool-matchesAccount (MatchNot m) a = not $ matchesAccount m a-matchesAccount (MatchAny) _ = True-matchesAccount (MatchNone) _ = False-matchesAccount (MatchOr ms) a = any (`matchesAccount` a) ms-matchesAccount (MatchAnd ms) a = all (`matchesAccount` a) ms-matchesAccount (MatchAcct r) a = regexMatchesCI r a-matchesAccount _ _ = False---- | What start date does this matcher specify, if any ?--- If the matcher is an OR expression, returns the earliest of the alternatives.--- When the flag is true, look for a starting effective date instead.-matcherStartDate :: Bool -> Matcher -> Maybe Day-matcherStartDate effective (MatchOr ms) = earliestMaybeDate $ map (matcherStartDate effective) ms-matcherStartDate effective (MatchAnd ms) = latestMaybeDate $ map (matcherStartDate effective) ms-matcherStartDate False (MatchDate (DateSpan (Just d) _)) = Just d-matcherStartDate True (MatchEDate (DateSpan (Just d) _)) = Just d-matcherStartDate _ _ = Nothing---- | Does this matcher specify a start date and nothing else (that would--- filter postings prior to the date) ?--- When the flag is true, look for a starting effective date instead.-matcherIsStartDateOnly :: Bool -> Matcher -> Bool-matcherIsStartDateOnly _ MatchAny = False-matcherIsStartDateOnly _ MatchNone = False-matcherIsStartDateOnly effective (MatchOr ms) = and $ map (matcherIsStartDateOnly effective) ms-matcherIsStartDateOnly effective (MatchAnd ms) = and $ map (matcherIsStartDateOnly effective) ms-matcherIsStartDateOnly False (MatchDate (DateSpan (Just _) _)) = True-matcherIsStartDateOnly True (MatchEDate (DateSpan (Just _) _)) = True-matcherIsStartDateOnly _ _ = False---- | Does this matcher match everything ?-matcherIsNull MatchAny = True-matcherIsNull (MatchAnd []) = True-matcherIsNull (MatchNot (MatchOr [])) = True-matcherIsNull _ = False---- | What is the earliest of these dates, where Nothing is earliest ?-earliestMaybeDate :: [Maybe Day] -> Maybe Day-earliestMaybeDate = headDef Nothing . sortBy compareMaybeDates---- | What is the latest of these dates, where Nothing is earliest ?-latestMaybeDate :: [Maybe Day] -> Maybe Day-latestMaybeDate = headDef Nothing . sortBy (flip compareMaybeDates)---- | Compare two maybe dates, Nothing is earliest.-compareMaybeDates :: Maybe Day -> Maybe Day -> Ordering-compareMaybeDates Nothing Nothing = EQ-compareMaybeDates Nothing (Just _) = LT-compareMaybeDates (Just _) Nothing = GT-compareMaybeDates (Just a) (Just b) = compare a b--tests_Hledger_Data_Matching :: Test-tests_Hledger_Data_Matching = TestList- [--  "parseQuery" ~: do-    let d = parsedate "2011/1/1"-    parseQuery d "a" `is` (MatchAcct "a", [])-    parseQuery d "acct:a" `is` (MatchAcct "a", [])-    parseQuery d "acct:a desc:b" `is` (MatchAnd [MatchAcct "a", MatchDesc "b"], [])-    parseQuery d "\"acct:expenses:autres d\233penses\"" `is` (MatchAcct "expenses:autres d\233penses", [])-    parseQuery d "not:desc:'a b'" `is` (MatchNot $ MatchDesc "a b", [])--    parseQuery d "inacct:a desc:b" `is` (MatchDesc "b", [QueryOptInAcct "a"])-    parseQuery d "inacct:a inacct:b" `is` (MatchAny, [QueryOptInAcct "a", QueryOptInAcct "b"])--    parseQuery d "status:1" `is` (MatchStatus True, [])-    parseQuery d "status:0" `is` (MatchStatus False, [])-    parseQuery d "status:" `is` (MatchStatus False, [])-    parseQuery d "real:1" `is` (MatchReal True, [])--  ,"matchesAccount" ~: do-    assertBool "positive acct match" $ matchesAccount (MatchAcct "b:c") "a:bb:c:d"-    -- assertBool "acct should match at beginning" $ not $ matchesAccount (MatchAcct True "a:b") "c:a:b"--  ,"matchesPosting" ~: do-    -- matching posting status..-    assertBool "positive match on true posting status"  $-                   (MatchStatus True)  `matchesPosting` nullposting{pstatus=True}-    assertBool "negative match on true posting status"  $-               not $ (MatchNot $ MatchStatus True)  `matchesPosting` nullposting{pstatus=True}-    assertBool "positive match on false posting status" $-                   (MatchStatus False) `matchesPosting` nullposting{pstatus=False}-    assertBool "negative match on false posting status" $-               not $ (MatchNot $ MatchStatus False) `matchesPosting` nullposting{pstatus=False}-    assertBool "positive match on true posting status acquired from transaction" $-                   (MatchStatus True) `matchesPosting` nullposting{pstatus=False,ptransaction=Just nulltransaction{tstatus=True}}-    assertBool "real:1 on real posting" $ (MatchReal True) `matchesPosting` nullposting{ptype=RegularPosting}-    assertBool "real:1 on virtual posting fails" $ not $ (MatchReal True) `matchesPosting` nullposting{ptype=VirtualPosting}-    assertBool "real:1 on balanced virtual posting fails" $ not $ (MatchReal True) `matchesPosting` nullposting{ptype=BalancedVirtualPosting}--  ,"words''" ~: do-    assertEqual "1" ["a","b"]        (words'' [] "a b")-    assertEqual "2" ["a b"]          (words'' [] "'a b'")-    assertEqual "3" ["not:a","b"]    (words'' [] "not:a b")-    assertEqual "4" ["not:a b"]    (words'' [] "not:'a b'")-    assertEqual "5" ["not:a b"]    (words'' [] "'not:a b'")-    assertEqual "6" ["not:desc:a b"]    (words'' ["desc:"] "not:desc:'a b'")-- ]
Hledger/Data/Posting.hs view
@@ -1,14 +1,47 @@ {-| -A 'Posting' represents a 'MixedAmount' being added to or subtracted from a-single 'Account'.  Each 'Transaction' contains two or more postings which-should add up to 0. Postings also reference their parent transaction, so-we can get a date or description for a posting (from the transaction).-Strictly speaking, \"entry\" is probably a better name for these.+A 'Posting' represents a change (by some 'MixedAmount') of the balance in+some 'Account'.  Each 'Transaction' contains two or more postings which+should add up to 0. Postings reference their parent transaction, so we can+look up the date or description there.  -} -module Hledger.Data.Posting+module Hledger.Data.Posting (+  -- * Posting+  nullposting,+  -- * operations+  postingCleared,+  isReal,+  isVirtual,+  isBalancedVirtual,+  isEmptyPosting,+  hasAmount,+  postingAllTags,+  transactionAllTags,+  -- * date operations+  postingDate,+  isPostingInDateSpan,+  postingsDateSpan,+  -- * account name operations+  accountNamesFromPostings,+  accountNamePostingType,+  accountNameWithoutPostingType,+  accountNameWithPostingType,+  joinAccountNames,+  concatAccountNames,+  accountNameApplyAliases,+  -- * arithmetic+  sumPostings,+  -- * rendering+  showPosting,+  showPostingForRegister,+  -- * misc.+  postingTagsAsLines,+  tagsAsLines,+  showComment,+  tests_Hledger_Data_Posting+) where import Data.List import Data.Ord@@ -30,8 +63,8 @@ nullposting = Posting False "" nullmixedamt "" RegularPosting [] Nothing  showPosting :: Posting -> String-showPosting (Posting{paccount=a,pamount=amt,pcomment=com,ptype=t}) =-    concatTopPadded [showaccountname a ++ " ", showamount amt, comment]+showPosting p@Posting{paccount=a,pamount=amt,ptype=t} =+    unlines $ [concatTopPadded [showaccountname a ++ " ", showamount amt, showComment (pcomment p)]] ++ postingTagsAsLines p     where       ledger3ishlayout = False       acctnamewidth = if ledger3ishlayout then 25 else 22@@ -41,7 +74,17 @@                           VirtualPosting -> (\s -> "("++s++")", acctnamewidth-2)                           _ -> (id,acctnamewidth)       showamount = padleft 12 . showMixedAmount-      comment = if null com then "" else "  ; " ++ com+++postingTagsAsLines :: Posting -> [String]+postingTagsAsLines = tagsAsLines . ptags++tagsAsLines :: [(String, String)] -> [String]+tagsAsLines mds = map (\(k,v) -> "    ; " ++ k++": "++v) mds++showComment :: String -> String+showComment s = if null s then "" else "  ; " ++ s+ -- XXX refactor showPostingForRegister :: Posting -> String showPostingForRegister (Posting{paccount=a,pamount=amt,ptype=t}) =@@ -66,7 +109,7 @@ isBalancedVirtual p = ptype p == BalancedVirtualPosting  hasAmount :: Posting -> Bool-hasAmount = (/= missingamt) . pamount+hasAmount = (/= missingmixedamt) . pamount  accountNamesFromPostings :: [Posting] -> [AccountName] accountNamesFromPostings = nub . map paccount@@ -86,6 +129,14 @@                     then True                     else maybe False tstatus $ ptransaction p +-- | Tags for this posting including any inherited from its parent transaction.+postingAllTags :: Posting -> [Tag]+postingAllTags p = ptags p ++ maybe [] transactionAllTags (ptransaction p)++-- | Tags for this transaction including any inherited from above, when that is implemented.+transactionAllTags :: Transaction -> [Tag]+transactionAllTags t = ttags t+ -- | Does this posting fall within the given date span ? isPostingInDateSpan :: DateSpan -> Posting -> Bool isPostingInDateSpan s = spanContainsDate s . postingDate@@ -93,8 +144,8 @@ isEmptyPosting :: Posting -> Bool isEmptyPosting = isZeroMixedAmount . pamount --- | Get the minimal date span which contains all the postings, or--- DateSpan Nothing Nothing if there are none.+-- | Get the minimal date span which contains all the postings, or the+-- null date span if there are none. postingsDateSpan :: [Posting] -> DateSpan postingsDateSpan [] = DateSpan Nothing Nothing postingsDateSpan ps = DateSpan (Just $ postingDate $ head ps') (Just $ addDays 1 $ postingDate $ last ps')
Hledger/Data/TimeLog.hs view
@@ -81,7 +81,7 @@             tcode         = "",             tdescription  = showtime itod ++ "-" ++ showtime otod,             tcomment      = "",-            tmetadata     = [],+            ttags     = [],             tpostings = ps,             tpreceding_comment_lines=""           }@@ -95,7 +95,7 @@       hrs      = elapsedSeconds (toutc otime) (toutc itime) / 3600 where toutc = localTimeToUTC utc       amount   = Mixed [hours hrs]       ps       = [Posting{pstatus=False,paccount=acctname,pamount=amount,-                          pcomment="",ptype=VirtualPosting,pmetadata=[],ptransaction=Just t}]+                          pcomment="",ptype=VirtualPosting,ptags=[],ptransaction=Just t}]  tests_Hledger_Data_TimeLog = TestList [ 
Hledger/Data/Transaction.hs view
@@ -1,12 +1,39 @@ {-| -A 'Transaction' consists of two or more related 'Posting's which balance-to zero, representing a movement of some commodity(ies) between accounts,-plus a date and optional metadata like description and cleared status.+A 'Transaction' represents a movement of some commodity(ies) between two+or more accounts. It consists of multiple account 'Posting's which balance+to zero, a date, and optional extras like description, cleared status, and+tags.  -} -module Hledger.Data.Transaction+module Hledger.Data.Transaction (+  -- * Transaction+  nulltransaction,+  txnTieKnot,+  -- settxn,+  -- * operations+  showAccountName,+  hasRealPostings,+  realPostings,+  virtualPostings,+  balancedVirtualPostings,+  transactionsPostings,+  isTransactionBalanced,+  -- nonzerobalanceerror,+  -- * date operations+  transactionActualDate,+  transactionEffectiveDate,+  journalTransactionWithDate,+  -- * arithmetic+  transactionPostingBalances,+  balanceTransaction,+  -- * rendering+  showTransaction,+  showTransactionUnelided,+  -- * misc.+  tests_Hledger_Data_Transaction+) where import Data.List import Data.Maybe@@ -38,7 +65,7 @@                     tcode="",                      tdescription="",                      tcomment="",-                    tmetadata=[],+                    ttags=[],                     tpostings=[],                     tpreceding_comment_lines=""                   }@@ -65,36 +92,114 @@ showTransactionUnelided :: Transaction -> String showTransactionUnelided = showTransaction' False +tests_showTransactionUnelided = [+   "showTransactionUnelided" ~: do+    let t `gives` s = assertEqual "" s (showTransactionUnelided t)+    nulltransaction `gives` "0000/01/01\n\n"+    nulltransaction{+      tdate=parsedate "2012/05/14",+      teffectivedate=Just $ parsedate "2012/05/15",+      tstatus=False,+      tcode="code",+      tdescription="desc",+      tcomment="tcomment1\ntcomment2\n",+      ttags=[("ttag1","val1")],+      tpostings=[+        nullposting{+          pstatus=True,+          paccount="a",+          pamount=Mixed [dollars 1, hours 2],+          pcomment="pcomment1\npcomment2\n",+          ptype=RegularPosting,+          ptags=[("ptag1","val1"),("ptag2","val2")]+          }+       ]+      }+      `gives` unlines [+      "2012/05/14=2012/05/15 (code) desc  ; tcomment1",+      "    ; tcomment2",+      "    ; ttag1: val1",+      "                $1.00",+      "    * a          2.0h  ; pcomment1",+      "    ; pcomment2",+      "    ; ptag1: val1",+      "    ; ptag2: val2",+      ""+      ]+ ]++-- XXX overlaps showPosting showTransaction' :: Bool -> Transaction -> String showTransaction' elide t =-    unlines $ [description] ++ showpostings (tpostings t) ++ [""]+    unlines $ [descriptionline]+              ++ commentlines+              ++ (tagsAsLines $ ttags t)+              ++ (postingsAsLines elide t (tpostings t))+              ++ [""]     where-      description = concat [date, status, code, desc, comment]+      descriptionline = rstrip $ concat [date, status, code, desc, firstcomment]       date = showdate (tdate t) ++ maybe "" showedate (teffectivedate t)       showdate = printf "%-10s" . showDate       showedate = printf "=%s" . showdate       status = if tstatus t then " *" else ""       code = if length (tcode t) > 0 then printf " (%s)" $ tcode t else ""       desc = if null d then "" else " " ++ d where d = tdescription t-      comment = if null c then "" else "  ; " ++ c where c = tcomment t-      showpostings ps-          | elide && length ps > 1 && isTransactionBalanced Nothing t -- imprecise balanced check-              = map showposting (init ps) ++ [showpostingnoamt (last ps)]-          | otherwise = map showposting ps-          where-            showpostingnoamt p = rstrip $ showacct p ++ "              " ++ showcomment (pcomment p)-            showposting p = concatTopPadded [showacct p-                                            ,"  "-                                            ,showamt (pamount p)-                                            ,showcomment (pcomment p)-                                            ]-            showacct p = "    " ++ showstatus p ++ printf (printf "%%-%ds" w) (showAccountName Nothing (ptype p) (paccount p))-                where w = maximum $ map (length . paccount) ps-                      showstatus p = if pstatus p then "* " else ""-            showamt =-                padleft 12 . showMixedAmount-            showcomment s = if null s then "" else "  ; "++s+      (firstcomment, commentlines) = commentLines $ tcomment t +-- Render a transaction or posting's comment as indented & prefixed comment lines.+commentLines :: String -> (String, [String])+commentLines s+    | null s = ("", [])+    | otherwise = ("  ; " ++ first, map (indent . ("; "++)) rest)+    where (first:rest) = lines s++postingsAsLines :: Bool -> Transaction -> [Posting] -> [String]+postingsAsLines elide t ps+    | elide && length ps > 1 && isTransactionBalanced Nothing t -- imprecise balanced check+       = (concatMap (postingAsLines False ps) $ init ps) ++ postingAsLines True ps (last ps)+    | otherwise = concatMap (postingAsLines False ps) ps++postingAsLines :: Bool -> [Posting] -> Posting -> [String]+postingAsLines elideamount ps p =+    postinglines+    ++ commentlines+    ++ tagsAsLines (ptags p)+  where+    postinglines = map rstrip $ lines $ concatTopPadded [showacct p, "  ", amount, firstcomment]+    amount = if elideamount then "" else showamt (pamount p)+    (firstcomment, commentlines) = commentLines $ pcomment p+    showacct p =+      indent $ showstatus p ++ printf (printf "%%-%ds" w) (showAccountName Nothing (ptype p) (paccount p))+        where+          showstatus p = if pstatus p then "* " else ""+          w = maximum $ map (length . paccount) ps+    showamt =+        padleft 12 . showMixedAmount++tests_postingAsLines = [+   "postingAsLines" ~: do+    let p `gives` ls = assertEqual "" ls (postingAsLines False [p] p)+    nullposting `gives` ["                 0"]+    nullposting{+      pstatus=True,+      paccount="a",+      pamount=Mixed [dollars 1, hours 2],+      pcomment="pcomment1\npcomment2\n",+      ptype=RegularPosting,+      ptags=[("ptag1","val1"),("ptag2","val2")]+      }+     `gives` [+      "                $1.00",+      "    * a          2.0h  ; pcomment1",+      "    ; pcomment2",+      "    ; ptag1: val1",+      "    ; ptag2: val2"+      ]      + ]++indent :: String -> String+indent = ("    "++)+ -- | Show an account name, clipped to the given width if any, and -- appropriately bracketed/parenthesised for the given posting type. showAccountName :: Maybe Int -> PostingType -> AccountName -> String@@ -232,9 +337,9 @@     where       (rsum, _, bvsum) = transactionPostingBalances t       rmsg | isReallyZeroMixedAmountCost rsum = ""-           | otherwise = "real postings are off by " ++ show (costOfMixedAmount rsum)+           | otherwise = "real postings are off by " ++ showMixedAmount (costOfMixedAmount rsum)       bvmsg | isReallyZeroMixedAmountCost bvsum = ""-            | otherwise = "balanced virtual postings are off by " ++ show (costOfMixedAmount bvsum)+            | otherwise = "balanced virtual postings are off by " ++ showMixedAmount (costOfMixedAmount bvsum)       sep = if not (null rmsg) && not (null bvmsg) then "; " else ""  transactionActualDate :: Transaction -> Day@@ -258,7 +363,10 @@ settxn :: Transaction -> Posting -> Posting settxn t p = p{ptransaction=Just t} -tests_Hledger_Data_Transaction = TestList [+tests_Hledger_Data_Transaction = TestList $ concat [+  tests_postingAsLines,+  tests_showTransactionUnelided,+  [   "showTransaction" ~: do      assertEqual "show a balanced transaction, eliding last amount"        (unlines@@ -318,12 +426,12 @@      assertEqual "show a transaction with one posting and a missing amount"        (unlines         ["2007/01/28 coopportunity"-        ,"    expenses:food:groceries              "+        ,"    expenses:food:groceries"         ,""         ])        (showTransaction         (txnTieKnot $ Transaction (parsedate "2007/01/28") Nothing False "" "coopportunity" "" []-         [Posting False "expenses:food:groceries" missingamt "" RegularPosting [] Nothing+         [Posting False "expenses:food:groceries" missingmixedamt "" RegularPosting [] Nothing          ] ""))    ,"showTransaction" ~: do@@ -331,13 +439,13 @@        (unlines         ["2010/01/01 x"         ,"    a        1 @ $2"-        ,"    b              "+        ,"    b"         ,""         ])        (showTransaction         (txnTieKnot $ Transaction (parsedate "2010/01/01") Nothing False "" "x" "" []          [Posting False "a" (Mixed [Amount unknown 1 (Just $ UnitPrice $ Mixed [Amount dollar{precision=0} 2 Nothing])]) "" RegularPosting [] Nothing-         ,Posting False "b" missingamt "" RegularPosting [] Nothing+         ,Posting False "b" missingmixedamt "" RegularPosting [] Nothing          ] ""))    ,"balanceTransaction" ~: do@@ -350,12 +458,12 @@      assertBool "detect unbalanced entry, multiple missing amounts"                     (isLeft $ balanceTransaction Nothing                            (Transaction (parsedate "2007/01/28") Nothing False "" "test" "" []-                            [Posting False "a" missingamt "" RegularPosting [] Nothing,-                             Posting False "b" missingamt "" RegularPosting [] Nothing+                            [Posting False "a" missingmixedamt "" RegularPosting [] Nothing,+                             Posting False "b" missingmixedamt "" RegularPosting [] Nothing                             ] ""))      let e = balanceTransaction Nothing (Transaction (parsedate "2007/01/28") Nothing False "" "" "" []                            [Posting False "a" (Mixed [dollars 1]) "" RegularPosting [] Nothing,-                            Posting False "b" missingamt "" RegularPosting [] Nothing+                            Posting False "b" missingmixedamt "" RegularPosting [] Nothing                            ] "")      assertBool "balanceTransaction allows one missing amount" (isRight e)      assertEqual "balancing amount is inferred"@@ -417,4 +525,4 @@              ] ""      assertBool "balanced virtual postings need to balance among themselves (2)" (isTransactionBalanced Nothing t) -  ]+  ]]
Hledger/Data/Types.hs view
@@ -69,6 +69,8 @@       separatorpositions :: [Int]  -- ^ positions of separators, counting leftward from decimal point     } deriving (Eq,Ord,Show,Read) +type Quantity = Double+ -- | An amount's price in another commodity may be written as \@ unit -- price or \@\@ total price.  Note although a MixedAmount is used, it -- should be in a single commodity, also the amount should be positive;@@ -78,7 +80,7 @@  data Amount = Amount {       commodity :: Commodity,-      quantity :: Double,+      quantity :: Quantity,       price :: Maybe Price  -- ^ the price for this amount at posting time     } deriving (Eq,Ord) @@ -87,13 +89,15 @@ data PostingType = RegularPosting | VirtualPosting | BalancedVirtualPosting                    deriving (Eq,Show) +type Tag = (String, String)+ data Posting = Posting {       pstatus :: Bool,       paccount :: AccountName,       pamount :: MixedAmount,-      pcomment :: String,+      pcomment :: String, -- ^ this posting's non-tag comment lines, as a single non-indented string       ptype :: PostingType,-      pmetadata :: [(String,String)],+      ptags :: [Tag],       ptransaction :: Maybe Transaction  -- ^ this posting's parent transaction (co-recursive types).                                         -- Tying this knot gets tedious, Maybe makes it easier/optional.     }@@ -109,8 +113,8 @@       tstatus :: Bool,  -- XXX tcleared ?       tcode :: String,       tdescription :: String,-      tcomment :: String,-      tmetadata :: [(String,String)],+      tcomment :: String, -- ^ this transaction's non-tag comment lines, as a single non-indented string+      ttags :: [Tag],       tpostings :: [Posting],            -- ^ this transaction's postings (co-recursive types).       tpreceding_comment_lines :: String     } deriving (Eq)@@ -173,33 +177,86 @@ -- raise an error. type JournalUpdate = ErrorT String IO (Journal -> Journal) +-- | The id of a data format understood by hledger, eg @journal@ or @csv@.+type Format = String+ -- | A hledger journal reader is a triple of format name, format-detecting -- predicate, and a parser to Journal.-data Reader = Reader {rFormat   :: String-                     ,rDetector :: FilePath -> String -> Bool-                     ,rParser   :: FilePath -> String -> ErrorT String IO Journal-                     }+data Reader = Reader {+     -- name of the format this reader handles+     rFormat   :: Format+     -- quickly check if this reader can probably handle the given file path and file content+    ,rDetector :: FilePath -> String -> Bool+     -- parse the given string, using the given parse rules file if any, returning a journal or error aware of the given file path+    ,rParser   :: Maybe FilePath -> FilePath -> String -> ErrorT String IO Journal+    } +instance Show Reader where show r = "Reader for "++rFormat r++-- data format parse/conversion rules++-- currently the only parse (conversion) rules are those for the CSV format+type ParseRules = CsvRules++-- XXX copied from Convert.hs+{- |+A set of data definitions and account-matching patterns sufficient to+convert a particular CSV data file into meaningful journal transactions. See above.+-}+data CsvRules = CsvRules {+      dateField :: Maybe FieldPosition,+      dateFormat :: Maybe String,+      statusField :: Maybe FieldPosition,+      codeField :: Maybe FieldPosition,+      descriptionField :: [FormatString],+      amountField :: Maybe FieldPosition,+      amountInField :: Maybe FieldPosition,+      amountOutField :: Maybe FieldPosition,+      currencyField :: Maybe FieldPosition,+      baseCurrency :: Maybe String,+      accountField :: Maybe FieldPosition,+      account2Field :: Maybe FieldPosition,+      effectiveDateField :: Maybe FieldPosition,+      baseAccount :: AccountName,+      accountRules :: [AccountRule]+} deriving (Show, Eq)++type FieldPosition = Int++type AccountRule = (+   [(String, Maybe String)] -- list of regex match patterns with optional replacements+  ,AccountName              -- account name to use for a transaction matching this rule+  )++-- format strings++data HledgerFormatField =+    AccountField+  | DefaultDateField+  | DescriptionField+  | TotalField+  | DepthSpacerField+  | FieldNo Int+    deriving (Show, Eq)++data FormatString =+    FormatLiteral String+  | FormatField Bool        -- Left justified ?+                (Maybe Int) -- Min width+                (Maybe Int) -- Max width+                HledgerFormatField       -- Field+  deriving (Show, Eq)++ data Ledger = Ledger {-      journal :: Journal,-      accountnametree :: Tree AccountName,-      accountmap :: Map.Map AccountName Account+      ledgerJournal :: Journal,+      ledgerAccountNameTree :: Tree AccountName,+      ledgerAccountMap :: Map.Map AccountName Account     }  data Account = Account {       aname :: AccountName,       apostings :: [Posting],    -- ^ postings in this account       abalance :: MixedAmount    -- ^ sum of postings in this account and subaccounts-    }---- | A generic, pure specification of how to filter (or search) transactions and postings.-data FilterSpec = FilterSpec {-     datespan  :: DateSpan   -- ^ only include if in this date span-    ,cleared   :: Maybe Bool -- ^ only include if cleared\/uncleared\/don't care-    ,real      :: Bool       -- ^ only include if real\/don't care-    ,empty     :: Bool       -- ^ include if empty (ie amount is zero)-    ,acctpats  :: [String]   -- ^ only include if matching these account patterns-    ,descpats  :: [String]   -- ^ only include if matching these description patterns-    ,depth     :: Maybe Int-    } deriving (Show)+    } -- deriving (Eq)  XXX 
+ Hledger/Query.hs view
@@ -0,0 +1,574 @@+{-|++A general query system for matching things (accounts, postings,+transactions..)  by various criteria, and a parser for query expressions.++-}++module Hledger.Query (+  -- * Query and QueryOpt+  Query(..),+  QueryOpt(..),+  -- * parsing+  parseQuery,+  simplifyQuery,+  filterQuery,+  -- * accessors+  queryIsNull,+  queryIsDepth,+  queryIsDate,+  queryIsStartDateOnly,+  queryStartDate,+  queryDateSpan,+  queryDepth,+  queryEmpty,+  inAccount,+  inAccountQuery,+  -- * matching+  matchesAccount,+  matchesPosting,+  matchesTransaction,+  -- * tests+  tests_Hledger_Query+)+where+import Data.Either+import Data.List+import Data.Maybe+import Data.Time.Calendar+import Safe (readDef, headDef, headMay)+import Test.HUnit+import Text.ParserCombinators.Parsec++import Hledger.Utils+import Hledger.Data.Types+import Hledger.Data.AccountName+import Hledger.Data.Dates+import Hledger.Data.Posting+import Hledger.Data.Transaction+++-- | A query is a composition of search criteria, which can be used to+-- match postings, transactions, accounts and more.+data Query = Any              -- ^ always match+           | None             -- ^ never match+           | Not Query        -- ^ negate this match+           | Or [Query]       -- ^ match if any of these match+           | And [Query]      -- ^ match if all of these match+           | Desc String      -- ^ match if description matches this regexp+           | Acct String      -- ^ match postings whose account matches this regexp+           | Date DateSpan    -- ^ match if actual date in this date span+           | EDate DateSpan   -- ^ match if effective date in this date span+           | Status Bool      -- ^ match if cleared status has this value+           | Real Bool        -- ^ match if "realness" (involves a real non-virtual account ?) has this value+           | Empty Bool       -- ^ if true, show zero-amount postings/accounts which are usually not shown+                              --   more of a query option than a query criteria ?+           | Depth Int        -- ^ match if account depth is less than or equal to this value+           | Tag String (Maybe String)  -- ^ match if a tag with this exact name, and with value+                                        -- matching the regexp if provided, exists+    deriving (Show, Eq)++-- | A query option changes a query's/report's behaviour and output in some way.+data QueryOpt = QueryOptInAcctOnly AccountName  -- ^ show an account register focussed on this account+              | QueryOptInAcct AccountName      -- ^ as above but include sub-accounts in the account register+           -- | QueryOptCostBasis      -- ^ show amounts converted to cost where possible+           -- | QueryOptEffectiveDate  -- ^ show effective dates instead of actual dates+    deriving (Show, Eq)++-- parsing++-- -- | A query restricting the account(s) to be shown in the sidebar, if any.+-- -- Just looks at the first query option.+-- showAccountMatcher :: [QueryOpt] -> Maybe Query+-- showAccountMatcher (QueryOptInAcctSubsOnly a:_) = Just $ Acct True $ accountNameToAccountRegex a+-- showAccountMatcher _ = Nothing+++-- | Convert a query expression containing zero or more space-separated+-- terms to a query and zero or more query options. A query term is either:+--+-- 1. a search pattern, which matches on one or more fields, eg:+--+--      acct:REGEXP     - match the account name with a regular expression+--      desc:REGEXP     - match the transaction description+--      date:PERIODEXP  - match the date with a period expression+--+--    The prefix indicates the field to match, or if there is no prefix+--    account name is assumed.+--+-- 2. a query option, which modifies the reporting behaviour in some+--    way. There is currently one of these, which may appear only once:+--+--      inacct:FULLACCTNAME+--+-- The usual shell quoting rules are assumed. When a pattern contains+-- whitespace, it (or the whole term including prefix) should be enclosed+-- in single or double quotes.+--+-- Period expressions may contain relative dates, so a reference date is+-- required to fully parse these.+--+-- Multiple terms are combined as follows:+-- 1. multiple account patterns are OR'd together+-- 2. multiple description patterns are OR'd together+-- 3. then all terms are AND'd together+parseQuery :: Day -> String -> (Query,[QueryOpt])+parseQuery d s = (q, opts)+  where+    terms = words'' prefixes s+    (pats, opts) = partitionEithers $ map (parseQueryTerm d) terms+    (descpats, pats') = partition queryIsDesc pats+    (acctpats, otherpats) = partition queryIsAcct pats'+    q = simplifyQuery $ And $ [Or acctpats, Or descpats] ++ otherpats++tests_parseQuery = [+  "parseQuery" ~: do+    let d = nulldate -- parsedate "2011/1/1"+    parseQuery d "acct:'expenses:autres d\233penses' desc:b" `is` (And [Acct "expenses:autres d\233penses", Desc "b"], [])+    parseQuery d "inacct:a desc:\"b b\"" `is` (Desc "b b", [QueryOptInAcct "a"])+    parseQuery d "inacct:a inacct:b" `is` (Any, [QueryOptInAcct "a", QueryOptInAcct "b"])+    parseQuery d "desc:'x x'" `is` (Desc "x x", [])+    parseQuery d "'a a' 'b" `is` (Or [Acct "a a",Acct "'b"], [])+    parseQuery d "\"" `is` (Acct "\"", [])+ ]++-- XXX+-- | Quote-and-prefix-aware version of words - don't split on spaces which+-- are inside quotes, including quotes which may have one of the specified+-- prefixes in front, and maybe an additional not: prefix in front of that.+words'' :: [String] -> String -> [String]+words'' prefixes = fromparse . parsewith maybeprefixedquotedphrases -- XXX+    where+      maybeprefixedquotedphrases = choice' [prefixedQuotedPattern, quotedPattern, pattern] `sepBy` many1 spacenonewline+      prefixedQuotedPattern = do+        not' <- fromMaybe "" `fmap` (optionMaybe $ string "not:")+        let allowednexts | null not' = prefixes+                         | otherwise = prefixes ++ [""]+        next <- choice' $ map string allowednexts+        let prefix = not' ++ next+        p <- quotedPattern+        return $ prefix ++ stripquotes p+      quotedPattern = do+        p <- between (oneOf "'\"") (oneOf "'\"") $ many $ noneOf "'\""+        return $ stripquotes p+      pattern = many (noneOf " \n\r")++tests_words'' = [+   "words''" ~: do+    assertEqual "1" ["a","b"]        (words'' [] "a b")+    assertEqual "2" ["a b"]          (words'' [] "'a b'")+    assertEqual "3" ["not:a","b"]    (words'' [] "not:a b")+    assertEqual "4" ["not:a b"]    (words'' [] "not:'a b'")+    assertEqual "5" ["not:a b"]    (words'' [] "'not:a b'")+    assertEqual "6" ["not:desc:a b"]    (words'' ["desc:"] "not:desc:'a b'")+    let s `gives` r = assertEqual "" r (words'' prefixes s)+    "\"acct:expenses:autres d\233penses\"" `gives` ["acct:expenses:autres d\233penses"]+    "\"" `gives` ["\""]+ ]++-- XXX+-- keep synced with patterns below, excluding "not"+prefixes = map (++":") [+     "inacctonly"+    ,"inacct"+    ,"desc"+    ,"acct"+    ,"date"+    ,"edate"+    ,"status"+    ,"real"+    ,"empty"+    ,"depth"+    ,"tag"+    ]++defaultprefix = "acct"++-- -- | Parse the query string as a boolean tree of match patterns.+-- parseQueryTerm :: String -> Query+-- parseQueryTerm s = either (const (Any)) id $ runParser query () "" $ lexmatcher s++-- lexmatcher :: String -> [String]+-- lexmatcher s = words' s++-- query :: GenParser String () Query+-- query = undefined++-- | Parse a single query term as either a query or a query option.+parseQueryTerm :: Day -> String -> Either Query QueryOpt+parseQueryTerm _ ('i':'n':'a':'c':'c':'t':'o':'n':'l':'y':':':s) = Right $ QueryOptInAcctOnly s+parseQueryTerm _ ('i':'n':'a':'c':'c':'t':':':s) = Right $ QueryOptInAcct s+parseQueryTerm d ('n':'o':'t':':':s) = case parseQueryTerm d s of+                                       Left m  -> Left $ Not m+                                       Right _ -> Left Any -- not:somequeryoption will be ignored+parseQueryTerm _ ('d':'e':'s':'c':':':s) = Left $ Desc s+parseQueryTerm _ ('a':'c':'c':'t':':':s) = Left $ Acct s+parseQueryTerm d ('d':'a':'t':'e':':':s) =+        case parsePeriodExpr d s of Left _ -> Left None -- XXX should warn+                                    Right (_,span) -> Left $ Date span+parseQueryTerm d ('e':'d':'a':'t':'e':':':s) =+        case parsePeriodExpr d s of Left _ -> Left None -- XXX should warn+                                    Right (_,span) -> Left $ EDate span+parseQueryTerm _ ('s':'t':'a':'t':'u':'s':':':s) = Left $ Status $ parseStatus s+parseQueryTerm _ ('r':'e':'a':'l':':':s) = Left $ Real $ parseBool s+parseQueryTerm _ ('e':'m':'p':'t':'y':':':s) = Left $ Empty $ parseBool s+parseQueryTerm _ ('d':'e':'p':'t':'h':':':s) = Left $ Depth $ readDef 0 s+parseQueryTerm _ ('t':'a':'g':':':s) = Left $ Tag n v where (n,v) = parseTag s+parseQueryTerm _ "" = Left $ Any+parseQueryTerm d s = parseQueryTerm d $ defaultprefix++":"++s++tests_parseQueryTerm = [+  "parseQueryTerm" ~: do+    let s `gives` r = parseQueryTerm nulldate s `is` r+    "a" `gives` (Left $ Acct "a")+    "acct:expenses:autres d\233penses" `gives` (Left $ Acct "expenses:autres d\233penses")+    "not:desc:a b" `gives` (Left $ Not $ Desc "a b")+    "status:1" `gives` (Left $ Status True)+    "status:0" `gives` (Left $ Status False)+    "status:" `gives` (Left $ Status False)+    "real:1" `gives` (Left $ Real True)+    "date:2008" `gives` (Left $ Date $ DateSpan (Just $ parsedate "2008/01/01") (Just $ parsedate "2009/01/01"))+    "date:from 2012/5/17" `gives` (Left $ Date $ DateSpan (Just $ parsedate "2012/05/17") Nothing)+    "inacct:a" `gives` (Right $ QueryOptInAcct "a")+    "tag:a" `gives` (Left $ Tag "a" Nothing)+    "tag:a=some value" `gives` (Left $ Tag "a" (Just "some value"))+ ]++parseTag :: String -> (String, Maybe String)+parseTag s | '=' `elem` s = (n, Just $ tail v)+           | otherwise    = (s, Nothing)+           where (n,v) = break (=='=') s++-- | Parse the boolean value part of a "status:" query, allowing "*" as+-- another way to spell True, similar to the journal file format.+parseStatus :: String -> Bool+parseStatus s = s `elem` (truestrings ++ ["*"])++-- | Parse the boolean value part of a "status:" query. A true value can+-- be spelled as "1", "t" or "true".+parseBool :: String -> Bool+parseBool s = s `elem` truestrings++truestrings :: [String]+truestrings = ["1","t","true"]++simplifyQuery :: Query -> Query+simplifyQuery q =+  let q' = simplify q+  in if q' == q then q else simplifyQuery q'+  where+    simplify (And []) = Any+    simplify (And [q]) = simplify q+    simplify (And qs) | same qs = simplify $ head qs+                      | any (==None) qs = None+                      | all queryIsDate qs = Date $ spansIntersect $ mapMaybe queryTermDateSpan qs+                      | otherwise = And $ concat $ [map simplify dateqs, map simplify otherqs]+                      where (dateqs, otherqs) = partition queryIsDate $ filter (/=Any) qs+    simplify (Or []) = Any+    simplify (Or [q]) = simplifyQuery q+    simplify (Or qs) | same qs = simplify $ head qs+                     | any (==Any) qs = Any+                     -- all queryIsDate qs = Date $ spansUnion $ mapMaybe queryTermDateSpan qs  ?+                     | otherwise = Or $ map simplify $ filter (/=None) qs+    simplify (Date (DateSpan Nothing Nothing)) = Any+    simplify q = q++tests_simplifyQuery = [+ "simplifyQuery" ~: do+  let q `gives` r = assertEqual "" r (simplifyQuery q)+  Or [Acct "a"] `gives` Acct "a"+  Or [Any,None] `gives` Any+  And [Any,None] `gives` None+  And [Any,Any] `gives` Any+  And [Acct "b",Any] `gives` Acct "b"+  And [Any,And [Date (DateSpan Nothing Nothing)]] `gives` Any+  And [Date (DateSpan Nothing (Just $ parsedate "2013-01-01")), Date (DateSpan (Just $ parsedate "2012-01-01") Nothing)]+      `gives` Date (DateSpan (Just $ parsedate "2012-01-01") (Just $ parsedate "2013-01-01"))+  And [Or [],Or [Desc "b b"]] `gives` Desc "b b"+ ]++same [] = True+same (a:as) = all (a==) as++-- | Remove query terms (or whole sub-expressions) not matching the given+-- predicate from this query.  XXX Semantics not yet clear.+filterQuery :: (Query -> Bool) -> Query -> Query+filterQuery p (And qs) = And $ filter p qs+filterQuery p (Or qs) = Or $ filter p qs+-- filterQuery p (Not q) = Not $ filterQuery p q+filterQuery p q = if p q then q else Any++tests_filterQuery = [+ "filterQuery" ~: do+  let (q,p) `gives` r = assertEqual "" r (filterQuery p q)+  (Any, queryIsDepth) `gives` Any+  (Depth 1, queryIsDepth) `gives` Depth 1+  -- (And [Date nulldatespan, Not (Or [Any, Depth 1])], queryIsDepth) `gives` And [Not (Or [Depth 1])]+ ]++-- * accessors++-- | Does this query match everything ?+queryIsNull :: Query -> Bool+queryIsNull Any = True+queryIsNull (And []) = True+queryIsNull (Not (Or [])) = True+queryIsNull _ = False++queryIsDepth :: Query -> Bool+queryIsDepth (Depth _) = True+queryIsDepth _ = False++queryIsDate :: Query -> Bool+queryIsDate (Date _) = True+queryIsDate _ = False++queryIsDesc :: Query -> Bool+queryIsDesc (Desc _) = True+queryIsDesc _ = False++queryIsAcct :: Query -> Bool+queryIsAcct (Acct _) = True+queryIsAcct _ = False++-- | Does this query specify a start date and nothing else (that would+-- filter postings prior to the date) ?+-- When the flag is true, look for a starting effective date instead.+queryIsStartDateOnly :: Bool -> Query -> Bool+queryIsStartDateOnly _ Any = False+queryIsStartDateOnly _ None = False+queryIsStartDateOnly effective (Or ms) = and $ map (queryIsStartDateOnly effective) ms+queryIsStartDateOnly effective (And ms) = and $ map (queryIsStartDateOnly effective) ms+queryIsStartDateOnly False (Date (DateSpan (Just _) _)) = True+queryIsStartDateOnly True (EDate (DateSpan (Just _) _)) = True+queryIsStartDateOnly _ _ = False++-- | What start date (or effective date) does this query specify, if any ?+-- For OR expressions, use the earliest of the dates. NOT is ignored.+queryStartDate :: Bool -> Query -> Maybe Day+queryStartDate effective (Or ms) = earliestMaybeDate $ map (queryStartDate effective) ms+queryStartDate effective (And ms) = latestMaybeDate $ map (queryStartDate effective) ms+queryStartDate False (Date (DateSpan (Just d) _)) = Just d+queryStartDate True (EDate (DateSpan (Just d) _)) = Just d+queryStartDate _ _ = Nothing++queryTermDateSpan (Date span) = Just span+queryTermDateSpan _ = Nothing++-- | What date span (or effective date span) does this query specify ?+-- For OR expressions, use the widest possible span. NOT is ignored.+queryDateSpan :: Bool -> Query -> DateSpan+queryDateSpan effective q = spansUnion $ queryDateSpans effective q++-- | Extract all date (or effective date) spans specified in this query.+-- NOT is ignored.+queryDateSpans :: Bool -> Query -> [DateSpan]+queryDateSpans effective (Or qs) = concatMap (queryDateSpans effective) qs+queryDateSpans effective (And qs) = concatMap (queryDateSpans effective) qs+queryDateSpans False (Date span) = [span]+queryDateSpans True (EDate span) = [span]+queryDateSpans _ _ = []++-- | What is the earliest of these dates, where Nothing is earliest ?+earliestMaybeDate :: [Maybe Day] -> Maybe Day+earliestMaybeDate = headDef Nothing . sortBy compareMaybeDates++-- | What is the latest of these dates, where Nothing is earliest ?+latestMaybeDate :: [Maybe Day] -> Maybe Day+latestMaybeDate = headDef Nothing . sortBy (flip compareMaybeDates)++-- | Compare two maybe dates, Nothing is earliest.+compareMaybeDates :: Maybe Day -> Maybe Day -> Ordering+compareMaybeDates Nothing Nothing = EQ+compareMaybeDates Nothing (Just _) = LT+compareMaybeDates (Just _) Nothing = GT+compareMaybeDates (Just a) (Just b) = compare a b++-- | The depth limit this query specifies, or a large number if none.+queryDepth :: Query -> Int+queryDepth q = case queryDepth' q of [] -> 99999+                                     ds -> minimum ds+  where+    queryDepth' (Depth d) = [d]+    queryDepth' (Or qs) = concatMap queryDepth' qs+    queryDepth' (And qs) = concatMap queryDepth' qs+    queryDepth' _ = []++-- | The empty (zero amount) status specified by this query, defaulting to false.+queryEmpty :: Query -> Bool+queryEmpty = headDef False . queryEmpty'+  where+    queryEmpty' (Empty v) = [v]+    queryEmpty' (Or qs) = concatMap queryEmpty' qs+    queryEmpty' (And qs) = concatMap queryEmpty' qs+    queryEmpty' _ = []++-- -- | The "include empty" option specified by this query, defaulting to false.+-- emptyQueryOpt :: [QueryOpt] -> Bool+-- emptyQueryOpt = headDef False . emptyQueryOpt'+--   where+--     emptyQueryOpt' [] = False+--     emptyQueryOpt' (QueryOptEmpty v:_) = v+--     emptyQueryOpt' (_:vs) = emptyQueryOpt' vs++-- | The account we are currently focussed on, if any, and whether subaccounts are included.+-- Just looks at the first query option.+inAccount :: [QueryOpt] -> Maybe (AccountName,Bool)+inAccount [] = Nothing+inAccount (QueryOptInAcctOnly a:_) = Just (a,False)+inAccount (QueryOptInAcct a:_) = Just (a,True)++-- | A query for the account(s) we are currently focussed on, if any.+-- Just looks at the first query option.+inAccountQuery :: [QueryOpt] -> Maybe Query+inAccountQuery [] = Nothing+inAccountQuery (QueryOptInAcctOnly a:_) = Just $ Acct $ accountNameToAccountOnlyRegex a+inAccountQuery (QueryOptInAcct a:_) = Just $ Acct $ accountNameToAccountRegex a++-- -- | Convert a query to its inverse.+-- negateQuery :: Query -> Query+-- negateQuery =  Not++-- matching++-- | Does the match expression match this account ?+-- A matching in: clause is also considered a match.+matchesAccount :: Query -> AccountName -> Bool+matchesAccount (None) _ = False+matchesAccount (Not m) a = not $ matchesAccount m a+matchesAccount (Or ms) a = any (`matchesAccount` a) ms+matchesAccount (And ms) a = all (`matchesAccount` a) ms+matchesAccount (Acct r) a = regexMatchesCI r a+matchesAccount (Depth d) a = accountNameLevel a <= d+matchesAccount (Tag _ _) _ = False+matchesAccount _ _ = True++tests_matchesAccount = [+   "matchesAccount" ~: do+    assertBool "positive acct match" $ matchesAccount (Acct "b:c") "a:bb:c:d"+    -- assertBool "acct should match at beginning" $ not $ matchesAccount (Acct True "a:b") "c:a:b"+    let q `matches` a = assertBool "" $ q `matchesAccount` a+    Depth 2 `matches` "a:b"+    assertBool "" $ Depth 2 `matchesAccount` "a"+    assertBool "" $ Depth 2 `matchesAccount` "a:b"+    assertBool "" $ not $ Depth 2 `matchesAccount` "a:b:c"+    assertBool "" $ Date nulldatespan `matchesAccount` "a"+    assertBool "" $ EDate nulldatespan `matchesAccount` "a"+    assertBool "" $ not $ (Tag "a" Nothing) `matchesAccount` "a"+ ]++-- | Does the match expression match this posting ?+matchesPosting :: Query -> Posting -> Bool+matchesPosting (Not q) p = not $ q `matchesPosting` p+matchesPosting (Any) _ = True+matchesPosting (None) _ = False+matchesPosting (Or qs) p = any (`matchesPosting` p) qs+matchesPosting (And qs) p = all (`matchesPosting` p) qs+matchesPosting (Desc r) p = regexMatchesCI r $ maybe "" tdescription $ ptransaction p+matchesPosting (Acct r) p = regexMatchesCI r $ paccount p+matchesPosting (Date span) p =+    case d of Just d'  -> spanContainsDate span d'+              Nothing -> False+    where d = maybe Nothing (Just . tdate) $ ptransaction p+matchesPosting (EDate span) p =+    case postingEffectiveDate p of Just d  -> spanContainsDate span d+                                   Nothing -> False+matchesPosting (Status v) p = v == postingCleared p+matchesPosting (Real v) p = v == isReal p+matchesPosting (Depth d) Posting{paccount=a} = Depth d `matchesAccount` a+-- matchesPosting (Empty v) Posting{pamount=a} = v == isZeroMixedAmount a+-- matchesPosting (Empty False) Posting{pamount=a} = True+-- matchesPosting (Empty True) Posting{pamount=a} = isZeroMixedAmount a+matchesPosting (Empty _) _ = True+matchesPosting (Tag n Nothing) p = isJust $ lookupTagByName n $ postingAllTags p+matchesPosting (Tag n (Just v)) p = isJust $ lookupTagByNameAndValue (n,v) $ postingAllTags p+-- matchesPosting _ _ = False++tests_matchesPosting = [+   "matchesPosting" ~: do+    -- matching posting status..+    assertBool "positive match on true posting status"  $+                   (Status True)  `matchesPosting` nullposting{pstatus=True}+    assertBool "negative match on true posting status"  $+               not $ (Not $ Status True)  `matchesPosting` nullposting{pstatus=True}+    assertBool "positive match on false posting status" $+                   (Status False) `matchesPosting` nullposting{pstatus=False}+    assertBool "negative match on false posting status" $+               not $ (Not $ Status False) `matchesPosting` nullposting{pstatus=False}+    assertBool "positive match on true posting status acquired from transaction" $+                   (Status True) `matchesPosting` nullposting{pstatus=False,ptransaction=Just nulltransaction{tstatus=True}}+    assertBool "real:1 on real posting" $ (Real True) `matchesPosting` nullposting{ptype=RegularPosting}+    assertBool "real:1 on virtual posting fails" $ not $ (Real True) `matchesPosting` nullposting{ptype=VirtualPosting}+    assertBool "real:1 on balanced virtual posting fails" $ not $ (Real True) `matchesPosting` nullposting{ptype=BalancedVirtualPosting}+    assertBool "" $ (Acct "'b") `matchesPosting` nullposting{paccount="'b"}+    assertBool "" $ not $ (Tag "a" (Just "r$")) `matchesPosting` nullposting+    assertBool "" $ (Tag "foo" Nothing) `matchesPosting` nullposting{ptags=[("foo","")]}+    assertBool "" $ (Tag "foo" Nothing) `matchesPosting` nullposting{ptags=[("foo","baz")]}+    assertBool "" $ (Tag "foo" (Just "a")) `matchesPosting` nullposting{ptags=[("foo","bar")]}+    assertBool "" $ not $ (Tag "foo" (Just "a$")) `matchesPosting` nullposting{ptags=[("foo","bar")]}+    assertBool "" $ not $ (Tag " foo " (Just "a")) `matchesPosting` nullposting{ptags=[("foo","bar")]}+    assertBool "" $ not $ (Tag "foo foo" (Just " ar ba ")) `matchesPosting` nullposting{ptags=[("foo foo","bar bar")]}+    -- a tag match on a posting also sees inherited tags+    assertBool "" $ (Tag "txntag" Nothing) `matchesPosting` nullposting{ptransaction=Just nulltransaction{ttags=[("txntag","")]}}+ ]++-- | Does the match expression match this transaction ?+matchesTransaction :: Query -> Transaction -> Bool+matchesTransaction (Not q) t = not $ q `matchesTransaction` t+matchesTransaction (Any) _ = True+matchesTransaction (None) _ = False+matchesTransaction (Or qs) t = any (`matchesTransaction` t) qs+matchesTransaction (And qs) t = all (`matchesTransaction` t) qs+matchesTransaction (Desc r) t = regexMatchesCI r $ tdescription t+matchesTransaction q@(Acct _) t = any (q `matchesPosting`) $ tpostings t+matchesTransaction (Date span) t = spanContainsDate span $ tdate t+matchesTransaction (EDate span) t = spanContainsDate span $ transactionEffectiveDate t+matchesTransaction (Status v) t = v == tstatus t+matchesTransaction (Real v) t = v == hasRealPostings t+matchesTransaction (Empty _) _ = True+matchesTransaction (Depth d) t = any (Depth d `matchesPosting`) $ tpostings t+matchesTransaction (Tag n Nothing) t = isJust $ lookupTagByName n $ transactionAllTags t+matchesTransaction (Tag n (Just v)) t = isJust $ lookupTagByNameAndValue (n,v) $ transactionAllTags t++-- matchesTransaction _ _ = False++tests_matchesTransaction = [+  "matchesTransaction" ~: do+   let q `matches` t = assertBool "" $ q `matchesTransaction` t+   Any `matches` nulltransaction+   assertBool "" $ not $ (Desc "x x") `matchesTransaction` nulltransaction{tdescription="x"}+   assertBool "" $ (Desc "x x") `matchesTransaction` nulltransaction{tdescription="x x"}+   -- see posting for more tag tests+   assertBool "" $ (Tag "foo" (Just "a")) `matchesTransaction` nulltransaction{ttags=[("foo","bar")]}+   -- a tag match on a transaction usually ignores posting tags+   assertBool "" $ not $ (Tag "postingtag" Nothing) `matchesTransaction` nulltransaction{tpostings=[nullposting{ptags=[("postingtag","")]}]}+ ]++lookupTagByName :: String -> [Tag] -> Maybe Tag+lookupTagByName namepat tags = headMay [(n,v) | (n,v) <- tags, matchTagName namepat n]++lookupTagByNameAndValue :: Tag -> [Tag] -> Maybe Tag+lookupTagByNameAndValue (namepat, valpat) tags = headMay [(n,v) | (n,v) <- tags, matchTagName namepat n, matchTagValue valpat v]++matchTagName :: String -> String -> Bool+matchTagName pat name = pat == name++matchTagValue :: String -> String -> Bool+matchTagValue pat value = regexMatchesCI pat value++postingEffectiveDate :: Posting -> Maybe Day+postingEffectiveDate p = maybe Nothing (Just . transactionEffectiveDate) $ ptransaction p++-- tests++tests_Hledger_Query :: Test+tests_Hledger_Query = TestList $+    tests_simplifyQuery+ ++ tests_words''+ ++ tests_filterQuery+ ++ tests_parseQueryTerm+ ++ tests_parseQuery+ ++ tests_matchesAccount+ ++ tests_matchesPosting+ ++ tests_matchesTransaction+
Hledger/Read.hs view
@@ -1,28 +1,35 @@+{-# LANGUAGE ScopedTypeVariables #-} {-|  -Read hledger data from various data formats, and related utilities.+This is the entry point to hledger's reading system, which can read+Journals from various data formats. Use this module if you want to parse+journal data or read journal files. Generally it should not be necessary+to import modules below this one.  -}  module Hledger.Read (-       tests_Hledger_Read,-       readJournalFile,+       -- * Journal reading API+       defaultJournalPath,+       defaultJournal,        readJournal,-       journalFromPathAndString,-       ledgeraccountname,-       myJournalPath,-       myJournal,-       someamount,-       journalenvvar,-       journaldefaultfilename,-       requireJournalFile,-       ensureJournalFile,+       readJournal',+       readJournalFile,+       requireJournalFileExists,+       ensureJournalFileExists,+       -- * Parsers used elsewhere+       accountname,+       amount,+       amount',+       -- * Tests+       samplejournal,+       tests_Hledger_Read, ) where+import qualified Control.Exception as C import Control.Monad.Error-import Data.Either (partitionEithers) import Data.List-import Safe (headDef)+import Data.Maybe import System.Directory (doesFileExist, getHomeDirectory) import System.Environment (getEnv) import System.Exit (exitFailure)@@ -32,70 +39,131 @@ import Text.Printf  import Hledger.Data.Dates (getCurrentDay)-import Hledger.Data.Types (Journal(..), Reader(..))+import Hledger.Data.Types import Hledger.Data.Journal (nullctx) import Hledger.Read.JournalReader as JournalReader import Hledger.Read.TimelogReader as TimelogReader+import Hledger.Read.CsvReader as CsvReader import Hledger.Utils import Prelude hiding (getContents, writeFile)-import Hledger.Utils.UTF8 (getContents, hGetContents, writeFile)+import Hledger.Utils.UTF8IOCompat (getContents, hGetContents, writeFile)  -journalenvvar           = "LEDGER_FILE"-journalenvvar2          = "LEDGER"-journaldefaultfilename  = ".hledger.journal"+journalEnvVar           = "LEDGER_FILE"+journalEnvVar2          = "LEDGER"+journalDefaultFilename  = ".hledger.journal" --- Here are the available readers. The first is the default, used for unknown data formats.+-- The available data file readers, each one handling a particular data+-- format. The first is also used as the default for unknown formats. readers :: [Reader] readers = [   JournalReader.reader  ,TimelogReader.reader+ ,CsvReader.reader  ] -formats   = map rFormat readers+-- | All the data formats we can read.+-- formats = map rFormat readers -readerForFormat :: String -> Maybe Reader+-- | Get the default journal file path specified by the environment.+-- Like ledger, we look first for the LEDGER_FILE environment+-- variable, and if that does not exist, for the legacy LEDGER+-- environment variable. If neither is set, or the value is blank,+-- return the hard-coded default, which is @.hledger.journal@ in the+-- users's home directory (or in the current directory, if we cannot+-- determine a home directory).+defaultJournalPath :: IO String+defaultJournalPath = do+  s <- envJournalPath+  if null s then defaultJournalPath else return s+    where+      envJournalPath =+        getEnv journalEnvVar+         `C.catch` (\(_::C.IOException) -> getEnv journalEnvVar2+                                            `C.catch` (\(_::C.IOException) -> return ""))+      defaultJournalPath = do+                  home <- getHomeDirectory `C.catch` (\(_::C.IOException) -> return "")+                  return $ home </> journalDefaultFilename++-- | Read the default journal file specified by the environment, or raise an error.+defaultJournal :: IO Journal+defaultJournal = defaultJournalPath >>= readJournalFile Nothing Nothing >>= either error' return++-- | Read a journal from the given string, trying all known formats, or simply throw an error.+readJournal' :: String -> IO Journal+readJournal' s = readJournal Nothing Nothing Nothing s >>= either error' return++tests_readJournal' = [+  "readJournal' parses sample journal" ~: do+     _ <- samplejournal+     assertBool "" True+ ]++++-- | Read a journal from this string, trying whatever readers seem appropriate:+--+-- - if a format is specified, try that reader only+--+-- - or if one or more readers recognises the file path and data, try those+--+-- - otherwise, try them all.+--+-- A CSV conversion rules file may also be specified for use by the CSV reader.+readJournal :: Maybe Format -> Maybe FilePath -> Maybe FilePath -> String -> IO (Either String Journal)+readJournal format rulesfile path s =+  -- trace (show (format, rulesfile, path)) $+  tryReaders $ readersFor (format, path, s)+  where+    -- try each reader in turn, returning the error of the first if all fail+    tryReaders :: [Reader] -> IO (Either String Journal)+    tryReaders = firstSuccessOrBestError []+      where+        firstSuccessOrBestError :: [String] -> [Reader] -> IO (Either String Journal)+        firstSuccessOrBestError [] []        = return $ Left "no readers found"+        firstSuccessOrBestError errs (r:rs) = do+          -- printf "trying %s reader\n" (rFormat r)+          result <- (runErrorT . (rParser r) rulesfile path') s+          case result of Right j -> return $ Right j                       -- success!+                         Left e  -> firstSuccessOrBestError (errs++[e]) rs -- keep trying+        firstSuccessOrBestError (e:_) []    = return $ Left e              -- none left, return first error+        path' = fromMaybe "(string)" path++-- | Which readers are worth trying for this (possibly unspecified) format, filepath, and data ?+readersFor :: (Maybe Format, Maybe FilePath, String) -> [Reader]+readersFor (format,path,s) =+    case format of +     Just f  -> case readerForFormat f of Just r  -> [r]+                                          Nothing -> []+     Nothing -> case path of Nothing  -> readers+                             Just "-" -> readers+                             Just p   -> case readersForPathAndData (p,s) of [] -> readers+                                                                             rs -> rs++-- | Find the (first) reader which can handle the given format, if any.+readerForFormat :: Format -> Maybe Reader readerForFormat s | null rs = Nothing                   | otherwise = Just $ head rs     where        rs = filter ((s==).rFormat) readers :: [Reader] --- | Read a Journal from this string (and file path), auto-detecting the--- data format, or give a useful error string. Tries to parse each known--- data format in turn. If none succeed, gives the error message specific--- to the intended data format, which if not specified is guessed from the--- file suffix and possibly the data.-journalFromPathAndString :: Maybe String -> FilePath -> String -> IO (Either String Journal)-journalFromPathAndString format fp s = do-  let readers' = case format of Just f -> case readerForFormat f of Just r -> [r]-                                                                    Nothing -> []-                                Nothing -> readers-  (errors, journals) <- partitionEithers `fmap` mapM tryReader readers'-  case journals of j:_ -> return $ Right j-                   _   -> return $ Left $ errMsg errors-    where-      tryReader r = (runErrorT . (rParser r) fp) s-      errMsg [] = unknownFormatMsg-      errMsg es = printf "could not parse %s data in %s\n%s" (rFormat r) fp e-          where (r,e) = headDef (head readers, head es) $ filter detects $ zip readers es-                detects (r,_) = (rDetector r) fp s-      unknownFormatMsg = printf "could not parse %sdata in %s" (fmt formats) fp-          where fmt [] = ""-                fmt [f] = f ++ " "-                fmt fs = intercalate ", " (init fs) ++ " or " ++ last fs ++ " "+-- | Find the readers which think they can handle the given file path and data, if any.+readersForPathAndData :: (FilePath,String) -> [Reader]+readersForPathAndData (f,s) = filter (\r -> (rDetector r) f s) readers --- | Read a journal from this file, using the specified data format or--- trying all known formats, or give an error string; also create the file--- if it doesn't exist.-readJournalFile :: Maybe String -> FilePath -> IO (Either String Journal)-readJournalFile format "-" = getContents >>= journalFromPathAndString format "(stdin)"-readJournalFile format f = do-  requireJournalFile f-  withFile f ReadMode $ \h -> hGetContents h >>= journalFromPathAndString format f+-- | Read a Journal from this file (or stdin if the filename is -) or give+-- an error message, using the specified data format or trying all known+-- formats. A CSV conversion rules file may be specified for better+-- conversion of that format.+readJournalFile :: Maybe Format -> Maybe FilePath -> FilePath -> IO (Either String Journal)+readJournalFile format rulesfile "-" = getContents >>= readJournal format rulesfile (Just "(stdin)")+readJournalFile format rulesfile f = do+  requireJournalFileExists f+  withFile f ReadMode $ \h -> hGetContents h >>= readJournal format rulesfile (Just f)  -- | If the specified journal file does not exist, give a helpful error and quit.-requireJournalFile :: FilePath -> IO ()-requireJournalFile f = do+requireJournalFileExists :: FilePath -> IO ()+requireJournalFileExists f = do   exists <- doesFileExist f   when (not exists) $ do     hPrintf stderr "The hledger journal file \"%s\" was not found.\n" f@@ -104,8 +172,8 @@     exitFailure  -- | Ensure there is a journal file at the given path, creating an empty one if needed.-ensureJournalFile :: FilePath -> IO ()-ensureJournalFile f = do+ensureJournalFileExists :: FilePath -> IO ()+ensureJournalFileExists f = do   exists <- doesFileExist f   when (not exists) $ do     hPrintf stderr "Creating hledger journal file \"%s\".\n" f@@ -119,39 +187,41 @@   d <- getCurrentDay   return $ printf "; journal created %s by hledger\n" (show d) --- | Read a Journal from this string, using the specified data format or--- trying all known formats, or give an error string.-readJournal :: Maybe String -> String -> IO (Either String Journal)-readJournal format s = journalFromPathAndString format "(string)" s---- | Get the user's journal file path. Like ledger, we look first for the--- LEDGER_FILE environment variable, and if that does not exist, for the--- legacy LEDGER environment variable. If neither is set, or the value is--- blank, return the default journal file path, which is--- ".hledger.journal" in the users's home directory, or if we cannot--- determine that, in the current directory.-myJournalPath :: IO String-myJournalPath = do-  s <- envJournalPath-  if null s then defaultJournalPath else return s-    where-      envJournalPath = getEnv journalenvvar `catch` (\_ -> getEnv journalenvvar2 `catch` (\_ -> return ""))-      defaultJournalPath = do-                  home <- getHomeDirectory `catch` (\_ -> return "")-                  return $ home </> journaldefaultfilename+-- tests --- | Read the user's default journal file, or give an error.-myJournal :: IO Journal-myJournal = myJournalPath >>= readJournalFile Nothing >>= either error' return+samplejournal = readJournal' $ unlines+ ["2008/01/01 income"+ ,"    assets:bank:checking  $1"+ ,"    income:salary"+ ,""+ ,"2008/06/01 gift"+ ,"    assets:bank:checking  $1"+ ,"    income:gifts"+ ,""+ ,"2008/06/02 save"+ ,"    assets:bank:saving  $1"+ ,"    assets:bank:checking"+ ,""+ ,"2008/06/03 * eat & shop"+ ,"    expenses:food      $1"+ ,"    expenses:supplies  $1"+ ,"    assets:cash"+ ,""+ ,"2008/12/31 * pay off"+ ,"    liabilities:debts  $1"+ ,"    assets:bank:checking"+ ] -tests_Hledger_Read = TestList-  [+tests_Hledger_Read = TestList $+  tests_readJournal'+  ++ [    tests_Hledger_Read_JournalReader,    tests_Hledger_Read_TimelogReader,+   tests_Hledger_Read_CsvReader, -   "journalFile" ~: do-    assertBool "journalFile should parse an empty file" (isRight $ parseWithCtx nullctx JournalReader.journalFile "")-    jE <- readJournal Nothing "" -- don't know how to get it from journalFile-    either error' (assertBool "journalFile parsing an empty file should give an empty journal" . null . jtxns) jE+   "journal" ~: do+    assertBool "journal should parse an empty file" (isRight $ parseWithCtx nullctx JournalReader.journal "")+    jE <- readJournal Nothing Nothing Nothing "" -- don't know how to get it from journal+    either error' (assertBool "journal parsing an empty file should give an empty journal" . null . jtxns) jE    ]
+ Hledger/Read/CsvReader.hs view
@@ -0,0 +1,584 @@+{-|++A reader for the CSV data format. Uses an extra rules file+(<http://hledger.org/MANUAL.html#rules-file-directives>) to help interpret+the data. Example:++@+\"2012\/3\/22\",\"something\",\"10.00\"+\"2012\/3\/23\",\"another\",\"5.50\"+@++and rules file:++@+date-field 0+description-field 1+amount-field 2+base-account assets:bank:checking++SAVINGS+assets:bank:savings+@++-}++module Hledger.Read.CsvReader (+  -- * Reader+  reader,+  -- * Tests+  tests_Hledger_Read_CsvReader+)+where+import Control.Exception hiding (try)+import Control.Monad+import Control.Monad.Error+-- import Test.HUnit+import Data.List+import Data.Maybe+import Data.Ord+import Data.Time.Format (parseTime)+import Safe+import System.Directory (doesFileExist)+import System.FilePath+import System.IO (stderr)+import System.Locale (defaultTimeLocale)+import Test.HUnit+import Text.CSV (parseCSV, CSV)+import Text.ParserCombinators.Parsec  hiding (parse)+import Text.ParserCombinators.Parsec.Error+import Text.ParserCombinators.Parsec.Pos+import Text.Printf (hPrintf)++import Hledger.Data+import Prelude hiding (getContents)+import Hledger.Utils.UTF8IOCompat (getContents)+import Hledger.Utils+import Hledger.Data.FormatStrings as FormatStrings+import Hledger.Read.JournalReader (accountname, amount)+++reader :: Reader+reader = Reader format detect parse++format :: String+format = "csv"++-- | Does the given file path and data look like CSV ?+detect :: FilePath -> String -> Bool+detect f _ = takeExtension f == '.':format++-- | Parse and post-process a "Journal" from CSV data, or give an error.+-- XXX currently ignores the string and reads from the file path+parse :: Maybe FilePath -> FilePath -> String -> ErrorT String IO Journal+parse rulesfile f s = -- trace ("running "++format++" reader") $+ do+  r <- liftIO $ readJournalFromCsv rulesfile f s+  case r of Left e -> throwError e+            Right j -> return j++nullrules = CsvRules {+      dateField=Nothing,+      dateFormat=Nothing,+      statusField=Nothing,+      codeField=Nothing,+      descriptionField=[],+      amountField=Nothing,+      amountInField=Nothing,+      amountOutField=Nothing,+      currencyField=Nothing,+      baseCurrency=Nothing,+      accountField=Nothing,+      account2Field=Nothing,+      effectiveDateField=Nothing,+      baseAccount="unknown",+      accountRules=[]+}++type CsvRecord = [String]+++-- | Read a Journal from the given CSV data (and filename, used for error+-- messages), or return an error. Proceed as follows:+-- @+-- 1. parse the CSV data+-- 2. identify the name of a file specifying conversion rules: either use+-- the name provided, derive it from the CSV filename, or raise an error+-- if the CSV filename is -.+-- 3. auto-create the rules file with default rules if it doesn't exist+-- 4. parse the rules file+-- 5. convert the CSV records to a journal using the rules+-- @+readJournalFromCsv :: Maybe FilePath -> FilePath -> String -> IO (Either String Journal)+readJournalFromCsv Nothing "-" _ = return $ Left "please use --rules-file when converting stdin"+readJournalFromCsv mrulesfile csvfile csvdata =+ handle (\e -> return $ Left $ show (e :: IOException)) $ do+  csvparse <- parseCsv csvfile csvdata+  let rs = case csvparse of+                  Left e -> throw $ userError $ show e+                  Right rs -> filter (/= [""]) rs+      badrecords = take 1 $ filter ((< 2).length) rs+      records = case badrecords of+                 []    -> rs+                 (_:_) -> throw $ userError $ "Parse error: at least one CSV record has less than two fields:\n"++(show $ head badrecords)++  let rulesfile = fromMaybe (rulesFileFor csvfile) mrulesfile+  created <- records `seq` (trace "ensureRulesFile" $ ensureRulesFileExists rulesfile)+  if created+   then hPrintf stderr "creating default conversion rules file %s, edit this file for better results\n" rulesfile+   else hPrintf stderr "using conversion rules file %s\n" rulesfile++  rules <- liftM (either (throw.userError.show) id) $ parseCsvRulesFile rulesfile++  let requiredfields = (maxFieldIndex rules + 1)+      badrecords = take 1 $ filter ((< requiredfields).length) records+  return $ case badrecords of+            []    -> Right nulljournal{jtxns=sortBy (comparing tdate) $ map (transactionFromCsvRecord rules) records}+            (_:_) -> Left $ "Parse error: at least one CSV record does not contain a field referenced by the conversion rules file:\n"++(show $ head badrecords)++-- | Ensure there is a conversion rules file at the given path, creating a+-- default one if needed and returning True in this case.+ensureRulesFileExists :: FilePath -> IO Bool+ensureRulesFileExists f = do+  exists <- doesFileExist f+  if exists+   then return False+   else do+     -- note Hledger.Utils.UTF8.* do no line ending conversion on windows,+     -- we currently require unix line endings on all platforms.+     writeFile f newRulesFileContent+     return True++parseCsv :: FilePath -> String -> IO (Either ParseError CSV)+parseCsv path csvdata =+  case path of+    "-" -> liftM (parseCSV "(stdin)") getContents+    _   -> return $ parseCSV path csvdata++-- | The highest (0-based) field index referenced in the field+-- definitions, or -1 if no fields are defined.+maxFieldIndex :: CsvRules -> Int+maxFieldIndex r = maximumDef (-1) $ catMaybes [+                   dateField r+                  ,statusField r+                  ,codeField r+                  ,amountField r+                  ,amountInField r+                  ,amountOutField r+                  ,currencyField r+                  ,accountField r+                  ,account2Field r+                  ,effectiveDateField r+                  ]++-- rulesFileFor :: CliOpts -> FilePath -> FilePath+-- rulesFileFor CliOpts{rules_file_=Just f} _ = f+-- rulesFileFor CliOpts{rules_file_=Nothing} csvfile = replaceExtension csvfile ".rules"+rulesFileFor :: FilePath -> FilePath+rulesFileFor = flip replaceExtension ".rules"++newRulesFileContent :: String+newRulesFileContent = let prognameandversion = "hledger" in+    "# csv conversion rules file generated by " ++ prognameandversion ++ "\n" +++    "# Add rules to this file for more accurate conversion, see\n"+++    "# http://hledger.org/MANUAL.html#convert\n" +++    "\n" +++    "base-account assets:bank:checking\n" +++    "date-field 0\n" +++    "description-field 4\n" +++    "amount-field 1\n" +++    "base-currency $\n" +++    "\n" +++    "# account-assigning rules\n" +++    "\n" +++    "SPECTRUM\n" +++    "expenses:health:gym\n" +++    "\n" +++    "ITUNES\n" +++    "BLKBSTR=BLOCKBUSTER\n" +++    "expenses:entertainment\n" +++    "\n" +++    "(TO|FROM) SAVINGS\n" +++    "assets:bank:savings\n"++-- rules file parser++parseCsvRulesFile :: FilePath -> IO (Either ParseError CsvRules)+parseCsvRulesFile f = do+  s <- readFile f+  let rules = parseCsvRules f s+  return $ case rules of+             Left e -> Left e+             Right r -> case validateRules r of+                          Left e -> Left $ toParseError e+                          Right r -> Right r+  where+    toParseError s = newErrorMessage (Message s) (initialPos "")++parseCsvRules :: FilePath -> String -> Either ParseError CsvRules+parseCsvRules rulesfile s = runParser csvrulesfile nullrules{baseAccount=takeBaseName rulesfile} rulesfile s++csvrulesfile :: GenParser Char CsvRules CsvRules+csvrulesfile = do+  many blankorcommentline+  many definitions+  r <- getState+  ars <- many accountrule+  many blankorcommentline+  eof+  return r{accountRules=ars}++definitions :: GenParser Char CsvRules ()+definitions = do+  choice' [+    datefield+   ,dateformat+   ,statusfield+   ,codefield+   ,descriptionfield+   ,amountfield+   ,amountinfield+   ,amountoutfield+   ,currencyfield+   ,accountfield+   ,account2field+   ,effectivedatefield+   ,basecurrency+   ,baseaccount+   ,commentline+   ] <?> "definition"+  return ()++datefield = do+  string "date-field"+  many1 spacenonewline+  v <- restofline+  updateState (\r -> r{dateField=readMay v})++effectivedatefield = do+  string "effective-date-field"+  many1 spacenonewline+  v <- restofline+  updateState (\r -> r{effectiveDateField=readMay v})++dateformat = do+  string "date-format"+  many1 spacenonewline+  v <- restofline+  updateState (\r -> r{dateFormat=Just v})++codefield = do+  string "code-field"+  many1 spacenonewline+  v <- restofline+  updateState (\r -> r{codeField=readMay v})++statusfield = do+  string "status-field"+  many1 spacenonewline+  v <- restofline+  updateState (\r -> r{statusField=readMay v})++descriptionFieldValue :: GenParser Char st [FormatString]+descriptionFieldValue = do+--      try (fieldNo <* spacenonewline)+      try fieldNo+  <|> formatStrings+  where+    fieldNo = many1 digit >>= \x -> return [FormatField False Nothing Nothing $ FieldNo $ read x]++descriptionfield = do+  string "description-field"+  many1 spacenonewline+  formatS <- descriptionFieldValue+  restofline+  updateState (\x -> x{descriptionField=formatS})++amountfield = do+  string "amount-field"+  many1 spacenonewline+  v <- restofline+  x <- updateState (\r -> r{amountField=readMay v})+  return x++amountinfield = do+  choice [string "amount-in-field", string "in-field"]+  many1 spacenonewline+  v <- restofline+  updateState (\r -> r{amountInField=readMay v})++amountoutfield = do+  choice [string "amount-out-field", string "out-field"]+  many1 spacenonewline+  v <- restofline+  updateState (\r -> r{amountOutField=readMay v})++currencyfield = do+  string "currency-field"+  many1 spacenonewline+  v <- restofline+  updateState (\r -> r{currencyField=readMay v})++accountfield = do+  string "account-field"+  many1 spacenonewline+  v <- restofline+  updateState (\r -> r{accountField=readMay v})++account2field = do+  string "account2-field"+  many1 spacenonewline+  v <- restofline+  updateState (\r -> r{account2Field=readMay v})++basecurrency = do+  choice [string "base-currency", string "currency"]+  many1 spacenonewline+  v <- restofline+  updateState (\r -> r{baseCurrency=Just v})++baseaccount = do+  string "base-account"+  many1 spacenonewline+  v <- accountname+  optional newline+  updateState (\r -> r{baseAccount=v})++accountrule :: GenParser Char CsvRules AccountRule+accountrule = do+  many blankorcommentline+  pats <- many1 matchreplacepattern+  guard $ length pats >= 2+  let pats' = init pats+      acct = either (fail.show) id $ runParser accountname () "" $ fst $ last pats+  many blankorcommentline+  return (pats',acct)+ <?> "account rule"++blankline = many spacenonewline >> newline >> return () <?> "blank line"++commentchar = oneOf ";#"++commentline = many spacenonewline >> commentchar >> restofline >> return () <?> "comment line"++blankorcommentline = choice' [blankline, commentline]++matchreplacepattern = do+  notFollowedBy commentchar+  matchpat <- many1 (noneOf "=\n")+  replpat <- optionMaybe $ do {char '='; many $ noneOf "\n"}+  newline+  return (matchpat,replpat)++validateRules :: CsvRules -> Either String CsvRules+validateRules rules =+ let hasAmount = isJust $ amountField rules+     hasIn = isJust $ amountInField rules+     hasOut = isJust $ amountOutField rules+ in case (hasAmount, hasIn, hasOut) of+    (True, True, _) -> Left "Don't specify amount-in-field when specifying amount-field"+    (True, _, True) -> Left "Don't specify amount-out-field when specifying amount-field"+    (_, False, True) -> Left "Please specify amount-in-field when specifying amount-out-field"+    (_, True, False) -> Left "Please specify amount-out-field when specifying amount-in-field"+    (False, False, False) -> Left "Please specify either amount-field, or amount-in-field and amount-out-field"+    _ -> Right rules++-- csv record conversion+formatD :: CsvRecord -> Bool -> Maybe Int -> Maybe Int -> HledgerFormatField -> String+formatD record leftJustified min max f = case f of +  FieldNo n       -> maybe "" show $ atMay record n+  -- Some of these might in theory in read from fields+  AccountField         -> ""+  DepthSpacerField     -> ""+  TotalField           -> ""+  DefaultDateField     -> ""+  DescriptionField     -> ""+ where+   show = formatValue leftJustified min max++formatDescription :: CsvRecord -> [FormatString] -> String+formatDescription _ [] = ""+formatDescription record (f:fs) = s ++ (formatDescription record fs)+  where s = case f of+                FormatLiteral l -> l+                FormatField leftJustified min max field  -> formatD record leftJustified min max field++transactionFromCsvRecord :: CsvRules -> CsvRecord -> Transaction+transactionFromCsvRecord rules fields =+  let +      date = parsedate $ normaliseDate (dateFormat rules) $ maybe "1900/1/1" (atDef "" fields) (dateField rules)+      effectivedate = do idx <- effectiveDateField rules+                         return $ parsedate $ normaliseDate (dateFormat rules) $ (atDef "" fields) idx+      status = maybe False (null . strip . (atDef "" fields)) (statusField rules)+      code = maybe "" (atDef "" fields) (codeField rules)+      desc = formatDescription fields (descriptionField rules)+      comment = ""+      precomment = ""+      baseacc = maybe (baseAccount rules) (atDef "" fields) (accountField rules)+      amountstr = getAmount rules fields+      amountstr' = strnegate amountstr where strnegate ('-':s) = s+                                             strnegate s = '-':s+      currency = maybe (fromMaybe "" $ baseCurrency rules) (atDef "" fields) (currencyField rules)+      amountstr'' = currency ++ amountstr'+      amountparse = runParser amount nullctx "" amountstr''+      a = either (const nullmixedamt) id amountparse+      -- Using costOfMixedAmount here to allow complex costs like "10 GBP @@ 15 USD".+      -- Aim is to have "10 GBP @@ 15 USD" applied to account "acct", but have "-15USD" applied to "baseacct"+      baseamount = costOfMixedAmount a+      unknownacct | (readDef 0 amountstr' :: Double) < 0 = "income:unknown"+                  | otherwise = "expenses:unknown"+      (acct',newdesc) = identify (accountRules rules) unknownacct desc+      acct = maybe acct' (atDef "" fields) (account2Field rules)+      t = Transaction {+              tdate=date,+              teffectivedate=effectivedate,+              tstatus=status,+              tcode=code,+              tdescription=newdesc,+              tcomment=comment,+              tpreceding_comment_lines=precomment,+              ttags=[],+              tpostings=[+                   Posting {+                     pstatus=False,+                     paccount=acct,+                     pamount=a,+                     pcomment="",+                     ptype=RegularPosting,+                     ptags=[],+                     ptransaction=Just t+                   },+                   Posting {+                     pstatus=False,+                     paccount=baseacc,+                     pamount=(-baseamount),+                     pcomment="",+                     ptype=RegularPosting,+                     ptags=[],+                     ptransaction=Just t+                   }+                  ]+            }+  in t++-- | Convert some date string with unknown format to YYYY/MM/DD.+normaliseDate :: Maybe String -- ^ User-supplied date format: this should be tried in preference to all others+              -> String -> String+normaliseDate mb_user_format s =+    let parsewith = flip (parseTime defaultTimeLocale) s in+    maybe (error' $ "could not parse \""++s++"\" as a date, consider adding a date-format directive or upgrading")+          showDate $+          firstJust $ (map parsewith $+                       maybe [] (:[]) mb_user_format+                       -- the - modifier requires time-1.2.0.5, released+                       -- in 2011/5, so for now we emulate it for wider+                       -- compatibility.  time < 1.2.0.5 also has a buggy+                       -- %y which we don't do anything about.+                       -- ++ [+                       -- "%Y/%m/%d"+                       -- ,"%Y/%-m/%-d"+                       -- ,"%Y-%m-%d"+                       -- ,"%Y-%-m-%-d"+                       -- ,"%m/%d/%Y"+                       -- ,"%-m/%-d/%Y"+                       -- ,"%m-%d-%Y"+                       -- ,"%-m-%-d-%Y"+                       -- ]+                      )+                      ++ [+                       parseTime defaultTimeLocale "%Y/%m/%e" s+                      ,parseTime defaultTimeLocale "%Y-%m-%e" s+                      ,parseTime defaultTimeLocale "%m/%e/%Y" s+                      ,parseTime defaultTimeLocale "%m-%e-%Y" s+                      ,parseTime defaultTimeLocale "%Y/%m/%e" (take 5 s ++ "0" ++ drop 5 s)+                      ,parseTime defaultTimeLocale "%Y-%m-%e" (take 5 s ++ "0" ++ drop 5 s)+                      ,parseTime defaultTimeLocale "%m/%e/%Y" ('0':s)+                      ,parseTime defaultTimeLocale "%m-%e-%Y" ('0':s)+                      ]++-- | Apply account matching rules to a transaction description to obtain+-- the most appropriate account and a new description.+identify :: [AccountRule] -> String -> String -> (String,String)+identify rules defacct desc | null matchingrules = (defacct,desc)+                            | otherwise = (acct,newdesc)+    where+      matchingrules = filter ismatch rules :: [AccountRule]+          where ismatch = any ((`regexMatchesCI` desc) . fst) . fst+      (prs,acct) = head matchingrules+      p_ms_r = filter (\(_,m,_) -> m) $ map (\(p,r) -> (p, p `regexMatchesCI` desc, r)) prs+      (p,_,r) = head p_ms_r+      newdesc = case r of Just repl -> regexReplaceCI p repl desc+                          Nothing   -> desc++getAmount :: CsvRules -> CsvRecord -> String+getAmount rules fields = case amountField rules of+  Just f  -> maybe "" (atDef "" fields) $ Just f+  Nothing ->+    case (i, o) of+      (x, "") -> x+      ("", x) -> "-"++x+      p -> error' $ "using amount-in-field and amount-out-field, found a value in both fields: "++show p+    where+      i = maybe "" (atDef "" fields) (amountInField rules)+      o = maybe "" (atDef "" fields) (amountOutField rules)++tests_Hledger_Read_CsvReader = TestList (test_parser ++ test_description_parsing)++test_description_parsing = [+      "description-field 1" ~: assertParseDescription "description-field 1\n" [FormatField False Nothing Nothing (FieldNo 1)]+    , "description-field 1 " ~: assertParseDescription "description-field 1 \n" [FormatField False Nothing Nothing (FieldNo 1)]+    , "description-field %(1)" ~: assertParseDescription "description-field %(1)\n" [FormatField False Nothing Nothing (FieldNo 1)]+    , "description-field %(1)/$(2)" ~: assertParseDescription "description-field %(1)/%(2)\n" [+          FormatField False Nothing Nothing (FieldNo 1)+        , FormatLiteral "/"+        , FormatField False Nothing Nothing (FieldNo 2)+        ]+    ]+  where+    assertParseDescription string expected = do assertParseEqual (parseDescription string) (nullrules {descriptionField = expected})+    parseDescription :: String -> Either ParseError CsvRules+    parseDescription x = runParser descriptionfieldWrapper nullrules "(unknown)" x+    descriptionfieldWrapper :: GenParser Char CsvRules CsvRules+    descriptionfieldWrapper = do+      descriptionfield+      r <- getState+      return r++test_parser =  [++   "convert rules parsing: empty file" ~: do+     -- let assertMixedAmountParse parseresult mixedamount =+     --         (either (const "parse error") showMixedAmountDebug parseresult) ~?= (showMixedAmountDebug mixedamount)+    assertParseEqual (parseCsvRules "unknown" "") nullrules++  ,"convert rules parsing: accountrule" ~: do+     assertParseEqual (parseWithCtx nullrules accountrule "A\na\n") -- leading blank line required+                 ([("A",Nothing)], "a")++  ,"convert rules parsing: trailing comments" ~: do+     assertParse (parseWithCtx nullrules csvrulesfile "A\na\n# \n#\n")++  ,"convert rules parsing: trailing blank lines" ~: do+     assertParse (parseWithCtx nullrules csvrulesfile "A\na\n\n  \n")++  -- not supported+  -- ,"convert rules parsing: no final newline" ~: do+  --    assertParse (parseWithCtx nullrules csvrulesfile "A\na")+  --    assertParse (parseWithCtx nullrules csvrulesfile "A\na\n# \n#")+  --    assertParse (parseWithCtx nullrules csvrulesfile "A\na\n\n  ")++                 -- (nullrules{+                 --   -- dateField=Maybe FieldPosition,+                 --   -- statusField=Maybe FieldPosition,+                 --   -- codeField=Maybe FieldPosition,+                 --   -- descriptionField=Maybe FieldPosition,+                 --   -- amountField=Maybe FieldPosition,+                 --   -- currencyField=Maybe FieldPosition,+                 --   -- baseCurrency=Maybe String,+                 --   -- baseAccount=AccountName,+                 --   accountRules=[+                 --        ([("A",Nothing)], "a")+                 --       ]+                 --  })++  ]
Hledger/Read/JournalReader.hs view
@@ -1,145 +1,63 @@+{-# LANGUAGE RecordWildCards #-} {-| -A reader for hledger's (and c++ ledger's) journal file format.--From the ledger 2.5 manual:+A reader for hledger's journal file format+(<http://hledger.org/MANUAL.html#the-journal-file>).  hledger's journal+format is a compatible subset of c++ ledger's+(<http://ledger-cli.org/3.0/doc/ledger3.html#Journal-Format>), so this+reader should handle many ledger files as well. Example:  @-The ledger file format is quite simple, but also very flexible. It supports-many options, though typically the user can ignore most of them. They are-summarized below.  The initial character of each line determines what the-line means, and how it should be interpreted. Allowable initial characters-are:--NUMBER      A line beginning with a number denotes an entry. It may be followed by any-            number of lines, each beginning with whitespace, to denote the entry’s account-            transactions. The format of the first line is:--                    DATE[=EDATE] [*|!] [(CODE)] DESC--            If ‘*’ appears after the date (with optional effective date), it indicates the entry-            is “cleared”, which can mean whatever the user wants it t omean. If ‘!’ appears-            after the date, it indicates d the entry is “pending”; i.e., tentatively cleared from-            the user’s point of view, but not yet actually cleared. If a ‘CODE’ appears in-            parentheses, it may be used to indicate a check number, or the type of the-            transaction. Following these is the payee, or a description of the transaction.-            The format of each following transaction is:--                      ACCOUNT     AMOUNT    [; NOTE]--            The ‘ACCOUNT’ may be surrounded by parentheses if it is a virtual-            transactions, or square brackets if it is a virtual transactions that must-            balance. The ‘AMOUNT’ can be followed by a per-unit transaction cost,-            by specifying ‘ AMOUNT’, or a complete transaction cost with ‘\@ AMOUNT’.-            Lastly, the ‘NOTE’ may specify an actual and/or effective date for the-            transaction by using the syntax ‘[ACTUAL_DATE]’ or ‘[=EFFECTIVE_DATE]’ or-            ‘[ACTUAL_DATE=EFFECtIVE_DATE]’.--=           An automated entry. A value expression must appear after the equal sign.-            After this initial line there should be a set of one or more transactions, just as-            if it were normal entry. If the amounts of the transactions have no commodity,-            they will be applied as modifiers to whichever real transaction is matched by-            the value expression.- -~           A period entry. A period expression must appear after the tilde.-            After this initial line there should be a set of one or more transactions, just as-            if it were normal entry.--!           A line beginning with an exclamation mark denotes a command directive. It-            must be immediately followed by the command word. The supported commands-            are:--           ‘!include’-                        Include the stated ledger file.-           ‘!account’-                        The account name is given is taken to be the parent of all transac--                        tions that follow, until ‘!end’ is seen.-           ‘!end’       Ends an account block.- -;          A line beginning with a colon indicates a comment, and is ignored.- -Y          If a line begins with a capital Y, it denotes the year used for all subsequent-           entries that give a date without a year. The year should appear immediately-           after the Y, for example: ‘Y2004’. This is useful at the beginning of a file, to-           specify the year for that file. If all entries specify a year, however, this command-           has no effect.-           - -P          Specifies a historical price for a commodity. These are usually found in a pricing-           history file (see the ‘-Q’ option). The syntax is:--                  P DATE SYMBOL PRICE--N SYMBOL   Indicates that pricing information is to be ignored for a given symbol, nor will-           quotes ever be downloaded for that symbol. Useful with a home currency, such-           as the dollar ($). It is recommended that these pricing options be set in the price-           database file, which defaults to ‘~/.pricedb’. The syntax for this command is:--                  N SYMBOL--        -D AMOUNT   Specifies the default commodity to use, by specifying an amount in the expected-           format. The entry command will use this commodity as the default when none-           other can be determined. This command may be used multiple times, to set-           the default flags for different commodities; whichever is seen last is used as the-           default commodity. For example, to set US dollars as the default commodity,-           while also setting the thousands flag and decimal flag for that commodity, use:--                  D $1,000.00--C AMOUNT1 = AMOUNT2-           Specifies a commodity conversion, where the first amount is given to be equiv--           alent to the second amount. The first amount should use the decimal precision-           desired during reporting:--                  C 1.00 Kb = 1024 bytes--i, o, b, h-           These four relate to timeclock support, which permits ledger to read timelog-           files. See the timeclock’s documentation for more info on the syntax of its-           timelog files.+2012\/3\/24 gift+    expenses:gifts  $10+    assets:cash @  -}  module Hledger.Read.JournalReader (-       emptyLine,-       journalAddFile,-       journalFile,-       ledgeraccountname,-       ledgerdatetime,-       ledgerDefaultYear,-       ledgerDirective,-       ledgerHistoricalPrice,-       reader,-       someamount,-       tests_Hledger_Read_JournalReader+  -- * Reader+  reader,+  -- * Parsers used elsewhere+  parseJournalWith,+  getParentAccount,+  journal,+  directive,+  defaultyeardirective,+  historicalpricedirective,+  datetime,+  accountname,+  amount,+  amount',+  emptyline,+  -- * Tests+  tests_Hledger_Read_JournalReader ) where+import qualified Control.Exception as C import Control.Monad import Control.Monad.Error import Data.Char (isNumber)+import Data.Either (partitionEithers) import Data.List import Data.List.Split (wordsBy) import Data.Maybe import Data.Time.Calendar--- import Data.Time.Clock--- import Data.Time.Format import Data.Time.LocalTime import Safe (headDef)--- import System.Locale (defaultTimeLocale) import Test.HUnit import Text.ParserCombinators.Parsec hiding (parse) import Text.Printf+import System.FilePath+import System.Time (getClockTime)  import Hledger.Data-import Hledger.Read.Utils import Hledger.Utils import Prelude hiding (readFile)-import Hledger.Utils.UTF8 (readFile)+import Hledger.Utils.UTF8IOCompat (readFile)  --- let's get to it+-- standard reader exports  reader :: Reader reader = Reader format detect parse@@ -149,76 +67,113 @@  -- | Does the given file path and data provide hledger's journal file format ? detect :: FilePath -> String -> Bool-detect f _ = fileSuffix f == format+detect f _ = takeExtension f `elem` ['.':format, ".j"]  -- | Parse and post-process a "Journal" from hledger's journal file -- format, or give an error.-parse :: FilePath -> String -> ErrorT String IO Journal-parse = parseJournalWith journalFile+parse :: Maybe FilePath -> FilePath -> String -> ErrorT String IO Journal+parse _ = -- trace ("running "++format++" reader") .+          parseJournalWith journal +-- parsing utils++-- | Flatten a list of JournalUpdate's into a single equivalent one.+combineJournalUpdates :: [JournalUpdate] -> JournalUpdate+combineJournalUpdates us = liftM (foldr (.) id) $ sequence us++-- | Given a JournalUpdate-generating parsec parser, file path and data string,+-- parse and post-process a Journal so that it's ready to use, or give an error.+parseJournalWith :: (GenParser Char JournalContext (JournalUpdate,JournalContext)) -> FilePath -> String -> ErrorT String IO Journal+parseJournalWith p f s = do+  tc <- liftIO getClockTime+  tl <- liftIO getCurrentLocalTime+  y <- liftIO getCurrentYear+  case runParser p nullctx{ctxYear=Just y} f s of+    Right (updates,ctx) -> do+                           j <- updates `ap` return nulljournal+                           case journalFinalise tc tl f s ctx j of+                             Right j'  -> return j'+                             Left estr -> throwError estr+    Left e -> throwError $ show e++setYear :: Integer -> GenParser tok JournalContext ()+setYear y = updateState (\ctx -> ctx{ctxYear=Just y})++getYear :: GenParser tok JournalContext (Maybe Integer)+getYear = liftM ctxYear getState++setCommodity :: Commodity -> GenParser tok JournalContext ()+setCommodity c = updateState (\ctx -> ctx{ctxCommodity=Just c})++getCommodity :: GenParser tok JournalContext (Maybe Commodity)+getCommodity = liftM ctxCommodity getState++pushParentAccount :: String -> GenParser tok JournalContext ()+pushParentAccount parent = updateState addParentAccount+    where addParentAccount ctx0 = ctx0 { ctxAccount = parent : ctxAccount ctx0 }++popParentAccount :: GenParser tok JournalContext ()+popParentAccount = do ctx0 <- getState+                      case ctxAccount ctx0 of+                        [] -> unexpected "End of account block with no beginning"+                        (_:rest) -> setState $ ctx0 { ctxAccount = rest }++getParentAccount :: GenParser tok JournalContext String+getParentAccount = liftM (concatAccountNames . reverse . ctxAccount) getState++addAccountAlias :: (AccountName,AccountName) -> GenParser tok JournalContext ()+addAccountAlias a = updateState (\(ctx@Ctx{..}) -> ctx{ctxAliases=a:ctxAliases})++getAccountAliases :: GenParser tok JournalContext [(AccountName,AccountName)]+getAccountAliases = liftM ctxAliases getState++clearAccountAliases :: GenParser tok JournalContext ()+clearAccountAliases = updateState (\(ctx@Ctx{..}) -> ctx{ctxAliases=[]})++-- parsers+ -- | Top-level journal parser. Returns a single composite, I/O performing, -- error-raising "JournalUpdate" (and final "JournalContext") which can be -- applied to an empty journal to get the final result.-journalFile :: GenParser Char JournalContext (JournalUpdate,JournalContext)-journalFile = do+journal :: GenParser Char JournalContext (JournalUpdate,JournalContext)+journal = do   journalupdates <- many journalItem   eof   finalctx <- getState-  return $ (juSequence journalupdates, finalctx)+  return $ (combineJournalUpdates journalupdates, finalctx)     where        -- As all journal line types can be distinguished by the first       -- character, excepting transactions versus empty (blank or       -- comment-only) lines, can use choice w/o try-      journalItem = choice [ ledgerDirective-                           , liftM (return . addTransaction) ledgerTransaction-                           , liftM (return . addModifierTransaction) ledgerModifierTransaction-                           , liftM (return . addPeriodicTransaction) ledgerPeriodicTransaction-                           , liftM (return . addHistoricalPrice) ledgerHistoricalPrice-                           , emptyLine >> return (return id)+      journalItem = choice [ directive+                           , liftM (return . addTransaction) transaction+                           , liftM (return . addModifierTransaction) modifiertransaction+                           , liftM (return . addPeriodicTransaction) periodictransaction+                           , liftM (return . addHistoricalPrice) historicalpricedirective+                           , emptyline >> return (return id)                            ] <?> "journal transaction or directive" -emptyLine :: GenParser Char JournalContext ()-emptyLine = do many spacenonewline-               optional $ (char ';' <?> "comment") >> many (noneOf "\n")-               newline-               return ()--ledgercomment :: GenParser Char JournalContext String-ledgercomment = do-  many1 $ char ';'-  many spacenonewline-  many (noneOf "\n")-  <?> "comment"--ledgercommentline :: GenParser Char JournalContext String-ledgercommentline = do-  many spacenonewline-  s <- ledgercomment-  optional newline-  eof-  return s-  <?> "comment"--ledgerDirective :: GenParser Char JournalContext JournalUpdate-ledgerDirective = do+-- cf http://ledger-cli.org/3.0/doc/ledger3.html#Command-Directives+directive :: GenParser Char JournalContext JournalUpdate+directive = do   optional $ char '!'   choice' [-    ledgerInclude-   ,ledgerAlias-   ,ledgerEndAliases-   ,ledgerAccountBegin-   ,ledgerAccountEnd-   ,ledgerTagDirective-   ,ledgerEndTagDirective-   ,ledgerDefaultYear-   ,ledgerDefaultCommodity-   ,ledgerCommodityConversion-   ,ledgerIgnoredPriceCommodity+    includedirective+   ,aliasdirective+   ,endaliasesdirective+   ,accountdirective+   ,enddirective+   ,tagdirective+   ,endtagdirective+   ,defaultyeardirective+   ,defaultcommoditydirective+   ,commodityconversiondirective+   ,ignoredpricecommoditydirective    ]   <?> "directive" -ledgerInclude :: GenParser Char JournalContext JournalUpdate-ledgerInclude = do+includedirective :: GenParser Char JournalContext JournalUpdate+includedirective = do   string "include"   many1 spacenonewline   filename <- restofline@@ -227,33 +182,33 @@   return $ do filepath <- expandPath outerPos filename               txt <- readFileOrError outerPos filepath               let inIncluded = show outerPos ++ " in included file " ++ show filename ++ ":\n"-              case runParser journalFile outerState filepath txt of-                Right (ju,_) -> juSequence [return $ journalAddFile (filepath,txt), ju] `catchError` (throwError . (inIncluded ++))+              case runParser journal outerState filepath txt of+                Right (ju,_) -> combineJournalUpdates [return $ journalAddFile (filepath,txt), ju] `catchError` (throwError . (inIncluded ++))                 Left err     -> throwError $ inIncluded ++ show err       where readFileOrError pos fp =-                ErrorT $ liftM Right (readFile fp) `catch`-                  \err -> return $ Left $ printf "%s reading %s:\n%s" (show pos) fp (show err)+                ErrorT $ liftM Right (readFile fp) `C.catch`+                  \e -> return $ Left $ printf "%s reading %s:\n%s" (show pos) fp (show (e::C.IOException))  journalAddFile :: (FilePath,String) -> Journal -> Journal journalAddFile f j@Journal{files=fs} = j{files=fs++[f]} -ledgerAccountBegin :: GenParser Char JournalContext JournalUpdate-ledgerAccountBegin = do+accountdirective :: GenParser Char JournalContext JournalUpdate+accountdirective = do   string "account"   many1 spacenonewline-  parent <- ledgeraccountname+  parent <- accountname   newline   pushParentAccount parent   return $ return id -ledgerAccountEnd :: GenParser Char JournalContext JournalUpdate-ledgerAccountEnd = do+enddirective :: GenParser Char JournalContext JournalUpdate+enddirective = do   string "end"   popParentAccount   return (return id) -ledgerAlias :: GenParser Char JournalContext JournalUpdate-ledgerAlias = do+aliasdirective :: GenParser Char JournalContext JournalUpdate+aliasdirective = do   string "alias"   many1 spacenonewline   orig <- many1 $ noneOf "="@@ -263,28 +218,28 @@                   ,accountNameWithoutPostingType $ strip alias)   return $ return id -ledgerEndAliases :: GenParser Char JournalContext JournalUpdate-ledgerEndAliases = do+endaliasesdirective :: GenParser Char JournalContext JournalUpdate+endaliasesdirective = do   string "end aliases"   clearAccountAliases   return (return id) -ledgerTagDirective :: GenParser Char JournalContext JournalUpdate-ledgerTagDirective = do+tagdirective :: GenParser Char JournalContext JournalUpdate+tagdirective = do   string "tag" <?> "tag directive"   many1 spacenonewline   _ <- many1 nonspace   restofline   return $ return id -ledgerEndTagDirective :: GenParser Char JournalContext JournalUpdate-ledgerEndTagDirective = do+endtagdirective :: GenParser Char JournalContext JournalUpdate+endtagdirective = do   (string "end tag" <|> string "pop") <?> "end tag or pop directive"   restofline   return $ return id -ledgerDefaultYear :: GenParser Char JournalContext JournalUpdate-ledgerDefaultYear = do+defaultyeardirective :: GenParser Char JournalContext JournalUpdate+defaultyeardirective = do   char 'Y' <?> "default year"   many spacenonewline   y <- many1 digit@@ -293,84 +248,157 @@   setYear y'   return $ return id -ledgerDefaultCommodity :: GenParser Char JournalContext JournalUpdate-ledgerDefaultCommodity = do+defaultcommoditydirective :: GenParser Char JournalContext JournalUpdate+defaultcommoditydirective = do   char 'D' <?> "default commodity"   many1 spacenonewline-  a <- someamount-  -- someamount always returns a MixedAmount containing one Amount, but let's be safe+  a <- amount+  -- amount always returns a MixedAmount containing one Amount, but let's be safe   let as = amounts a   when (not $ null as) $ setCommodity $ commodity $ head as   restofline   return $ return id -ledgerHistoricalPrice :: GenParser Char JournalContext HistoricalPrice-ledgerHistoricalPrice = do+historicalpricedirective :: GenParser Char JournalContext HistoricalPrice+historicalpricedirective = do   char 'P' <?> "historical price"   many spacenonewline-  date <- try (do {LocalTime d _ <- ledgerdatetime; return d}) <|> ledgerdate -- a time is ignored+  date <- try (do {LocalTime d _ <- datetime; return d}) <|> date -- a time is ignored   many1 spacenonewline   symbol <- commoditysymbol   many spacenonewline-  price <- someamount+  price <- amount   restofline   return $ HistoricalPrice date symbol price -ledgerIgnoredPriceCommodity :: GenParser Char JournalContext JournalUpdate-ledgerIgnoredPriceCommodity = do+ignoredpricecommoditydirective :: GenParser Char JournalContext JournalUpdate+ignoredpricecommoditydirective = do   char 'N' <?> "ignored-price commodity"   many1 spacenonewline   commoditysymbol   restofline   return $ return id -ledgerCommodityConversion :: GenParser Char JournalContext JournalUpdate-ledgerCommodityConversion = do+commodityconversiondirective :: GenParser Char JournalContext JournalUpdate+commodityconversiondirective = do   char 'C' <?> "commodity conversion"   many1 spacenonewline-  someamount+  amount   many spacenonewline   char '='   many spacenonewline-  someamount+  amount   restofline   return $ return id -ledgerModifierTransaction :: GenParser Char JournalContext ModifierTransaction-ledgerModifierTransaction = do+modifiertransaction :: GenParser Char JournalContext ModifierTransaction+modifiertransaction = do   char '=' <?> "modifier transaction"   many spacenonewline   valueexpr <- restofline-  postings <- ledgerpostings+  postings <- postings   return $ ModifierTransaction valueexpr postings -ledgerPeriodicTransaction :: GenParser Char JournalContext PeriodicTransaction-ledgerPeriodicTransaction = do+periodictransaction :: GenParser Char JournalContext PeriodicTransaction+periodictransaction = do   char '~' <?> "periodic transaction"   many spacenonewline   periodexpr <- restofline-  postings <- ledgerpostings+  postings <- postings   return $ PeriodicTransaction periodexpr postings --- | Parse a (possibly unbalanced) ledger transaction.-ledgerTransaction :: GenParser Char JournalContext Transaction-ledgerTransaction = do-  date <- ledgerdate <?> "transaction"-  edate <- optionMaybe (ledgereffectivedate date) <?> "effective date"-  status <- ledgerstatus <?> "cleared flag"-  code <- ledgercode <?> "transaction code"-  (description, comment) <--      (do {many1 spacenonewline; d <- liftM rstrip (many (noneOf ";\n")); c <- ledgercomment <|> return ""; newline; return (d, c)} <|>-       do {many spacenonewline; c <- ledgercomment <|> return ""; newline; return ("", c)}-      ) <?> "description and/or comment"-  md <- try ledgermetadata <|> return []-  postings <- ledgerpostings-  return $ txnTieKnot $ Transaction date edate status code description comment md postings ""+-- | Parse a (possibly unbalanced) transaction.+transaction :: GenParser Char JournalContext Transaction+transaction = do+  date <- date <?> "transaction"+  edate <- optionMaybe (effectivedate date) <?> "effective date"+  status <- status <?> "cleared flag"+  code <- code <?> "transaction code"+  -- now there can be whitespace followed by a description and/or comment/tag comment+  let pdescription = many (noneOf ";\n") >>= return . strip+  (description, inlinecomment, inlinetag) <-+    try (do many1 spacenonewline+            d <- pdescription+            (c, m) <- inlinecomment+            return (d,c,m))+    <|> (newline >> return ("", [], []))+  (nextlinecomments, nextlinetags) <- commentlines+  let comment = unlines $ inlinecomment ++ nextlinecomments+      tags = inlinetag ++ nextlinetags+  postings <- postings+  return $ txnTieKnot $ Transaction date edate status code description comment tags postings "" +tests_transaction = [+   "transaction" ~: do+    -- let s `gives` t = assertParseEqual (parseWithCtx nullctx transaction s) t+    let s `gives` t = do+                        let p = parseWithCtx nullctx transaction s+                        assertBool "transaction parser failed" $ isRight p+                        let Right t2 = p+                            same f = assertEqual "" (f t) (f t2)+                        same tdate+                        same teffectivedate+                        same tstatus+                        same tcode+                        same tdescription+                        same tcomment+                        same ttags+                        same tpreceding_comment_lines+                        same tpostings+    -- "0000/01/01\n\n" `gives` nulltransaction +    unlines [+      "2012/05/14=2012/05/15 (code) desc  ; tcomment1",+      "    ; tcomment2",+      "    ; ttag1: val1",+      "    * a         $1.00  ; pcomment1",+      "    ; pcomment2",+      "    ; ptag1: val1",+      "    ; ptag2: val2"+      ]+     `gives`+     nulltransaction{+      tdate=parsedate "2012/05/14",+      teffectivedate=Just $ parsedate "2012/05/15",+      tstatus=False,+      tcode="code",+      tdescription="desc",+      tcomment="tcomment1\ntcomment2\n",+      ttags=[("ttag1","val1")],+      tpostings=[+        nullposting{+          pstatus=True,+          paccount="a",+          pamount=Mixed [dollars 1],+          pcomment="pcomment1\npcomment2\n",+          ptype=RegularPosting,+          ptags=[("ptag1","val1"),("ptag2","val2")],+          ptransaction=Nothing+          }+        ],+      tpreceding_comment_lines=""+      }++    assertParseEqual (parseWithCtx nullctx transaction entry1_str) entry1+    assertBool "transaction should not parse just a date"+                   $ isLeft $ parseWithCtx nullctx transaction "2009/1/1\n"+    assertBool "transaction should require some postings"+                   $ isLeft $ parseWithCtx nullctx transaction "2009/1/1 a\n"+    let t = parseWithCtx nullctx transaction "2009/1/1 a ;comment\n b 1\n"+    assertBool "transaction should not include a comment in the description"+                   $ either (const False) ((== "a") . tdescription) t+    assertBool "parse transaction with following whitespace line" $+       isRight $ parseWithCtx nullctx transaction $ unlines [+         "2012/1/1"+        ,"  a  1"+        ,"  b"+        ," "+        ]+ ]+ -- | Parse a date in YYYY/MM/DD format. Fewer digits are allowed. The year -- may be omitted if a default year has already been set.-ledgerdate :: GenParser Char JournalContext Day-ledgerdate = do+date :: GenParser Char JournalContext Day+date = do   -- hacky: try to ensure precise errors for invalid dates   -- XXX reported error position is not too good   -- pos <- getPosition@@ -392,9 +420,9 @@ -- timezone will be ignored; the time is treated as local time.  Fewer -- digits are allowed, except in the timezone. The year may be omitted if -- a default year has already been set.-ledgerdatetime :: GenParser Char JournalContext LocalTime-ledgerdatetime = do -  day <- ledgerdate+datetime :: GenParser Char JournalContext LocalTime+datetime = do+  day <- date   many1 spacenonewline   h <- many1 digit   let h' = read h@@ -420,8 +448,8 @@   -- return $ localTimeToUTC tz' $ LocalTime day $ TimeOfDay h' m' (fromIntegral s')   return $ LocalTime day $ TimeOfDay h' m' (fromIntegral s') -ledgereffectivedate :: Day -> GenParser Char JournalContext Day-ledgereffectivedate actualdate = do+effectivedate :: Day -> GenParser Char JournalContext Day+effectivedate actualdate = do   char '='   -- kludgy way to use actual date for default year   let withDefaultYear d p = do@@ -430,81 +458,68 @@         r <- p         when (isJust y) $ setYear $ fromJust y         return r-  edate <- withDefaultYear actualdate ledgerdate+  edate <- withDefaultYear actualdate date   return edate -ledgerstatus :: GenParser Char JournalContext Bool-ledgerstatus = try (do { many spacenonewline; char '*' <?> "status"; return True } ) <|> return False--ledgercode :: GenParser Char JournalContext String-ledgercode = try (do { many1 spacenonewline; char '(' <?> "code"; code <- anyChar `manyTill` char ')'; return code } ) <|> return ""--ledgermetadata :: GenParser Char JournalContext [(String,String)]-ledgermetadata = many ledgermetadataline+status :: GenParser Char JournalContext Bool+status = try (do { many spacenonewline; char '*' <?> "status"; return True } ) <|> return False --- a comment line containing a metadata declaration, eg:--- ; name: value-ledgermetadataline :: GenParser Char JournalContext (String,String)-ledgermetadataline = do-  many1 spacenonewline-  many1 $ char ';'-  many spacenonewline-  name <- many1 $ noneOf ": \t"-  char ':'-  many spacenonewline-  value <- many (noneOf "\n")-  optional newline---  eof-  return (name,value)-  <?> "metadata line"+code :: GenParser Char JournalContext String+code = try (do { many1 spacenonewline; char '(' <?> "code"; code <- anyChar `manyTill` char ')'; return code } ) <|> return "" --- Parse the following whitespace-beginning lines as postings, posting metadata, and/or comments.--- complicated to handle intermixed comment and metadata lines.. make me better ?-ledgerpostings :: GenParser Char JournalContext [Posting]-ledgerpostings = do-  ctx <- getState-  -- we'll set the correct position for sub-parses for more useful errors-  pos <- getPosition-  ls <- many1 $ try linebeginningwithspaces-  let lsnumbered = zip ls [0..]-      parses p = isRight . parseWithCtx ctx p-      postinglines = filter (not . (ledgercommentline `parses`) . fst) lsnumbered-      -- group any metadata lines with the posting line above-      postinglinegroups :: [(String,Line)] -> [(String,Line)]-      postinglinegroups [] = []-      postinglinegroups ((pline,num):ls) = (unlines (pline:(map fst mdlines)), num):postinglinegroups rest-          where (mdlines,rest) = span ((ledgermetadataline `parses`) . fst) ls-      pstrs = postinglinegroups postinglines-      parseNumberedPostingLine (str,num) = fromparse $ parseWithCtx ctx (setPosition (incSourceLine pos num) >> ledgerposting) str-  when (null pstrs) $ fail "no postings"-  return $ map parseNumberedPostingLine pstrs-  <?> "postings"+-- Parse the following whitespace-beginning lines as postings, posting tags, and/or comments.+postings :: GenParser Char JournalContext [Posting]+postings = many1 (try posting) <?> "postings"             -linebeginningwithspaces :: GenParser Char JournalContext String-linebeginningwithspaces = do-  sp <- many1 spacenonewline-  c <- nonspace-  cs <- restofline-  return $ sp ++ (c:cs) ++ "\n"+-- linebeginningwithspaces :: GenParser Char JournalContext String+-- linebeginningwithspaces = do+--   sp <- many1 spacenonewline+--   c <- nonspace+--   cs <- restofline+--   return $ sp ++ (c:cs) ++ "\n" -ledgerposting :: GenParser Char JournalContext Posting-ledgerposting = do+posting :: GenParser Char JournalContext Posting+posting = do   many1 spacenonewline-  status <- ledgerstatus+  status <- status   many spacenonewline   account <- modifiedaccountname   let (ptype, account') = (accountNamePostingType account, unbracket account)-  amount <- postingamount+  amount <- spaceandamountormissing   many spacenonewline-  comment <- ledgercomment <|> return ""-  newline-  md <- ledgermetadata-  return (Posting status account' amount comment ptype md Nothing)+  (inlinecomment, inlinetag) <- inlinecomment+  (nextlinecomments, nextlinetags) <- commentlines+  let comment = unlines $ inlinecomment ++ nextlinecomments+      tags = inlinetag ++ nextlinetags+  return (Posting status account' amount comment ptype tags Nothing) --- Parse an account name, then apply any parent account prefix and/or account aliases currently in effect.+tests_posting = [+  "posting" ~: do+    -- let s `gives` r = assertParseEqual (parseWithCtx nullctx posting s) r+    let s `gives` p = do+                         let parse = parseWithCtx nullctx posting s+                         assertBool "posting parser" $ isRight parse+                         let Right p2 = parse+                             same f = assertEqual "" (f p) (f p2)+                         same pstatus+                         same paccount+                         same pamount+                         same pcomment+                         same ptype+                         same ptags+                         same ptransaction+    "  expenses:food:dining  $10.00   ; a: a a \n   ; b: b b \n"+     `gives`+     (Posting False "expenses:food:dining" (Mixed [dollars 10]) "" RegularPosting [("a","a a"), ("b","b b")] Nothing)++    assertBool "posting parses a quoted commodity with numbers"+      (isRight $ parseWithCtx nullctx posting "  a  1 \"DE123\"\n")+ ]++-- | Parse an account name, then apply any parent account prefix and/or account aliases currently in effect. modifiedaccountname :: GenParser Char JournalContext AccountName modifiedaccountname = do-  a <- ledgeraccountname+  a <- accountname   prefix <- getParentAccount   let prefixed = prefix `joinAccountNames` a   aliases <- getAccountAliases@@ -514,8 +529,8 @@ -- them, and are terminated by two or more spaces. They should have one or -- more components of at least one character, separated by the account -- separator char.-ledgeraccountname :: GenParser Char st AccountName-ledgeraccountname = do+accountname :: GenParser Char st AccountName+accountname = do     a <- many1 (nonspace <|> singlespace)     let a' = striptrailingspace a     when (accountNameFromComponents (accountNameComponents a') /= a')@@ -529,18 +544,56 @@ -- accountnamechar = notFollowedBy (oneOf "()[]") >> nonspace --     <?> "account name character (non-bracket, non-parenthesis, non-whitespace)" --- | Parse an amount, with an optional left or right currency symbol and--- optional price.-postingamount :: GenParser Char JournalContext MixedAmount-postingamount =+-- | Parse whitespace then an amount, with an optional left or right+-- currency symbol and optional price, or return the special+-- "missing" marker amount.+spaceandamountormissing :: GenParser Char JournalContext MixedAmount+spaceandamountormissing =   try (do         many1 spacenonewline-        someamount <|> return missingamt-      ) <|> return missingamt+        amount <|> return missingmixedamt+      ) <|> return missingmixedamt -someamount :: GenParser Char JournalContext MixedAmount-someamount = try leftsymbolamount <|> try rightsymbolamount <|> nosymbolamount +tests_spaceandamountormissing = [+   "spaceandamountormissing" ~: do+    assertParseEqual (parseWithCtx nullctx spaceandamountormissing " $47.18") (Mixed [dollars 47.18])+    assertParseEqual (parseWithCtx nullctx spaceandamountormissing "$47.18") missingmixedamt+    assertParseEqual (parseWithCtx nullctx spaceandamountormissing " ") missingmixedamt+    assertParseEqual (parseWithCtx nullctx spaceandamountormissing "") missingmixedamt+ ] +-- | Parse an amount, with an optional left or right currency symbol and+-- optional price.+amount :: GenParser Char JournalContext MixedAmount+amount = try leftsymbolamount <|> try rightsymbolamount <|> nosymbolamount++tests_amount = [+   "amount" ~: do+    assertParseEqual (parseWithCtx nullctx amount "$47.18") (Mixed [dollars 47.18])+    assertParseEqual (parseWithCtx nullctx amount "$1.")+                (Mixed [Amount Commodity {symbol="$",side=L,spaced=False,decimalpoint='.',precision=0,separator=',',separatorpositions=[]} 1 Nothing])+  ,"amount with unit price" ~: do+    assertParseEqual+     (parseWithCtx nullctx amount "$10 @ €0.5")+     (Mixed [Amount{commodity=dollar{precision=0},+                    quantity=10,+                    price=(Just $ UnitPrice $ Mixed [Amount{commodity=euro{precision=1},+                                                            quantity=0.5,+                                                            price=Nothing}])}])+  ,"amount with total price" ~: do+    assertParseEqual+     (parseWithCtx nullctx amount "$10 @@ €5")+     (Mixed [Amount{commodity=dollar{precision=0},+                    quantity=10,+                    price=(Just $ TotalPrice $ Mixed [Amount{commodity=euro{precision=0},+                                                             quantity=5,+                                                             price=Nothing}])}])+ ]++-- | Run the amount parser on a string to get the result or an error.+amount' :: String -> MixedAmount+amount' s = either (error' . show) id $ parseWithCtx nullctx amount s+ leftsymbolamount :: GenParser Char JournalContext MixedAmount leftsymbolamount = do   sign <- optionMaybe $ string "-"@@ -593,56 +646,14 @@           try (do                 char '@'                 many spacenonewline-                a <- someamount -- XXX can parse more prices ad infinitum, shouldn't+                a <- amount -- XXX can parse more prices ad infinitum, shouldn't                 return $ Just $ TotalPrice a)            <|> (do             many spacenonewline-            a <- someamount -- XXX can parse more prices ad infinitum, shouldn't+            a <- amount -- XXX can parse more prices ad infinitum, shouldn't             return $ Just $ UnitPrice a))          <|> return Nothing --- gawd.. trying to parse a ledger number without error:--type Quantity = Double---- -- | Parse a ledger-style numeric quantity and also return the number of--- -- digits to the right of the decimal point and whether thousands are--- -- separated by comma.--- amountquantity :: GenParser Char JournalContext (Quantity, Int, Bool)--- amountquantity = do---   sign <- optionMaybe $ string "-"---   (intwithcommas,frac) <- numberparts---   let comma = ',' `elem` intwithcommas---   let precision = length frac---   -- read the actual value. We expect this read to never fail.---   let int = filter (/= ',') intwithcommas---   let int' = if null int then "0" else int---   let frac' = if null frac then "0" else frac---   let sign' = fromMaybe "" sign---   let quantity = read $ sign'++int'++"."++frac'---   return (quantity, precision, comma)---   <?> "commodity quantity"---- -- | parse the two strings of digits before and after a possible decimal--- -- point.  The integer part may contain commas, or either part may be--- -- empty, or there may be no point.--- numberparts :: GenParser Char JournalContext (String,String)--- numberparts = numberpartsstartingwithdigit <|> numberpartsstartingwithpoint---- numberpartsstartingwithdigit :: GenParser Char JournalContext (String,String)--- numberpartsstartingwithdigit = do---   let digitorcomma = digit <|> char ','---   first <- digit---   rest <- many digitorcomma---   frac <- try (do {char '.'; many digit}) <|> return ""---   return (first:rest,frac)-                     --- numberpartsstartingwithpoint :: GenParser Char JournalContext (String,String)--- numberpartsstartingwithpoint = do---   char '.'---   frac <- many1 digit---   return ("",frac)- -- | Parse a numeric quantity for its value and display attributes.  Some -- international number formats (cf -- http://en.wikipedia.org/wiki/Decimal_separator) are accepted: either@@ -692,8 +703,7 @@   return (quantity,precision,decimalpoint,separator,separatorpositions)   <?> "number" -tests_Hledger_Read_JournalReader = TestList [-+tests_number = [     "number" ~: do       let s `is` n = assertParseEqual (parseWithCtx nullctx number s) n           assertFails = assertBool "" . isLeft . parseWithCtx nullctx number @@ -715,40 +725,121 @@       assertFails "1..1"       assertFails ".1,"       assertFails ",1."+ ] -   ,"ledgerTransaction" ~: do-    assertParseEqual (parseWithCtx nullctx ledgerTransaction entry1_str) entry1-    assertBool "ledgerTransaction should not parse just a date"-                   $ isLeft $ parseWithCtx nullctx ledgerTransaction "2009/1/1\n"-    assertBool "ledgerTransaction should require some postings"-                   $ isLeft $ parseWithCtx nullctx ledgerTransaction "2009/1/1 a\n"-    let t = parseWithCtx nullctx ledgerTransaction "2009/1/1 a ;comment\n b 1\n"-    assertBool "ledgerTransaction should not include a comment in the description"-                   $ either (const False) ((== "a") . tdescription) t+-- older comment parsers -  ,"ledgerModifierTransaction" ~: do-     assertParse (parseWithCtx nullctx ledgerModifierTransaction "= (some value expr)\n some:postings  1\n")+emptyline :: GenParser Char JournalContext ()+emptyline = do many spacenonewline+               optional $ (char ';' <?> "comment") >> many (noneOf "\n")+               newline+               return () -  ,"ledgerPeriodicTransaction" ~: do-     assertParse (parseWithCtx nullctx ledgerPeriodicTransaction "~ (some period expr)\n some:postings  1\n")+comment :: GenParser Char JournalContext String+comment = do+  many1 $ char ';'+  many spacenonewline+  c <- many (noneOf "\n")+  return $ rstrip c+  <?> "comment" -  ,"ledgerDirective" ~: do-     assertParse (parseWithCtx nullctx ledgerDirective "!include /some/file.x\n")-     assertParse (parseWithCtx nullctx ledgerDirective "account some:account\n")-     assertParse (parseWithCtx nullctx (ledgerDirective >> ledgerDirective) "!account a\nend\n")+commentline :: GenParser Char JournalContext String+commentline = do+  many spacenonewline+  c <- comment+  optional newline+  eof+  return c+  <?> "comment" -  ,"ledgercommentline" ~: do-     assertParse (parseWithCtx nullctx ledgercommentline "; some comment \n")-     assertParse (parseWithCtx nullctx ledgercommentline " \t; x\n")-     assertParse (parseWithCtx nullctx ledgercommentline ";x")+-- newer comment parsers -  ,"ledgerdate" ~: do-     assertParse (parseWithCtx nullctx ledgerdate "2011/1/1")-     assertParseFailure (parseWithCtx nullctx ledgerdate "1/1")-     assertParse (parseWithCtx nullctx{ctxYear=Just 2011} ledgerdate "1/1")+inlinecomment :: GenParser Char JournalContext ([String],[Tag])+inlinecomment = try (do {tag <- tagcomment; newline; return ([], [tag])})+                    <|> (do {c <- comment; newline; return ([rstrip c], [])})+                    <|> (newline >> return ([], [])) -  ,"ledgerdatetime" ~: do-      let p = do {t <- ledgerdatetime; eof; return t}+tests_inlinecomment = [+   "inlinecomment" ~: do+    let s `gives` r = assertParseEqual (parseWithCtx nullctx inlinecomment s) r+    ";  comment \n" `gives` (["comment"],[])+    ";tag: a value \n" `gives` ([],[("tag","a value")])+ ]++commentlines :: GenParser Char JournalContext ([String],[Tag])+commentlines = do+  comortags <- many $ choice' [(liftM Right tagline)+                             ,(do {many1 spacenonewline; c <- comment; newline; return $ Left c }) -- XXX fix commentnewline+                             ]+  return $ partitionEithers comortags++tests_commentlines = [+   "commentlines" ~: do+    let s `gives` r = assertParseEqual (parseWithCtx nullctx commentlines s) r+    "    ;  comment 1 \n ; tag1:  val1 \n ;comment 2\n;unindented comment\n"+     `gives` (["comment 1","comment 2"],[("tag1","val1")])+ ]++-- a comment line containing a tag declaration, eg:+-- ; name: value+tagline :: GenParser Char JournalContext Tag+tagline = do+  many1 spacenonewline+  tag <- tagcomment+  newline+  return tag++-- a comment containing a tag, like  "; name: some value"+tagcomment :: GenParser Char JournalContext Tag+tagcomment = do+  many1 $ char ';'+  many spacenonewline+  name <- many1 $ noneOf ": \t"+  char ':'+  many spacenonewline+  value <- many (noneOf "\n")+  return (name, rstrip value)+  <?> "tag comment"++tests_tagcomment = [+   "tagcomment" ~: do+    let s `gives` r = assertParseEqual (parseWithCtx nullctx tagcomment s) r+    ";tag: a value \n" `gives` ("tag","a value")+ ]++tests_Hledger_Read_JournalReader = TestList $ concat [+    tests_number,+    tests_amount,+    tests_spaceandamountormissing,+    tests_tagcomment,+    tests_inlinecomment,+    tests_commentlines,+    tests_posting,+    tests_transaction,+    [+   "modifiertransaction" ~: do+     assertParse (parseWithCtx nullctx modifiertransaction "= (some value expr)\n some:postings  1\n")++  ,"periodictransaction" ~: do+     assertParse (parseWithCtx nullctx periodictransaction "~ (some period expr)\n some:postings  1\n")++  ,"directive" ~: do+     assertParse (parseWithCtx nullctx directive "!include /some/file.x\n")+     assertParse (parseWithCtx nullctx directive "account some:account\n")+     assertParse (parseWithCtx nullctx (directive >> directive) "!account a\nend\n")++  ,"commentline" ~: do+     assertParse (parseWithCtx nullctx commentline "; some comment \n")+     assertParse (parseWithCtx nullctx commentline " \t; x\n")+     assertParse (parseWithCtx nullctx commentline ";x")++  ,"date" ~: do+     assertParse (parseWithCtx nullctx date "2011/1/1")+     assertParseFailure (parseWithCtx nullctx date "1/1")+     assertParse (parseWithCtx nullctx{ctxYear=Just 2011} date "1/1")++  ,"datetime" ~: do+      let p = do {t <- datetime; eof; return t}           bad = assertParseFailure . parseWithCtx nullctx p           good = assertParse . parseWithCtx nullctx p       bad "2011/1/1"@@ -763,69 +854,34 @@       assertParseEqual (parseWithCtx nullctx p "2011/1/1 00:00-0800") startofday       assertParseEqual (parseWithCtx nullctx p "2011/1/1 00:00+1234") startofday -  ,"ledgerDefaultYear" ~: do-     assertParse (parseWithCtx nullctx ledgerDefaultYear "Y 2010\n")-     assertParse (parseWithCtx nullctx ledgerDefaultYear "Y 10001\n")--  ,"ledgerHistoricalPrice" ~:-    assertParseEqual (parseWithCtx nullctx ledgerHistoricalPrice "P 2004/05/01 XYZ $55.00\n") (HistoricalPrice (parsedate "2004/05/01") "XYZ" $ Mixed [dollars 55])--  ,"ledgerIgnoredPriceCommodity" ~: do-     assertParse (parseWithCtx nullctx ledgerIgnoredPriceCommodity "N $\n")--  ,"ledgerDefaultCommodity" ~: do-     assertParse (parseWithCtx nullctx ledgerDefaultCommodity "D $1,000.0\n")+  ,"defaultyeardirective" ~: do+     assertParse (parseWithCtx nullctx defaultyeardirective "Y 2010\n")+     assertParse (parseWithCtx nullctx defaultyeardirective "Y 10001\n") -  ,"ledgerCommodityConversion" ~: do-     assertParse (parseWithCtx nullctx ledgerCommodityConversion "C 1h = $50.00\n")+  ,"historicalpricedirective" ~:+    assertParseEqual (parseWithCtx nullctx historicalpricedirective "P 2004/05/01 XYZ $55.00\n") (HistoricalPrice (parsedate "2004/05/01") "XYZ" $ Mixed [dollars 55]) -  ,"ledgerTagDirective" ~: do-     assertParse (parseWithCtx nullctx ledgerTagDirective "tag foo \n")+  ,"ignoredpricecommoditydirective" ~: do+     assertParse (parseWithCtx nullctx ignoredpricecommoditydirective "N $\n") -  ,"ledgerEndTagDirective" ~: do-     assertParse (parseWithCtx nullctx ledgerEndTagDirective "end tag \n")-  ,"ledgerEndTagDirective" ~: do-     assertParse (parseWithCtx nullctx ledgerEndTagDirective "pop \n")+  ,"defaultcommoditydirective" ~: do+     assertParse (parseWithCtx nullctx defaultcommoditydirective "D $1,000.0\n") -  ,"ledgeraccountname" ~: do-    assertBool "ledgeraccountname parses a normal accountname" (isRight $ parsewith ledgeraccountname "a:b:c")-    assertBool "ledgeraccountname rejects an empty inner component" (isLeft $ parsewith ledgeraccountname "a::c")-    assertBool "ledgeraccountname rejects an empty leading component" (isLeft $ parsewith ledgeraccountname ":b:c")-    assertBool "ledgeraccountname rejects an empty trailing component" (isLeft $ parsewith ledgeraccountname "a:b:")+  ,"commodityconversiondirective" ~: do+     assertParse (parseWithCtx nullctx commodityconversiondirective "C 1h = $50.00\n") - ,"ledgerposting" ~: do-    assertParseEqual (parseWithCtx nullctx ledgerposting "  expenses:food:dining  $10.00\n")-                     (Posting False "expenses:food:dining" (Mixed [dollars 10]) "" RegularPosting [] Nothing)-    assertBool "ledgerposting parses a quoted commodity with numbers"-                   (isRight $ parseWithCtx nullctx ledgerposting "  a  1 \"DE123\"\n")+  ,"tagdirective" ~: do+     assertParse (parseWithCtx nullctx tagdirective "tag foo \n") -  ,"someamount" ~: do-     let -- | compare a parse result with a MixedAmount, showing the debug representation for clarity-         assertMixedAmountParse parseresult mixedamount =-             (either (const "parse error") showMixedAmountDebug parseresult) ~?= (showMixedAmountDebug mixedamount)-     assertMixedAmountParse (parseWithCtx nullctx someamount "1 @ $2")-                            (Mixed [Amount unknown 1 (Just $ UnitPrice $ Mixed [Amount dollar{precision=0} 2 Nothing])])+  ,"endtagdirective" ~: do+     assertParse (parseWithCtx nullctx endtagdirective "end tag \n")+     assertParse (parseWithCtx nullctx endtagdirective "pop \n") -  ,"postingamount" ~: do-    assertParseEqual (parseWithCtx nullctx postingamount " $47.18") (Mixed [dollars 47.18])-    assertParseEqual (parseWithCtx nullctx postingamount " $1.")-                (Mixed [Amount Commodity {symbol="$",side=L,spaced=False,decimalpoint='.',precision=0,separator=',',separatorpositions=[]} 1 Nothing])-  ,"postingamount with unit price" ~: do-    assertParseEqual-     (parseWithCtx nullctx postingamount " $10 @ €0.5")-     (Mixed [Amount{commodity=dollar{precision=0},-                    quantity=10,-                    price=(Just $ UnitPrice $ Mixed [Amount{commodity=euro{precision=1},-                                                            quantity=0.5,-                                                            price=Nothing}])}])-  ,"postingamount with total price" ~: do-    assertParseEqual-     (parseWithCtx nullctx postingamount " $10 @@ €5")-     (Mixed [Amount{commodity=dollar{precision=0},-                    quantity=10,-                    price=(Just $ TotalPrice $ Mixed [Amount{commodity=euro{precision=0},-                                                             quantity=5,-                                                             price=Nothing}])}])+  ,"accountname" ~: do+    assertBool "accountname parses a normal accountname" (isRight $ parsewith accountname "a:b:c")+    assertBool "accountname rejects an empty inner component" (isLeft $ parsewith accountname "a::c")+    assertBool "accountname rejects an empty leading component" (isLeft $ parsewith accountname ":b:c")+    assertBool "accountname rejects an empty trailing component" (isLeft $ parsewith accountname "a:b:")    ,"leftsymbolamount" ~: do     assertParseEqual (parseWithCtx nullctx leftsymbolamount "$1")@@ -835,7 +891,14 @@     assertParseEqual (parseWithCtx nullctx leftsymbolamount "-$1")                      (Mixed [Amount Commodity {symbol="$",side=L,spaced=False,decimalpoint='.',precision=0,separator=',',separatorpositions=[]} (-1) Nothing]) - ]+  ,"amount" ~: do+     let -- | compare a parse result with a MixedAmount, showing the debug representation for clarity+         assertMixedAmountParse parseresult mixedamount =+             (either (const "parse error") showMixedAmountDebug parseresult) ~?= (showMixedAmountDebug mixedamount)+     assertMixedAmountParse (parseWithCtx nullctx amount "1 @ $2")+                            (Mixed [Amount unknown 1 (Just $ UnitPrice $ Mixed [Amount dollar{precision=0} 2 Nothing])])++ ]]  entry1_str = unlines  ["2007/01/28 coopportunity"
Hledger/Read/TimelogReader.hs view
@@ -1,7 +1,13 @@ {-| -A reader for the timelog file format generated by timeclock.el.+A reader for the timelog file format generated by timeclock.el+(<http://www.emacswiki.org/emacs/TimeClock>). Example: +@+i 2007\/03\/10 12:26:00 hledger+o 2007\/03\/10 17:26:02+@+ From timeclock.el 2.6:  @@@ -32,29 +38,27 @@      now finished.  Useful for creating summary reports. @ -Example:--@-i 2007/03/10 12:26:00 hledger-o 2007/03/10 17:26:02-@- -}  module Hledger.Read.TimelogReader (-       reader,-       tests_Hledger_Read_TimelogReader+  -- * Reader+  reader,+  -- * Tests+  tests_Hledger_Read_TimelogReader ) where import Control.Monad import Control.Monad.Error import Test.HUnit import Text.ParserCombinators.Parsec hiding (parse)+import System.FilePath  import Hledger.Data-import Hledger.Read.Utils-import Hledger.Read.JournalReader (ledgerDirective, ledgerHistoricalPrice,-                                   ledgerDefaultYear, emptyLine, ledgerdatetime)+-- XXX too much reuse ?+import Hledger.Read.JournalReader (+  directive, historicalpricedirective, defaultyeardirective, emptyline, datetime,+  parseJournalWith, getParentAccount+  ) import Hledger.Utils  @@ -66,13 +70,14 @@  -- | Does the given file path and data provide timeclock.el's timelog format ? detect :: FilePath -> String -> Bool-detect f _ = fileSuffix f == format+detect f _ = takeExtension f == '.':format  -- | Parse and post-process a "Journal" from timeclock.el's timelog -- format, saving the provided file path and the current time, or give an -- error.-parse :: FilePath -> String -> ErrorT String IO Journal-parse = parseJournalWith timelogFile+parse :: Maybe FilePath -> FilePath -> String -> ErrorT String IO Journal+parse _ = -- trace ("running "++format++" reader") .+          parseJournalWith timelogFile  timelogFile :: GenParser Char JournalContext (JournalUpdate,JournalContext) timelogFile = do items <- many timelogItem@@ -83,10 +88,10 @@       -- 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-      timelogItem = choice [ ledgerDirective-                          , liftM (return . addHistoricalPrice) ledgerHistoricalPrice-                          , ledgerDefaultYear-                          , emptyLine >> return (return id)+      timelogItem = choice [ directive+                          , liftM (return . addHistoricalPrice) historicalpricedirective+                          , defaultyeardirective+                          , emptyline >> return (return id)                           , liftM (return . addTimeLogEntry)  timelogentry                           ] <?> "timelog entry, or default year or historical price directive" @@ -95,7 +100,7 @@ timelogentry = do   code <- oneOf "bhioO"   many1 spacenonewline-  datetime <- ledgerdatetime+  datetime <- datetime   comment <- optionMaybe (many1 spacenonewline >> liftM2 (++) getParentAccount restofline)   return $ TimeLogEntry (read [code]) datetime (maybe "" rstrip comment) 
− Hledger/Read/Utils.hs
@@ -1,86 +0,0 @@-{-# LANGUAGE RecordWildCards #-}-{-|-Utilities common to hledger journal readers.--}--module Hledger.Read.Utils-where--import Control.Monad.Error-import Data.List-import System.Directory (getHomeDirectory)-import System.FilePath(takeDirectory,combine)-import System.Time (getClockTime)-import Text.ParserCombinators.Parsec--import Hledger.Data.Types-import Hledger.Utils-import Hledger.Data.Posting-import Hledger.Data.Dates (getCurrentYear)-import Hledger.Data.Journal---juSequence :: [JournalUpdate] -> JournalUpdate-juSequence us = liftM (foldr (.) id) $ sequence us---- | Given a JournalUpdate-generating parsec parser, file path and data string,--- parse and post-process a Journal so that it's ready to use, or give an error.-parseJournalWith :: (GenParser Char JournalContext (JournalUpdate,JournalContext)) -> FilePath -> String -> ErrorT String IO Journal-parseJournalWith p f s = do-  tc <- liftIO getClockTime-  tl <- liftIO getCurrentLocalTime-  y <- liftIO getCurrentYear-  case runParser p nullctx{ctxYear=Just y} f s of-    Right (updates,ctx) -> do-                           j <- updates `ap` return nulljournal-                           case journalFinalise tc tl f s ctx j of-                             Right j'  -> return j'-                             Left estr -> throwError estr-    Left e -> throwError $ show e--setYear :: Integer -> GenParser tok JournalContext ()-setYear y = updateState (\ctx -> ctx{ctxYear=Just y})--getYear :: GenParser tok JournalContext (Maybe Integer)-getYear = liftM ctxYear getState--setCommodity :: Commodity -> GenParser tok JournalContext ()-setCommodity c = updateState (\ctx -> ctx{ctxCommodity=Just c})--getCommodity :: GenParser tok JournalContext (Maybe Commodity)-getCommodity = liftM ctxCommodity getState--pushParentAccount :: String -> GenParser tok JournalContext ()-pushParentAccount parent = updateState addParentAccount-    where addParentAccount ctx0 = ctx0 { ctxAccount = parent : ctxAccount ctx0 }--popParentAccount :: GenParser tok JournalContext ()-popParentAccount = do ctx0 <- getState-                      case ctxAccount ctx0 of-                        [] -> unexpected "End of account block with no beginning"-                        (_:rest) -> setState $ ctx0 { ctxAccount = rest }--getParentAccount :: GenParser tok JournalContext String-getParentAccount = liftM (concatAccountNames . reverse . ctxAccount) getState--addAccountAlias :: (AccountName,AccountName) -> GenParser tok JournalContext ()-addAccountAlias a = updateState (\(ctx@Ctx{..}) -> ctx{ctxAliases=a:ctxAliases})--getAccountAliases :: GenParser tok JournalContext [(AccountName,AccountName)]-getAccountAliases = liftM ctxAliases getState--clearAccountAliases :: GenParser tok JournalContext ()-clearAccountAliases = updateState (\(ctx@Ctx{..}) -> ctx{ctxAliases=[]})---- | Convert a possibly relative, possibly tilde-containing file path to an absolute one.--- using the current directory from a parsec source position. ~username is not supported.-expandPath :: (MonadIO m) => SourcePos -> FilePath -> m FilePath-expandPath pos fp = liftM mkAbsolute (expandHome fp)-  where-    mkAbsolute = combine (takeDirectory (sourceName pos))-    expandHome inname | "~/" `isPrefixOf` inname = do homedir <- liftIO getHomeDirectory-                                                      return $ homedir ++ drop 1 inname-                      | otherwise                = return inname--fileSuffix :: FilePath -> String-fileSuffix = reverse . takeWhile (/='.') . reverse . dropWhile (/='.')
Hledger/Reports.hs view
@@ -20,7 +20,8 @@   whichDateFromOpts,   journalSelectingDateFromOpts,   journalSelectingAmountFromOpts,-  optsToFilterSpec,+  queryFromOpts,+  queryOptsFromOpts,   -- * Entries report   EntriesReport,   EntriesReportItem,@@ -41,7 +42,6 @@   AccountsReport,   AccountsReportItem,   accountsReport,-  accountsReport2,   isInteresting,   -- * Tests   tests_Hledger_Reports@@ -53,17 +53,22 @@ import Data.Maybe import Data.Ord import Data.Time.Calendar-import Data.Tree+-- import Data.Tree import Safe (headMay, lastMay) import System.Console.CmdArgs  -- for defaults support+import System.Time (ClockTime(TOD)) import Test.HUnit import Text.ParserCombinators.Parsec import Text.Printf  import Hledger.Data+import Hledger.Read (amount')+import Hledger.Query import Hledger.Utils --- report options, used in hledger-lib and above+-- | Standard options for customising report filtering and output,+-- corresponding to hledger's command-line options and query language+-- arguments. Used in hledger-lib and above. data ReportOpts = ReportOpts {      begin_          :: Maybe Day     ,end_            :: Maybe Day@@ -77,16 +82,16 @@     ,empty_          :: Bool     ,no_elide_       :: Bool     ,real_           :: Bool-    ,flat_           :: Bool -- balance-    ,drop_           :: Int  -- balance-    ,no_total_       :: Bool -- balance+    ,flat_           :: Bool -- for balance command+    ,drop_           :: Int  -- "+    ,no_total_       :: Bool -- "     ,daily_          :: Bool     ,weekly_         :: Bool     ,monthly_        :: Bool     ,quarterly_      :: Bool     ,yearly_         :: Bool     ,format_         :: Maybe FormatStr-    ,patterns_       :: [String]+    ,query_          :: String -- all arguments, as a string  } deriving (Show)  type DisplayExp = String@@ -166,31 +171,49 @@     | cost_ opts = journalConvertAmountsToCost     | otherwise = id --- | Convert application options to the library's generic filter specification.-optsToFilterSpec :: ReportOpts -> Day -> FilterSpec-optsToFilterSpec opts@ReportOpts{..} d = FilterSpec {-                                datespan=dateSpanFromOpts d opts-                               ,cleared= clearedValueFromOpts opts-                               ,real=real_-                               ,empty=empty_-                               ,acctpats=apats-                               ,descpats=dpats-                               ,depth = depth_-                               }-    where (apats,dpats) = parsePatternArgs patterns_+-- | Convert report options and arguments to a query.+queryFromOpts :: Day -> ReportOpts -> Query+queryFromOpts d opts@ReportOpts{..} = simplifyQuery $ And $ [flagsq, argsq]+  where+    flagsq = And $+              [Date $ dateSpanFromOpts d opts]+              ++ (if real_ then [Real True] else [])+              ++ (if empty_ then [Empty True] else []) -- ?+              ++ (maybe [] ((:[]) . Status) (clearedValueFromOpts opts))+              ++ (maybe [] ((:[]) . Depth) depth_)+    argsq = fst $ parseQuery d query_ --- | Gather filter pattern arguments into a list of account patterns and a--- list of description patterns. We interpret pattern arguments as--- follows: those prefixed with "desc:" are description patterns, all--- others are account patterns; also patterns prefixed with "not:" are--- negated. not: should come after desc: if both are used.-parsePatternArgs :: [String] -> ([String],[String])-parsePatternArgs args = (as, ds')-    where-      descprefix = "desc:"-      (ds, as) = partition (descprefix `isPrefixOf`) args-      ds' = map (drop (length descprefix)) ds+tests_queryFromOpts = [+ "queryFromOpts" ~: do+  assertEqual "" Any (queryFromOpts nulldate defreportopts)+  assertEqual "" (Acct "a") (queryFromOpts nulldate defreportopts{query_="a"})+  assertEqual "" (Desc "a a") (queryFromOpts nulldate defreportopts{query_="desc:'a a'"})+  assertEqual "" (Date $ mkdatespan "2012/01/01" "2013/01/01")+                 (queryFromOpts nulldate defreportopts{begin_=Just (parsedate "2012/01/01")+                                                      ,query_="date:'to 2013'"+                                                      })+  assertEqual "" (EDate $ mkdatespan "2012/01/01" "2013/01/01")+                 (queryFromOpts nulldate defreportopts{query_="edate:'in 2012'"})+  assertEqual "" (Or [Acct "a a", Acct "'b"])+                 (queryFromOpts nulldate defreportopts{query_="'a a' 'b"})+ ] +-- | Convert report options and arguments to query options.+queryOptsFromOpts :: Day -> ReportOpts -> [QueryOpt]+queryOptsFromOpts d ReportOpts{..} = flagsqopts ++ argsqopts+  where+    flagsqopts = []+    argsqopts = snd $ parseQuery d query_++tests_queryOptsFromOpts = [+ "queryOptsFromOpts" ~: do+  assertEqual "" [] (queryOptsFromOpts nulldate defreportopts)+  assertEqual "" [] (queryOptsFromOpts nulldate defreportopts{query_="a"})+  assertEqual "" [] (queryOptsFromOpts nulldate defreportopts{begin_=Just (parsedate "2012/01/01")+                                                             ,query_="date:'to 2013'"+                                                             })+ ]+ -------------------------------------------------------------------------------  -- | A journal entries report is a list of whole transactions as@@ -200,12 +223,20 @@ type EntriesReportItem = Transaction  -- | Select transactions for an entries report.-entriesReport :: ReportOpts -> FilterSpec -> Journal -> EntriesReport-entriesReport opts fspec j = sortBy (comparing f) $ jtxns $ filterJournalTransactions fspec j'+entriesReport :: ReportOpts -> Query -> Journal -> EntriesReport+entriesReport opts q j =+  sortBy (comparing date) $ filter (q `matchesTransaction`) ts     where-      f = transactionDateFn opts-      j' = journalSelectingAmountFromOpts opts j+      date = transactionDateFn opts+      ts = jtxns $ journalSelectingAmountFromOpts opts j +tests_entriesReport = [+  "entriesReport" ~: do+    assertEqual "not acct" 1 (length $ entriesReport defreportopts (Not $ Acct "bank") samplejournal)+    let span = mkdatespan "2008/06/01" "2008/07/01"+    assertEqual "date" 3 (length $ entriesReport defreportopts (Date $ span) samplejournal)+ ]+ -------------------------------------------------------------------------------  -- | A postings report is a list of postings with a running total, a label@@ -214,32 +245,37 @@                       ,[PostingsReportItem] -- line items, one per posting                       ) type PostingsReportItem = (Maybe (Day, String) -- transaction date and description if this is the first posting-                                 ,Posting      -- the posting-                                 ,MixedAmount  -- the running total after this posting-                                 )+                          ,Posting             -- the posting, possibly with account name depth-clipped+                          ,MixedAmount         -- the running total after this posting+                          )  -- | Select postings from the journal and add running balance and other -- information to make a postings report. Used by eg hledger's register command.-postingsReport :: ReportOpts -> FilterSpec -> Journal -> PostingsReport-postingsReport opts fspec j = (totallabel, postingsReportItems ps nullposting startbal (+))+postingsReport :: ReportOpts -> Query -> Journal -> PostingsReport+postingsReport opts q j = (totallabel, postingsReportItems ps nullposting depth startbal (+))     where       ps | interval == NoInterval = displayableps          | otherwise              = summarisePostingsByInterval interval depth empty reportspan displayableps-      j' =                                journalSelectingDateFromOpts opts-                                        $ journalSelectingAmountFromOpts opts-                                        j-      (precedingps, displayableps, _) =   postingsMatchingDisplayExpr (display_ opts)-                                        $ depthClipPostings depth-                                        $ journalPostings-                                        $ filterJournalPostings fspec{depth=Nothing}-                                        j'-      (interval, depth, empty, displayexpr) = (intervalFromOpts opts, depth_ opts, empty_ opts, display_ opts)+      j' = journalSelectingDateFromOpts opts $ journalSelectingAmountFromOpts opts j+      -- don't do depth filtering until the end+      (depth, q') = (queryDepth q, filterQuery (not . queryIsDepth) q)+      (precedingps, displayableps, _) =   dbg "ps3" $ postingsMatchingDisplayExpr (display_ opts)+                                        $ dbg "ps2" $ filter (q' `matchesPosting`)+                                        $ dbg "ps1" $ journalPostings j'+      dbg :: Show a => String -> a -> a+      -- dbg = ltrace+      dbg = flip const++      empty = queryEmpty q+      displayexpr = display_ opts  -- XXX+      interval = intervalFromOpts opts -- XXX       journalspan = journalDateSpan j'       -- requestedspan should be the intersection of any span specified       -- with period options and any span specified with display option.       -- The latter is not easily available, fake it for now.       requestedspan = periodspan `spanIntersect` displayspan-      periodspan = datespan fspec+      periodspan = queryDateSpan effectivedate q+      effectivedate = whichDateFromOpts opts == EffectiveDate       displayspan = postingsDateSpan ps           where (_,ps,_) = postingsMatchingDisplayExpr displayexpr $ journalPostings j'       matchedspan = postingsDateSpan displayableps@@ -247,21 +283,185 @@                  | otherwise = requestedspan `spanIntersect` matchedspan       startbal = sumPostings precedingps +tests_postingsReport = [+  "postingsReport" ~: do+   let (query, journal) `gives` n = (length $ snd $ postingsReport defreportopts query journal) `is` n+   (Any, nulljournal) `gives` 0+   (Any, samplejournal) `gives` 11+   -- register --depth just clips account names+   (Depth 2, samplejournal) `gives` 11+   -- (Depth 2, samplejournal) `gives` 6+   -- (Depth 1, samplejournal) `gives` 4++   assertEqual "" 11 (length $ snd $ postingsReport defreportopts Any samplejournal)+   assertEqual ""  9 (length $ snd $ postingsReport defreportopts{monthly_=True} Any samplejournal)+   assertEqual "" 19 (length $ snd $ postingsReport defreportopts{monthly_=True} (Empty True) samplejournal)+   assertEqual ""  4 (length $ snd $ postingsReport defreportopts (Acct "assets:bank:checking") samplejournal)++   -- (defreportopts, And [Acct "a a", Acct "'b"], samplejournal2) `gives` 0+   -- [(Just (parsedate "2008-01-01","income"),assets:bank:checking             $1,$1)+   -- ,(Nothing,income:salary                   $-1,0)+   -- ,(Just (2008-06-01,"gift"),assets:bank:checking             $1,$1)+   -- ,(Nothing,income:gifts                    $-1,0)+   -- ,(Just (2008-06-02,"save"),assets:bank:saving               $1,$1)+   -- ,(Nothing,assets:bank:checking            $-1,0)+   -- ,(Just (2008-06-03,"eat & shop"),expenses:food                    $1,$1)+   -- ,(Nothing,expenses:supplies                $1,$2)+   -- ,(Nothing,assets:cash                     $-2,0)+   -- ,(Just (2008-12-31,"pay off"),liabilities:debts                $1,$1)+   -- ,(Nothing,assets:bank:checking            $-1,0)+   -- ]++{-+    let opts = defreportopts+    (postingsReportAsText opts $ postingsReport opts (queryFromOpts date1 opts) j) `is` unlines+     ["2008/01/01 income               assets:bank:checking             $1           $1"+     ,"                                income:salary                   $-1            0"+     ,"2008/06/01 gift                 assets:bank:checking             $1           $1"+     ,"                                income:gifts                    $-1            0"+     ,"2008/06/02 save                 assets:bank:saving               $1           $1"+     ,"                                assets:bank:checking            $-1            0"+     ,"2008/06/03 eat & shop           expenses:food                    $1           $1"+     ,"                                expenses:supplies                $1           $2"+     ,"                                assets:cash                     $-2            0"+     ,"2008/12/31 pay off              liabilities:debts                $1           $1"+     ,"                                assets:bank:checking            $-1            0"+     ]++  ,"postings report with cleared option" ~:+   do +    let opts = defreportopts{cleared_=True}+    j <- readJournal' sample_journal_str+    (postingsReportAsText opts $ postingsReport opts (queryFromOpts date1 opts) j) `is` unlines+     ["2008/06/03 eat & shop           expenses:food                    $1           $1"+     ,"                                expenses:supplies                $1           $2"+     ,"                                assets:cash                     $-2            0"+     ,"2008/12/31 pay off              liabilities:debts                $1           $1"+     ,"                                assets:bank:checking            $-1            0"+     ]++  ,"postings report with uncleared option" ~:+   do +    let opts = defreportopts{uncleared_=True}+    j <- readJournal' sample_journal_str+    (postingsReportAsText opts $ postingsReport opts (queryFromOpts date1 opts) j) `is` unlines+     ["2008/01/01 income               assets:bank:checking             $1           $1"+     ,"                                income:salary                   $-1            0"+     ,"2008/06/01 gift                 assets:bank:checking             $1           $1"+     ,"                                income:gifts                    $-1            0"+     ,"2008/06/02 save                 assets:bank:saving               $1           $1"+     ,"                                assets:bank:checking            $-1            0"+     ]++  ,"postings report sorts by date" ~:+   do +    j <- readJournal' $ unlines+        ["2008/02/02 a"+        ,"  b  1"+        ,"  c"+        ,""+        ,"2008/01/01 d"+        ,"  e  1"+        ,"  f"+        ]+    let opts = defreportopts+    registerdates (postingsReportAsText opts $ postingsReport opts (queryFromOpts date1 opts) j) `is` ["2008/01/01","2008/02/02"]++  ,"postings report with account pattern" ~:+   do+    j <- samplejournal+    let opts = defreportopts{patterns_=["cash"]}+    (postingsReportAsText opts $ postingsReport opts (queryFromOpts date1 opts) j) `is` unlines+     ["2008/06/03 eat & shop           assets:cash                     $-2          $-2"+     ]++  ,"postings report with account pattern, case insensitive" ~:+   do +    j <- samplejournal+    let opts = defreportopts{patterns_=["cAsH"]}+    (postingsReportAsText opts $ postingsReport opts (queryFromOpts date1 opts) j) `is` unlines+     ["2008/06/03 eat & shop           assets:cash                     $-2          $-2"+     ]++  ,"postings report with display expression" ~:+   do +    j <- samplejournal+    let gives displayexpr = +            (registerdates (postingsReportAsText opts $ postingsReport opts (queryFromOpts date1 opts) j) `is`)+                where opts = defreportopts{display_=Just displayexpr}+    "d<[2008/6/2]"  `gives` ["2008/01/01","2008/06/01"]+    "d<=[2008/6/2]" `gives` ["2008/01/01","2008/06/01","2008/06/02"]+    "d=[2008/6/2]"  `gives` ["2008/06/02"]+    "d>=[2008/6/2]" `gives` ["2008/06/02","2008/06/03","2008/12/31"]+    "d>[2008/6/2]"  `gives` ["2008/06/03","2008/12/31"]++  ,"postings report with period expression" ~:+   do +    j <- samplejournal+    let periodexpr `gives` dates = do+          j' <- samplejournal+          registerdates (postingsReportAsText opts $ postingsReport opts (queryFromOpts date1 opts) j') `is` dates+              where opts = defreportopts{period_=maybePeriod date1 periodexpr}+    ""     `gives` ["2008/01/01","2008/06/01","2008/06/02","2008/06/03","2008/12/31"]+    "2008" `gives` ["2008/01/01","2008/06/01","2008/06/02","2008/06/03","2008/12/31"]+    "2007" `gives` []+    "june" `gives` ["2008/06/01","2008/06/02","2008/06/03"]+    "monthly" `gives` ["2008/01/01","2008/06/01","2008/12/01"]+    "quarterly" `gives` ["2008/01/01","2008/04/01","2008/10/01"]+    let opts = defreportopts{period_=maybePeriod date1 "yearly"}+    (postingsReportAsText opts $ postingsReport opts (queryFromOpts date1 opts) j) `is` unlines+     ["2008/01/01 - 2008/12/31         assets:bank:saving               $1           $1"+     ,"                                assets:cash                     $-2          $-1"+     ,"                                expenses:food                    $1            0"+     ,"                                expenses:supplies                $1           $1"+     ,"                                income:gifts                    $-1            0"+     ,"                                income:salary                   $-1          $-1"+     ,"                                liabilities:debts                $1            0"+     ]+    let opts = defreportopts{period_=maybePeriod date1 "quarterly"}+    registerdates (postingsReportAsText opts $ postingsReport opts (queryFromOpts date1 opts) j) `is` ["2008/01/01","2008/04/01","2008/10/01"]+    let opts = defreportopts{period_=maybePeriod date1 "quarterly",empty_=True}+    registerdates (postingsReportAsText opts $ postingsReport opts (queryFromOpts date1 opts) j) `is` ["2008/01/01","2008/04/01","2008/07/01","2008/10/01"]++  ]++  , "postings report with depth arg" ~:+   do +    j <- samplejournal+    let opts = defreportopts{depth_=Just 2}+    (postingsReportAsText opts $ postingsReport opts (queryFromOpts date1 opts) j) `is` unlines+     ["2008/01/01 income               assets:bank                      $1           $1"+     ,"                                income:salary                   $-1            0"+     ,"2008/06/01 gift                 assets:bank                      $1           $1"+     ,"                                income:gifts                    $-1            0"+     ,"2008/06/02 save                 assets:bank                      $1           $1"+     ,"                                assets:bank                     $-1            0"+     ,"2008/06/03 eat & shop           expenses:food                    $1           $1"+     ,"                                expenses:supplies                $1           $2"+     ,"                                assets:cash                     $-2            0"+     ,"2008/12/31 pay off              liabilities:debts                $1           $1"+     ,"                                assets:bank                     $-1            0"+     ]++-}+ ]+ totallabel = "Total" balancelabel = "Balance"  -- | Generate postings report line items.-postingsReportItems :: [Posting] -> Posting -> MixedAmount -> (MixedAmount -> MixedAmount -> MixedAmount) -> [PostingsReportItem]-postingsReportItems [] _ _ _ = []-postingsReportItems (p:ps) pprev b sumfn = i:(postingsReportItems ps p b' sumfn)+postingsReportItems :: [Posting] -> Posting -> Int -> MixedAmount -> (MixedAmount -> MixedAmount -> MixedAmount) -> [PostingsReportItem]+postingsReportItems [] _ _ _ _ = []+postingsReportItems (p:ps) pprev d b sumfn = i:(postingsReportItems ps p d b' sumfn)     where-      i = mkpostingsReportItem isfirst p b'+      i = mkpostingsReportItem isfirst p' b'+      p' = p{paccount=clipAccountName d $ paccount p}       isfirst = ptransaction p /= ptransaction pprev       b' = b `sumfn` pamount p --- | Generate one postings report line item, from a flag indicating--- whether to include transaction info, a posting, and the current running--- balance.+-- | Generate one postings report line item, given a flag indicating+-- whether to include transaction info, the posting, and the current+-- running balance. mkpostingsReportItem :: Bool -> Posting -> MixedAmount -> PostingsReportItem mkpostingsReportItem False p b = (Nothing, p, b) mkpostingsReportItem True p b = (ds, p, b)@@ -305,26 +505,31 @@  where   compareop = choice $ map (try . string) ["<=",">=","==","<","=",">"] --- | Clip the account names to the specified depth in a list of postings.-depthClipPostings :: Maybe Int -> [Posting] -> [Posting]-depthClipPostings depth = map (depthClipPosting depth)+-- -- | Clip the account names to the specified depth in a list of postings.+-- depthClipPostings :: Maybe Int -> [Posting] -> [Posting]+-- depthClipPostings depth = map (depthClipPosting depth) --- | Clip a posting's account name to the specified depth.-depthClipPosting :: Maybe Int -> Posting -> Posting-depthClipPosting Nothing p = p-depthClipPosting (Just d) p@Posting{paccount=a} = p{paccount=clipAccountName d a}+-- -- | Clip a posting's account name to the specified depth.+-- depthClipPosting :: Maybe Int -> Posting -> Posting+-- depthClipPosting Nothing p = p+-- depthClipPosting (Just d) p@Posting{paccount=a} = p{paccount=clipAccountName d a}  -- XXX confusing, refactor  -- | Convert a list of postings into summary postings. Summary postings -- are one per account per interval and aggregated to the specified depth -- if any.-summarisePostingsByInterval :: Interval -> Maybe Int -> Bool -> DateSpan -> [Posting] -> [Posting]+summarisePostingsByInterval :: Interval -> Int -> Bool -> DateSpan -> [Posting] -> [Posting] summarisePostingsByInterval interval depth empty reportspan ps = concatMap summarisespan $ splitSpan interval reportspan     where       summarisespan s = summarisePostingsInDateSpan s depth empty (postingsinspan s)       postingsinspan s = filter (isPostingInDateSpan s) ps +tests_summarisePostingsByInterval = [+  "summarisePostingsByInterval" ~: do+    summarisePostingsByInterval (Quarters 1) 99999 False (DateSpan Nothing Nothing) [] ~?= []+ ]+ -- | Given a date span (representing a reporting interval) and a list of -- postings within it: aggregate the postings so there is only one per -- account, and adjust their date/description so that they will render@@ -338,7 +543,7 @@ -- -- The showempty flag includes spans with no postings and also postings -- with 0 amount.-summarisePostingsInDateSpan :: DateSpan -> Maybe Int -> Bool -> [Posting] -> [Posting]+summarisePostingsInDateSpan :: DateSpan -> Int -> Bool -> [Posting] -> [Posting] summarisePostingsInDateSpan (DateSpan b e) depth showempty ps     | null ps && (isNothing b || isNothing e) = []     | null ps && showempty = [summaryp]@@ -354,9 +559,8 @@       anames = sort $ nub $ map paccount ps       -- aggregate balances by account, like journalToLedger, then do depth-clipping       (_,_,exclbalof,inclbalof) = groupPostings ps-      clippedanames = nub $ map (clipAccountName d) anames-      isclipped a = accountNameLevel a >= d-      d = fromMaybe 99999 $ depth+      clippedanames = nub $ map (clipAccountName depth) anames+      isclipped a = accountNameLevel a >= depth       balancetoshowfor a =           (if isclipped a then inclbalof else exclbalof) (if null a then "top" else a) @@ -384,10 +588,10 @@  -- | Select transactions from the whole journal for a transactions report, -- with no \"current\" account. The end result is similar to--- "postingsReport" except it uses matchers and transaction-based report+-- "postingsReport" except it uses queries and transaction-based report -- items and the items are most recent first. Used by eg hledger-web's -- journal view.-journalTransactionsReport :: ReportOpts -> Journal -> Matcher -> TransactionsReport+journalTransactionsReport :: ReportOpts -> Journal -> Query -> TransactionsReport journalTransactionsReport _ Journal{jtxns=ts} m = (totallabel, items)    where      ts' = sortBy (comparing tdate) $ filter (not . null . tpostings) $ map (filterTransactionPostings m) ts@@ -410,44 +614,44 @@ -- Currently, reporting intervals are not supported, and report items are -- most recent first. Used by eg hledger-web's account register view. ---accountTransactionsReport :: ReportOpts -> Journal -> Matcher -> Matcher -> TransactionsReport-accountTransactionsReport opts j m thisacctmatcher = (label, items)+accountTransactionsReport :: ReportOpts -> Journal -> Query -> Query -> TransactionsReport+accountTransactionsReport opts j m thisacctquery = (label, items)  where      -- transactions affecting this account, in date order-     ts = sortBy (comparing tdate) $ filter (matchesTransaction thisacctmatcher) $ jtxns $+     ts = sortBy (comparing tdate) $ filter (matchesTransaction thisacctquery) $ jtxns $           journalSelectingDateFromOpts opts $ journalSelectingAmountFromOpts opts j      -- starting balance: if we are filtering by a start date and nothing else,      -- the sum of postings to this account before that date; otherwise zero.-     (startbal,label) | matcherIsNull m                           = (nullmixedamt,        balancelabel)-                      | matcherIsStartDateOnly (effective_ opts) m = (sumPostings priorps, balancelabel)+     (startbal,label) | queryIsNull m                           = (nullmixedamt,        balancelabel)+                      | queryIsStartDateOnly (effective_ opts) m = (sumPostings priorps, balancelabel)                       | otherwise                                 = (nullmixedamt,        totallabel)                       where                         priorps = -- ltrace "priorps" $                                   filter (matchesPosting                                           (-- ltrace "priormatcher" $-                                           MatchAnd [thisacctmatcher, tostartdatematcher]))+                                           And [thisacctquery, tostartdatequery]))                                          $ transactionsPostings ts-                        tostartdatematcher = MatchDate (DateSpan Nothing startdate)-                        startdate = matcherStartDate (effective_ opts) m-     items = reverse $ accountTransactionsReportItems m (Just thisacctmatcher) startbal negate ts+                        tostartdatequery = Date (DateSpan Nothing startdate)+                        startdate = queryStartDate (effective_ opts) m+     items = reverse $ accountTransactionsReportItems m (Just thisacctquery) startbal negate ts  -- | Generate transactions report items from a list of transactions,--- using the provided query and current account matchers, starting balance,+-- using the provided query and current account queries, starting balance, -- sign-setting function and balance-summing function.-accountTransactionsReportItems :: Matcher -> Maybe Matcher -> MixedAmount -> (MixedAmount -> MixedAmount) -> [Transaction] -> [TransactionsReportItem]+accountTransactionsReportItems :: Query -> Maybe Query -> MixedAmount -> (MixedAmount -> MixedAmount) -> [Transaction] -> [TransactionsReportItem] accountTransactionsReportItems _ _ _ _ [] = []-accountTransactionsReportItems matcher thisacctmatcher bal signfn (t:ts) =+accountTransactionsReportItems query thisacctquery bal signfn (t:ts) =     -- This is used for both accountTransactionsReport and journalTransactionsReport,     -- which makes it a bit overcomplicated     case i of Just i' -> i':is               Nothing -> is     where-      tmatched@Transaction{tpostings=psmatched} = filterTransactionPostings matcher t-      (psthisacct,psotheracct) = case thisacctmatcher of Just m  -> partition (matchesPosting m) psmatched-                                                         Nothing -> ([],psmatched)+      tmatched@Transaction{tpostings=psmatched} = filterTransactionPostings query t+      (psthisacct,psotheracct) = case thisacctquery of Just m  -> partition (matchesPosting m) psmatched+                                                       Nothing -> ([],psmatched)       numotheraccts = length $ nub $ map paccount psotheracct       amt = negate $ sum $ map pamount psthisacct-      acct | isNothing thisacctmatcher = summarisePostings psmatched -- journal register+      acct | isNothing thisacctquery = summarisePostings psmatched -- journal register            | numotheraccts == 0 = "transfer between " ++ summarisePostingAccounts psthisacct            | otherwise          = prefix              ++ summarisePostingAccounts psotheracct            where prefix = maybe "" (\b -> if b then "from " else "to ") $ isNegativeMixedAmount amt@@ -457,7 +661,7 @@                  where                   a = signfn amt                   b = bal + a-      is = accountTransactionsReportItems matcher thisacctmatcher bal' signfn ts+      is = accountTransactionsReportItems query thisacctquery bal' signfn ts  -- | Generate a short readable summary of some postings, like -- "from (negatives) to (positives)".@@ -474,7 +678,7 @@ summarisePostingAccounts :: [Posting] -> String summarisePostingAccounts = intercalate ", " . map accountLeafName . nub . map paccount -filterTransactionPostings :: Matcher -> Transaction -> Transaction+filterTransactionPostings :: Query -> Transaction -> Transaction filterTransactionPostings m t@Transaction{tpostings=ps} = t{tpostings=filter (m `matchesPosting`) ps}  -------------------------------------------------------------------------------@@ -491,29 +695,18 @@                           ,MixedAmount) -- account balance, includes subs unless --flat is present  -- | Select accounts, and get their balances at the end of the selected--- period, and misc. display information, for an accounts report. Used by--- eg hledger's balance command.-accountsReport :: ReportOpts -> FilterSpec -> Journal -> AccountsReport-accountsReport opts filterspec j = accountsReport' opts j (journalToLedger filterspec)---- | Select accounts, and get their balances at the end of the selected--- period, and misc. display information, for an accounts report. Like--- "accountsReport" but uses the new matchers. Used by eg hledger-web's--- accounts sidebar.-accountsReport2 :: ReportOpts -> Matcher -> Journal -> AccountsReport-accountsReport2 opts matcher j = accountsReport' opts j (journalToLedger2 matcher)---- Accounts report helper.-accountsReport' :: ReportOpts -> Journal -> (Journal -> Ledger) -> AccountsReport-accountsReport' opts j jtol = (items, total)+-- period, and misc. display information, for an accounts report.+accountsReport :: ReportOpts -> Query -> Journal -> AccountsReport+accountsReport opts query j = (items, total)      where-      items = map mkitem interestingaccts+      -- don't do depth filtering until the end+      q' = filterQuery (not . queryIsDepth) query+      l =  journalToLedger q' $ journalSelectingDateFromOpts opts $ journalSelectingAmountFromOpts opts j+      acctnames = filter (query `matchesAccount`) $ journalAccountNames j       interestingaccts | no_elide_ opts = acctnames                        | otherwise = filter (isInteresting opts l) acctnames-      acctnames = sort $ tail $ flatten $ treemap aname accttree-      accttree = ledgerAccountTree (fromMaybe 99999 $ depth_ opts) l+      items = map mkitem interestingaccts       total = sum $ map abalance $ ledgerTopAccounts l-      l =  jtol $ journalSelectingDateFromOpts opts $ journalSelectingAmountFromOpts opts j        -- | Get data for one balance report line item.       mkitem :: AccountName -> AccountsReportItem@@ -530,6 +723,223 @@                  | otherwise = abalance acct                  where acct = ledgerAccount l a +tests_accountsReport = [+  "accountsReport" ~: do+   let (opts,journal) `gives` r = do+         let (eitems, etotal) = r+             (aitems, atotal) = accountsReport opts (queryFromOpts nulldate opts) journal+         assertEqual "items" eitems aitems+         -- assertEqual "" (length eitems) (length aitems)+         -- mapM (\(e,a) -> assertEqual "" e a) $ zip eitems aitems+         assertEqual "total" etotal atotal+          +   -- "accounts report with no args" ~:+   (defreportopts, nulljournal) `gives` ([], Mixed [nullamt])+   (defreportopts, samplejournal) `gives`+    ([+      ("assets","assets",0, amount' "$-1.00")+     ,("assets:bank:saving","bank:saving",1, amount' "$1.00")+     ,("assets:cash","cash",1, amount' "$-2.00")+     ,("expenses","expenses",0, amount' "$2.00")+     ,("expenses:food","food",1, amount' "$1.00")+     ,("expenses:supplies","supplies",1, amount' "$1.00")+     ,("income","income",0, amount' "$-2.00")+     ,("income:gifts","gifts",1, amount' "$-1.00")+     ,("income:salary","salary",1, amount' "$-1.00")+     ,("liabilities:debts","liabilities:debts",0, amount' "$1.00")+     ],+     Mixed [nullamt])++   -- "accounts report can be limited with --depth=N" ~:+   (defreportopts{depth_=Just 1}, samplejournal) `gives`+    ([+      ("assets",      "assets",      0, amount' "$-1.00")+     ,("expenses",    "expenses",    0, amount'  "$2.00")+     ,("income",      "income",      0, amount' "$-2.00")+     ,("liabilities", "liabilities", 0, amount'  "$1.00")+     ],+     Mixed [nullamt])++   -- or with depth:N+   (defreportopts{query_="depth:1"}, samplejournal) `gives`+    ([+      ("assets",      "assets",      0, amount' "$-1.00")+     ,("expenses",    "expenses",    0, amount'  "$2.00")+     ,("income",      "income",      0, amount' "$-2.00")+     ,("liabilities", "liabilities", 0, amount'  "$1.00")+     ],+     Mixed [nullamt])++   -- with a date span+   (defreportopts{query_="date:'in 2009'"}, samplejournal2) `gives`+    ([],+     Mixed [nullamt])+   (defreportopts{query_="edate:'in 2009'"}, samplejournal2) `gives`+    ([+      ("assets:bank:checking","assets:bank:checking",0,amount' "$1.00")+     ,("income:salary","income:salary",0,amount' "$-1.00")+     ],+     Mixed [nullamt])+++{-+    ,"accounts report with account pattern o" ~:+     defreportopts{patterns_=["o"]} `gives`+     ["                  $1  expenses:food"+     ,"                 $-2  income"+     ,"                 $-1    gifts"+     ,"                 $-1    salary"+     ,"--------------------"+     ,"                 $-1"+     ]++    ,"accounts report with account pattern o and --depth 1" ~:+     defreportopts{patterns_=["o"],depth_=Just 1} `gives`+     ["                  $1  expenses"+     ,"                 $-2  income"+     ,"--------------------"+     ,"                 $-1"+     ]++    ,"accounts report with account pattern a" ~:+     defreportopts{patterns_=["a"]} `gives`+     ["                 $-1  assets"+     ,"                  $1    bank:saving"+     ,"                 $-2    cash"+     ,"                 $-1  income:salary"+     ,"                  $1  liabilities:debts"+     ,"--------------------"+     ,"                 $-1"+     ]++    ,"accounts report with account pattern e" ~:+     defreportopts{patterns_=["e"]} `gives`+     ["                 $-1  assets"+     ,"                  $1    bank:saving"+     ,"                 $-2    cash"+     ,"                  $2  expenses"+     ,"                  $1    food"+     ,"                  $1    supplies"+     ,"                 $-2  income"+     ,"                 $-1    gifts"+     ,"                 $-1    salary"+     ,"                  $1  liabilities:debts"+     ,"--------------------"+     ,"                   0"+     ]++    ,"accounts report with unmatched parent of two matched subaccounts" ~: +     defreportopts{patterns_=["cash","saving"]} `gives`+     ["                 $-1  assets"+     ,"                  $1    bank:saving"+     ,"                 $-2    cash"+     ,"--------------------"+     ,"                 $-1"+     ]++    ,"accounts report with multi-part account name" ~: +     defreportopts{patterns_=["expenses:food"]} `gives`+     ["                  $1  expenses:food"+     ,"--------------------"+     ,"                  $1"+     ]++    ,"accounts report with negative account pattern" ~:+     defreportopts{patterns_=["not:assets"]} `gives`+     ["                  $2  expenses"+     ,"                  $1    food"+     ,"                  $1    supplies"+     ,"                 $-2  income"+     ,"                 $-1    gifts"+     ,"                 $-1    salary"+     ,"                  $1  liabilities:debts"+     ,"--------------------"+     ,"                  $1"+     ]++    ,"accounts report negative account pattern always matches full name" ~: +     defreportopts{patterns_=["not:e"]} `gives`+     ["--------------------"+     ,"                   0"+     ]++    ,"accounts report negative patterns affect totals" ~: +     defreportopts{patterns_=["expenses","not:food"]} `gives`+     ["                  $1  expenses:supplies"+     ,"--------------------"+     ,"                  $1"+     ]++    ,"accounts report with -E shows zero-balance accounts" ~:+     defreportopts{patterns_=["assets"],empty_=True} `gives`+     ["                 $-1  assets"+     ,"                  $1    bank"+     ,"                   0      checking"+     ,"                  $1      saving"+     ,"                 $-2    cash"+     ,"--------------------"+     ,"                 $-1"+     ]++    ,"accounts report with cost basis" ~: do+       j <- (readJournal Nothing Nothing Nothing $ unlines+              [""+              ,"2008/1/1 test           "+              ,"  a:b          10h @ $50"+              ,"  c:d                   "+              ]) >>= either error' return+       let j' = journalCanonicaliseAmounts $ journalConvertAmountsToCost j -- enable cost basis adjustment+       accountsReportAsText defreportopts (accountsReport defreportopts Any j') `is`+         ["                $500  a:b"+         ,"               $-500  c:d"+         ,"--------------------"+         ,"                   0"+         ]+-}+ ]++Right samplejournal2 = journalBalanceTransactions $ Journal+          [] +          [] +          [+           txnTieKnot $ Transaction {+             tdate=parsedate "2008/01/01",+             teffectivedate=Just $ parsedate "2009/01/01",+             tstatus=False,+             tcode="",+             tdescription="income",+             tcomment="",+             ttags=[],+             tpostings=[+              Posting {+                pstatus=False,+                paccount="assets:bank:checking",+                pamount=(Mixed [dollars 1]),+                pcomment="",+                ptype=RegularPosting,+                ptags=[],+                ptransaction=Nothing+              },+              Posting {+                pstatus=False,+                paccount="income:salary",+                pamount=(missingmixedamt),+                pcomment="",+                ptype=RegularPosting,+                ptags=[],+                ptransaction=Nothing+              }+             ],+             tpreceding_comment_lines=""+           }+          ]+          []+          []+          ""+          nullctx+          []+          (TOD 0 0)+ exclusiveBalance :: Account -> MixedAmount exclusiveBalance = sumPostings . apostings @@ -555,7 +965,7 @@     | numinterestingsubs < 2 && zerobalance && not emptyflag = False     | otherwise = True     where-      atmaxdepth = isJust d && Just (accountNameLevel a) == d where d = depth_ opts+      atmaxdepth = accountNameLevel a == depthFromOpts opts       emptyflag = empty_ opts       acct = ledgerAccount l a       zerobalance = isZeroMixedAmount inclbalance where inclbalance = abalance acct@@ -565,15 +975,29 @@             isInterestingTree = treeany (isInteresting opts l . aname)             subtrees = map (fromJust . ledgerAccountTreeAt l) $ ledgerSubAccounts l $ ledgerAccount l a --------------------------------------------------------------------------------+tests_isInterestingIndented = [+  "isInterestingIndented" ~: do +   let (opts, journal, acctname) `gives` r = isInterestingIndented opts l acctname `is` r+          where l = journalToLedger (queryFromOpts nulldate opts) journal+     +   (defreportopts, samplejournal, "expenses") `gives` True+ ] -tests_Hledger_Reports :: Test-tests_Hledger_Reports = TestList- [+depthFromOpts :: ReportOpts -> Int+depthFromOpts opts = min (fromMaybe 99999 $ depth_ opts) (queryDepth $ queryFromOpts nulldate opts) -  "summarisePostingsByInterval" ~: do-    summarisePostingsByInterval (Quarters 1) Nothing False (DateSpan Nothing Nothing) [] ~?= []+------------------------------------------------------------------------------- +tests_Hledger_Reports :: Test+tests_Hledger_Reports = TestList $+    tests_queryFromOpts+ ++ tests_queryOptsFromOpts+ ++ tests_entriesReport+ ++ tests_summarisePostingsByInterval+ ++ tests_postingsReport+ ++ tests_isInterestingIndented+ ++ tests_accountsReport+ ++ [   -- ,"summarisePostingsInDateSpan" ~: do   --   let gives (b,e,depth,showempty,ps) =   --           (summarisePostingsInDateSpan (mkdatespan b e) depth showempty ps `is`)
Hledger/Utils.hs view
@@ -1,3 +1,4 @@+{-# LANGUAGE CPP #-} {-|  Standard imports and utilities which are useful everywhere, or needed low@@ -19,12 +20,13 @@                           -- module Text.Printf,                           ---- all of this one:                           module Hledger.Utils,-                          Debug.Trace.trace-                          ---- and this for i18n - needs to be done in each module I think:-                          -- module Hledger.Utils.UTF8+                          Debug.Trace.trace,+                          -- module Hledger.Utils.UTF8IOCompat+                          SystemString,fromSystemString,toSystemString,error',userError'+                          -- the rest need to be done in each module I think                           ) where-import Codec.Binary.UTF8.String as UTF8 (decodeString, encodeString, isUTF8Encoded)+import Control.Monad.Error import Data.Char import Data.List import Data.Maybe@@ -32,15 +34,17 @@ import Data.Time.LocalTime import Data.Tree import Debug.Trace-import System.Info (os)+import System.Directory (getHomeDirectory)+import System.FilePath(takeDirectory,combine) import Test.HUnit import Text.ParserCombinators.Parsec import Text.Printf import Text.RegexPR -- import qualified Data.Map as Map -- --- import Prelude hiding (readFile,writeFile,getContents,putStr,putStrLn)--- import Hledger.Utils.UTF8+-- import Prelude hiding (readFile,writeFile,appendFile,getContents,putStr,putStrLn)+-- import Hledger.Utils.UTF8IOCompat   (readFile,writeFile,appendFile,getContents,putStr,putStrLn)+import Hledger.Utils.UTF8IOCompat (SystemString,fromSystemString,toSystemString,error',userError')  -- strings @@ -70,8 +74,13 @@ quoteIfSpaced s | isSingleQuoted s || isDoubleQuoted s = s                 | not $ any (`elem` s) whitespacechars = s                 | otherwise = "'"++escapeSingleQuotes s++"'"-                  where escapeSingleQuotes = regexReplace "'" "\'" +escapeSingleQuotes :: String -> String+escapeSingleQuotes = regexReplace "'" "\'"++escapeQuotes :: String -> String+escapeQuotes = regexReplace "([\"'])" "\\1"+ -- | Quote-aware version of words - don't split on spaces which are inside quotes. -- NB correctly handles "a'b" but not "''a''". words' :: String -> [String]@@ -87,6 +96,7 @@ unwords' :: [String] -> String unwords' = unwords . map singleQuoteIfNeeded +-- | Single-quote this string if it contains whitespace or double-quotes singleQuoteIfNeeded s | any (`elem` s) whitespacechars = "'"++s++"'"                       | otherwise = s @@ -180,41 +190,6 @@       fit w = take w . (++ repeat ' ')       blankline = replicate w ' ' --- encoded platform strings---- | A platform string is a string value from or for the operating system,--- such as a file path or command-line argument (or environment variable's--- name or value ?). On some platforms (such as unix) these are not real--- unicode strings but have some encoding such as UTF-8. This alias does--- no type enforcement but aids code clarity.-type PlatformString = String---- | Convert a possibly encoded platform string to a real unicode string.--- We decode the UTF-8 encoding recommended for unix systems--- (cf http://www.dwheeler.com/essays/fixing-unix-linux-filenames.html)--- and leave anything else unchanged.-fromPlatformString :: PlatformString -> String-fromPlatformString s = if UTF8.isUTF8Encoded s then UTF8.decodeString s else s---- | Convert a unicode string to a possibly encoded platform string.--- On unix we encode with the recommended UTF-8--- (cf http://www.dwheeler.com/essays/fixing-unix-linux-filenames.html)--- and elsewhere we leave it unchanged.-toPlatformString :: String -> PlatformString-toPlatformString = case os of-                     "unix" -> UTF8.encodeString-                     "linux" -> UTF8.encodeString-                     "darwin" -> UTF8.encodeString-                     _ -> id---- | A version of error that's better at displaying unicode.-error' :: String -> a-error' = error . toPlatformString---- | A version of userError that's better at displaying unicode.-userError' :: String -> IOError-userError' = userError . toPlatformString- -- math  difforzero :: (Num a, Ord a) => a -> a -> a@@ -378,21 +353,21 @@ -- testing  -- | Get a Test's label, or the empty string.-tname :: Test -> String-tname (TestLabel n _) = n-tname _ = ""+testName :: Test -> String+testName (TestLabel n _) = n+testName _ = ""  -- | Flatten a Test containing TestLists into a list of single tests.-tflatten :: Test -> [Test]-tflatten (TestLabel _ t@(TestList _)) = tflatten t-tflatten (TestList ts) = concatMap tflatten ts-tflatten t = [t]+flattenTests :: Test -> [Test]+flattenTests (TestLabel _ t@(TestList _)) = flattenTests t+flattenTests (TestList ts) = concatMap flattenTests ts+flattenTests t = [t]  -- | Filter TestLists in a Test, recursively, preserving the structure.-tfilter :: (Test -> Bool) -> Test -> Test-tfilter p (TestLabel l ts) = TestLabel l (tfilter p ts)-tfilter p (TestList ts) = TestList $ filter (any p . tflatten) $ map (tfilter p) ts-tfilter _ t = t+filterTests :: (Test -> Bool) -> Test -> Test+filterTests p (TestLabel l ts) = TestLabel l (filterTests p ts)+filterTests p (TestList ts) = TestList $ filter (any p . flattenTests) $ map (filterTests p) ts+filterTests _ t = t  -- | Simple way to assert something is some expected value, with no label. is :: (Eq a, Show a) => a -> a -> Assertion@@ -425,3 +400,18 @@ -- | Apply a function the specified number of times. Possibly uses O(n) stack ? applyN :: Int -> (a -> a) -> a -> a applyN n f = (!! n) . iterate f++-- | Convert a possibly relative, possibly tilde-containing file path to an absolute one.+-- using the current directory from a parsec source position. ~username is not supported.+expandPath :: (MonadIO m) => SourcePos -> FilePath -> m FilePath+expandPath pos fp = liftM mkAbsolute (expandHome fp)+  where+    mkAbsolute = combine (takeDirectory (sourceName pos))+    expandHome inname | "~/" `isPrefixOf` inname = do homedir <- liftIO getHomeDirectory+                                                      return $ homedir ++ drop 1 inname+                      | otherwise                = return inname++firstJust ms = case dropWhile (==Nothing) ms of+    [] -> Nothing+    (md:_) -> md+
− Hledger/Utils/UTF8.hs
@@ -1,87 +0,0 @@-{--From pandoc, slightly extended. Example usage:-- import Prelude hiding (readFile,writeFile,getContents,putStr,putStrLn)- import Hledger.Utils.UTF8 (readFile,writeFile,getContents,putStr,putStrLn)--------------------------------------------------------------------------Copyright (C) 2010 John MacFarlane <jgm@berkeley.edu>--This program is free software; you can redistribute it and/or modify-it under the terms of the GNU General Public License as published by-the Free Software Foundation; either version 2 of the License, or-(at your option) any later version.--This program is distributed in the hope that it will be useful,-but WITHOUT ANY WARRANTY; without even the implied warranty of-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the-GNU General Public License for more details.--You should have received a copy of the GNU General Public License-along with this program; if not, write to the Free Software-Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA--}--{- |-   Module      : Text.Pandoc.UTF8-   Copyright   : Copyright (C) 2010 John MacFarlane-   License     : GNU GPL, version 2 or above --   Maintainer  : John MacFarlane <jgm@berkeley.edu>-   Stability   : alpha-   Portability : portable--UTF-8 aware string IO functions that will work with GHC 6.10 or 6.12.--}-module Hledger.Utils.UTF8 ( readFile-                         , writeFile-                         , appendFile-                         , getContents-                         , hGetContents-                         , putStr-                         , putStrLn-                         , hPutStr-                         , hPutStrLn-                         )--where-import qualified Data.ByteString.Lazy as B-import Data.ByteString.Lazy.UTF8 (toString, fromString)-import Prelude hiding (readFile, writeFile, appendFile, getContents, putStr, putStrLn)-import System.IO (Handle)-import Control.Monad (liftM)--bom :: B.ByteString-bom = B.pack [0xEF, 0xBB, 0xBF]--stripBOM :: B.ByteString -> B.ByteString-stripBOM s | bom `B.isPrefixOf` s = B.drop 3 s-stripBOM s = s--readFile :: FilePath -> IO String-readFile = liftM (toString . stripBOM) . B.readFile--writeFile :: FilePath -> String -> IO ()-writeFile f = B.writeFile f . fromString--appendFile :: FilePath -> String -> IO ()-appendFile f = B.appendFile f . fromString--getContents :: IO String-getContents = liftM (toString . stripBOM) B.getContents--hGetContents :: Handle -> IO String-hGetContents h = liftM (toString . stripBOM) (B.hGetContents h)--putStr :: String -> IO ()-putStr = B.putStr . fromString--putStrLn :: String -> IO ()-putStrLn = B.putStrLn . fromString--hPutStr :: Handle -> String -> IO ()-hPutStr h = B.hPutStr h . fromString--hPutStrLn :: Handle -> String -> IO ()-hPutStrLn h s = hPutStr h (s ++ "\n")
+ Hledger/Utils/UTF8IOCompat.hs view
@@ -0,0 +1,122 @@+{-# LANGUAGE CPP #-}+{- |++UTF-8 aware string IO functions that will work across multiple platforms+and GHC versions. Includes code from Text.Pandoc.UTF8 ((C) 2010 John+MacFarlane).++Example usage:++ import Prelude hiding (readFile,writeFile,appendFile,getContents,putStr,putStrLn)+ import UTF8IOCompat   (readFile,writeFile,appendFile,getContents,putStr,putStrLn)+ import UTF8IOCompat   (SystemString,fromSystemString,toSystemString,error',userError')++-}++module Hledger.Utils.UTF8IOCompat (+  readFile,+  writeFile,+  appendFile,+  getContents,+  hGetContents,+  putStr,+  putStrLn,+  hPutStr,+  hPutStrLn,+  --+  SystemString,+  fromSystemString,+  toSystemString,+  error',+  userError',+)+where++import Control.Monad (liftM)+import qualified Data.ByteString.Lazy as B+import qualified Data.ByteString.Lazy.Char8 as B8+import qualified Data.ByteString.Lazy.UTF8 as U8 (toString, fromString)+import Prelude hiding (readFile, writeFile, appendFile, getContents, putStr, putStrLn)+import System.IO (Handle)+#if __GLASGOW_HASKELL__ < 702+import Codec.Binary.UTF8.String as UTF8 (decodeString, encodeString, isUTF8Encoded)+import System.Info (os)+#endif++bom :: B.ByteString+bom = B.pack [0xEF, 0xBB, 0xBF]++stripBOM :: B.ByteString -> B.ByteString+stripBOM s | bom `B.isPrefixOf` s = B.drop 3 s+stripBOM s = s++readFile :: FilePath -> IO String+readFile = liftM (U8.toString . stripBOM) . B.readFile++writeFile :: FilePath -> String -> IO ()+writeFile f = B.writeFile f . U8.fromString++appendFile :: FilePath -> String -> IO ()+appendFile f = B.appendFile f . U8.fromString++getContents :: IO String+getContents = liftM (U8.toString . stripBOM) B.getContents++hGetContents :: Handle -> IO String+hGetContents h = liftM (U8.toString . stripBOM) (B.hGetContents h)++putStr :: String -> IO ()+putStr = bs_putStr . U8.fromString++putStrLn :: String -> IO ()+putStrLn = bs_putStrLn . U8.fromString++hPutStr :: Handle -> String -> IO ()+hPutStr h = bs_hPutStr h . U8.fromString++hPutStrLn :: Handle -> String -> IO ()+hPutStrLn h = bs_hPutStrLn h . U8.fromString++-- span GHC versions including 6.12.3 - 7.4.1:+bs_putStr         = B8.putStr+bs_putStrLn       = B8.putStrLn+bs_hPutStr        = B8.hPut+bs_hPutStrLn h bs = B8.hPut h bs >> B8.hPut h (B.singleton 0x0a)+++-- | A string received from or being passed to the operating system, such+-- as a file path, command-line argument, or environment variable name or+-- value. With GHC versions before 7.2 on some platforms (posix) these are+-- typically encoded. When converting, we assume the encoding is UTF-8 (cf+-- <http://www.dwheeler.com/essays/fixing-unix-linux-filenames.html#UTF8>).+type SystemString = String++-- | Convert a system string to an ordinary string, decoding from UTF-8 if+-- it appears to be UTF8-encoded and GHC version is less than 7.2.+fromSystemString :: SystemString -> String+#if __GLASGOW_HASKELL__ < 702+fromSystemString s = if UTF8.isUTF8Encoded s then UTF8.decodeString s else s+#else+fromSystemString = id+#endif++-- | Convert a unicode string to a system string, encoding with UTF-8 if+-- we are on a posix platform with GHC < 7.2.+toSystemString :: String -> SystemString+#if __GLASGOW_HASKELL__ < 702+toSystemString = case os of+                     "unix" -> UTF8.encodeString+                     "linux" -> UTF8.encodeString+                     "darwin" -> UTF8.encodeString+                     _ -> id+#else+toSystemString = id+#endif++-- | A SystemString-aware version of error.+error' :: String -> a+error' = error . toSystemString++-- | A SystemString-aware version of userError.+userError' :: String -> IOError+userError' = userError . toSystemString
hledger-lib.cabal view
@@ -1,5 +1,5 @@ name:           hledger-lib-version: 0.17+version: 0.18 category:       Finance synopsis:       Core data types, parsers and utilities for the hledger accounting tool. description:@@ -17,8 +17,8 @@ homepage:       http://hledger.org bug-reports:    http://code.google.com/p/hledger/issues stability:      beta-tested-with:    GHC==7.0, GHC==7.2-cabal-version:  >= 1.6+tested-with:    GHC==7.0.4, GHC==7.2.2, GHC==7.4.1+cabal-version:  >= 1.8 build-type:     Simple -- data-dir:       data -- data-files:@@ -39,25 +39,27 @@                   Hledger.Data.Amount                   Hledger.Data.Commodity                   Hledger.Data.Dates-                  Hledger.Data.Transaction+                  Hledger.Data.FormatStrings                   Hledger.Data.Journal                   Hledger.Data.Ledger-                  Hledger.Data.Matching                   Hledger.Data.Posting                   Hledger.Data.TimeLog+                  Hledger.Data.Transaction                   Hledger.Data.Types+                  Hledger.Query                   Hledger.Read+                  Hledger.Read.CsvReader                   Hledger.Read.JournalReader                   Hledger.Read.TimelogReader-                  Hledger.Read.Utils                   Hledger.Reports                   Hledger.Utils-                  Hledger.Utils.UTF8+                  Hledger.Utils.UTF8IOCompat   Build-Depends:                   base >= 3 && < 5                  ,bytestring                  ,cmdargs >= 0.9.1   && < 0.10                  ,containers+                 ,csv                  ,directory                  ,filepath                  ,mtl@@ -66,6 +68,7 @@                  ,parsec                  ,regexpr >= 0.5.1                  ,safe >= 0.2+                 ,shakespeare-text >= 1.0 && < 1.1                  ,split == 0.1.*                  ,time                  ,utf8-string >= 0.3.5 && < 0.4