Add hledger-check script.
This commit is contained in:
		
							parent
							
								
									0a8212bda3
								
							
						
					
					
						commit
						66e33e0b0a
					
				
							
								
								
									
										475
									
								
								bin/hledger-check.hs
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										475
									
								
								bin/hledger-check.hs
									
									
									
									
									
										Executable file
									
								
							| @ -0,0 +1,475 @@ | |||||||
|  | #!/usr/bin/env stack | ||||||
|  | {- stack runghc --verbosity info | ||||||
|  |   --package filepath | ||||||
|  |   --package hledger-lib | ||||||
|  |   --package optparse-applicative | ||||||
|  |   --package megaparsec | ||||||
|  |   --package text | ||||||
|  |   --package time | ||||||
|  |   --package transformers | ||||||
|  | -} | ||||||
|  | {- | ||||||
|  | 
 | ||||||
|  | ``` | ||||||
|  | Usage: hledger-check [-f|--file FILE] [--alias OLD=NEW] [--ignore-assertions] | ||||||
|  |                      [-b|--begin DATE] [-e|--end DATE] [-C|--cleared] | ||||||
|  |                      [--pending] [-U|--uncleared] [-R|--real] [--sunday] | ||||||
|  |                      [-D|--daily ASSERT] [-W|--weekly ASSERT] | ||||||
|  |                      [-M|--monthly ASSERT] [-Q|--quarterly ASSERT] | ||||||
|  |                      [-Y|--yearly ASSERT] [ASSERT] | ||||||
|  |   Complex account balance assertions for hledger journals. | ||||||
|  | 
 | ||||||
|  | Available options: | ||||||
|  |   -h,--help                Show this help text | ||||||
|  |   -f,--file FILE           use a different input file. For stdin, use - | ||||||
|  |   --alias OLD=NEW          display accounts named OLD as NEW | ||||||
|  |   --ignore-assertions      ignore any balance assertions in the journal | ||||||
|  |   -b,--begin DATE          include postings/txns on or after this date | ||||||
|  |   -e,--end DATE            include postings/txns before this date | ||||||
|  |   -C,--cleared             include only cleared postings/txns | ||||||
|  |   --pending                include only pending postings/txns | ||||||
|  |   -U,--uncleared           include only uncleared (and pending) postings/txns | ||||||
|  |   -R,--real                include only non-virtual postings | ||||||
|  |   --sunday                 weeks start on Sunday | ||||||
|  |   -D,--daily ASSERT        assertions that must hold at the end of the day | ||||||
|  |   -W,--weekly ASSERT       assertions that must hold at the end of the week | ||||||
|  |   -M,--monthly ASSERT      assertions that must hold at the end of the month | ||||||
|  |   -Q,--quarterly ASSERT    assertions that must hold at the end of the quarter | ||||||
|  |   -Y,--yearly ASSERT       assertions that must hold at the end of the year | ||||||
|  |   ASSERT                   assertions that must hold after every transaction | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | Comparison: `<value OR account name>  cmp  <value OR account name>` | ||||||
|  | ------------------------------------------------------------------- | ||||||
|  | 
 | ||||||
|  | In the simplest form, an assertion is just a comparison between | ||||||
|  | values. A value is either an amount or an account name (both as | ||||||
|  | defined by hledger). The comparison operators are `<`, `<=`, `==`, | ||||||
|  | `>=`, `>`, and `!=` (with the obvious meanings). | ||||||
|  | 
 | ||||||
|  | Normally, the name of an account refers to the balance of that account | ||||||
|  | only, without including subaccounts. The syntax `* AccountName` refers | ||||||
|  | to the sum of the values in both that account and its subaccounts. | ||||||
|  | 
 | ||||||
|  | **Example:** | ||||||
|  | ``` | ||||||
|  | hledger-invariant -D "budget:books  >= £0" | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | "At the end of every day, the books budget is greater than or equal to | ||||||
|  | £0", implying that if I overspend, I need to take the money out of | ||||||
|  | some other account. Note the double space after `budget:books`, this | ||||||
|  | is because account names can contain single spaces. | ||||||
|  | 
 | ||||||
|  | Combination: `<assertion>  op  <assertion>` | ||||||
|  | ------------------------------------------- | ||||||
|  | 
 | ||||||
|  | Assertions can be combined with logical connectives. The connectives | ||||||
|  | are `&&`, `||`, `==>`, and `<==>` (with the obvious meanings). | ||||||
|  | Assertions can also be wrapped inside parentheses. | ||||||
|  | 
 | ||||||
|  | **Example:** | ||||||
|  | ``` | ||||||
|  | hledger-invariant "(assets:overdraft  < £2000) ==> (*assets:checking  == £0)" | ||||||
|  | ``` | ||||||
|  | 
 | ||||||
|  | "If I have taken money from my overdraft, then I must have no money in | ||||||
|  | my checking account (including subaccounts)." | ||||||
|  | -} | ||||||
|  | module Main where | ||||||
|  | 
 | ||||||
|  | import Control.Arrow (first) | ||||||
|  | import Control.Monad (mplus, mzero, unless, void) | ||||||
|  | import Control.Monad.Trans.Class (lift) | ||||||
|  | import Control.Monad.Trans.State.Strict (runStateT) | ||||||
|  | import Data.Function (on) | ||||||
|  | import Data.Functor.Identity (Identity(..)) | ||||||
|  | import Data.List (foldl', groupBy, intercalate, nub, sortOn) | ||||||
|  | import Data.List.NonEmpty (NonEmpty(..), nonEmpty, toList) | ||||||
|  | import Data.Maybe (fromMaybe, mapMaybe) | ||||||
|  | import Data.Time.Calendar (toGregorian) | ||||||
|  | import Data.Time.Calendar.OrdinalDate (mondayStartWeek, sundayStartWeek, toOrdinalDate) | ||||||
|  | import Data.Text (isPrefixOf, pack, unpack) | ||||||
|  | import qualified Hledger.Data as H | ||||||
|  | import qualified Hledger.Query as H | ||||||
|  | import qualified Hledger.Read as H | ||||||
|  | import qualified Hledger.Utils.Parse as H | ||||||
|  | import Options.Applicative | ||||||
|  | import System.Exit (exitFailure) | ||||||
|  | import System.FilePath (FilePath) | ||||||
|  | import qualified Text.Megaparsec as P | ||||||
|  | import qualified Text.Megaparsec.Text as P | ||||||
|  | 
 | ||||||
|  | main :: IO () | ||||||
|  | main = do | ||||||
|  |     opts <- execParser args | ||||||
|  |     journalFile <- maybe H.defaultJournalPath pure (file opts) | ||||||
|  |     ejournal    <- H.readJournalFile Nothing Nothing (not $ ignoreAssertions opts) journalFile | ||||||
|  |     case ejournal of | ||||||
|  |       Right j -> do | ||||||
|  |         (journal, starting) <- fixupJournal opts j | ||||||
|  |         let postings = H.journalPostings journal | ||||||
|  |         b1 <- checkAssertions starting (assertionsAlways    opts) (groupByNE ((==) `on` H.ptransaction) postings) | ||||||
|  |         b2 <- checkAssertions starting (assertionsDaily     opts) (groupByNE sameDay     postings) | ||||||
|  |         b3 <- checkAssertions starting (assertionsWeekly    opts) (groupByNE (sameWeek (sunday opts))   postings) | ||||||
|  |         b4 <- checkAssertions starting (assertionsMonthly   opts) (groupByNE sameMonth   postings) | ||||||
|  |         b5 <- checkAssertions starting (assertionsQuarterly opts) (groupByNE sameQuarter postings) | ||||||
|  |         b6 <- checkAssertions starting (assertionsYearly    opts) (groupByNE sameYear    postings) | ||||||
|  |         unless (b1 && b2 && b3 && b4 && b5 && b6) | ||||||
|  |           exitFailure | ||||||
|  |       Left err -> putStrLn err >> exitFailure | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | ------------------------------------------------------------------------------- | ||||||
|  | -- Assertions | ||||||
|  | 
 | ||||||
|  | -- | Check assertions against a collection of grouped postings: | ||||||
|  | -- assertions must hold when all postings in the group have been | ||||||
|  | -- applied. Print out errors as they are found. | ||||||
|  | checkAssertions :: [(H.AccountName, H.MixedAmount)] -> [(String, Predicate)] -> [NonEmpty H.Posting] -> IO Bool | ||||||
|  | checkAssertions balances0 asserts0 postingss | ||||||
|  |     | null failed = pure True | ||||||
|  |     | otherwise = putStrLn (intercalate "\n\n" failed) >> pure False | ||||||
|  |   where | ||||||
|  |     (_, _, failed) = foldl' applyAndCheck (balances0, asserts0, []) postingss | ||||||
|  | 
 | ||||||
|  |     -- Apply a collection of postings and check the assertions. | ||||||
|  |     applyAndCheck :: ([(H.AccountName, H.MixedAmount)], [(String, Predicate)], [String]) | ||||||
|  |                   -> NonEmpty H.Posting | ||||||
|  |                   -> ([(H.AccountName, H.MixedAmount)], [(String, Predicate)], [String]) | ||||||
|  |     applyAndCheck (starting, asserts, errs) ps = | ||||||
|  |       let ps' = toList ps | ||||||
|  |           closing  = starting `addAccounts` closingBalances' ps' | ||||||
|  |           checked  = map (\a -> (a, check (last ps') closing a)) asserts | ||||||
|  |           asserts' = [a | (a, Nothing) <- checked] | ||||||
|  |           errs'    = [e | (_, Just e)  <- checked] | ||||||
|  |       in (closing, asserts', errs ++ errs') | ||||||
|  | 
 | ||||||
|  |     -- Check an assertion against a collection of account balances, | ||||||
|  |     -- and return an error on failure. | ||||||
|  |     check :: H.Posting -> [(H.AccountName, H.MixedAmount)] -> (String, Predicate) -> Maybe String | ||||||
|  |     check lastp balances (pstr, p) | ||||||
|  |       | checkAssertion balances p = Nothing | ||||||
|  |       | otherwise = Just . unlines $ | ||||||
|  |           let after = case H.ptransaction lastp of | ||||||
|  |                 Just t  -> | ||||||
|  |                   "after transaction:\n" ++ H.showTransaction t ++ | ||||||
|  |                   "(after posting: " ++ init (H.showPosting lastp) ++ ")\n\n" | ||||||
|  |                 Nothing -> | ||||||
|  |                   "after posting:\n" ++ H.showPosting lastp | ||||||
|  | 
 | ||||||
|  |               -- Restrict to accounts mentioned in the predicate, and pretty-print balances | ||||||
|  |               balances' = map (first unpack) $ filter (flip inAssertion p . fst) balances | ||||||
|  |               maxalen   = maximum $ map (length . fst) balances' | ||||||
|  |               accounts = [ a <> padding <> show m | ||||||
|  |                          | (a,m) <- balances' | ||||||
|  |                          , let padding = replicate (2 + maxalen - length a) ' ' | ||||||
|  |                          ] | ||||||
|  |           in [ "assertion '" ++ pstr ++ "' violated", after ++ "relevant balances:"] ++ map ("    "++) accounts | ||||||
|  | 
 | ||||||
|  | -- | Check an assertion holds for a collection of account balances. | ||||||
|  | checkAssertion :: [(H.AccountName, H.MixedAmount)] -> Predicate -> Bool | ||||||
|  | checkAssertion accounts = checkAssertion' | ||||||
|  |   where | ||||||
|  |     checkAssertion' (Not p) = not (checkAssertion' p) | ||||||
|  |     checkAssertion' (Connect p1 c p2) = | ||||||
|  |       let p1' = checkAssertion' p1 | ||||||
|  |           p2' = checkAssertion' p2 | ||||||
|  |       in case c of | ||||||
|  |           AND     -> p1' && p2' | ||||||
|  |           OR      -> p1' || p2' | ||||||
|  |           IMPLIES -> not p1' || p2' | ||||||
|  |           IFF     -> p1' == p2' | ||||||
|  |     checkAssertion' (Compare v1 c v2) = | ||||||
|  |       let v1e = evaluate v1 | ||||||
|  |           v2e = evaluate v2 | ||||||
|  |           v1' = fixup v1e v2e | ||||||
|  |           v2' = fixup v2e v1e | ||||||
|  |       in case c of | ||||||
|  |           LLT -> v1' <  v2' | ||||||
|  |           EEQ -> v1' == v2' | ||||||
|  |           GGT -> v1' >  v2' | ||||||
|  |           LTE -> v1' <= v2' | ||||||
|  |           NEQ -> v1' /= v2' | ||||||
|  |           GTE -> v1' >= v2' | ||||||
|  | 
 | ||||||
|  |     evaluate (Account account) = | ||||||
|  |       fromMaybe H.nullmixedamt $ lookup account accounts | ||||||
|  |     evaluate (AccountNested account) = | ||||||
|  |       sum [m | (a,m) <- accounts, account == a || (a <> pack ":") `isPrefixOf` account] | ||||||
|  |     evaluate (Amount amount) = H.mixed [amount] | ||||||
|  | 
 | ||||||
|  |     -- Add missing amounts (with 0 value), normalise, throw away style | ||||||
|  |     -- information, and sort by commodity name. | ||||||
|  |     fixup (H.Mixed m1) (H.Mixed m2) = H.Mixed $ | ||||||
|  |       let m = H.Mixed (m1 ++ [m_ { H.aquantity = 0 } | m_ <- m2]) | ||||||
|  |           (H.Mixed as) = H.normaliseMixedAmount m | ||||||
|  |       in sortOn H.acommodity . map (\a -> a { H.astyle = H.amountstyle }) $ as | ||||||
|  | 
 | ||||||
|  | -- | Check if an account name is mentioned in an assertion. | ||||||
|  | inAssertion :: H.AccountName -> Predicate -> Bool | ||||||
|  | inAssertion account = inAssertion' | ||||||
|  |   where | ||||||
|  |     inAssertion' (Not p) = not (inAssertion' p) | ||||||
|  |     inAssertion' (Connect p1 _ p2) = inAssertion' p1 || inAssertion' p2 | ||||||
|  |     inAssertion' (Compare v1 _ v2) = inValue v1 || inValue v2 | ||||||
|  | 
 | ||||||
|  |     inValue (Account a) = account == a | ||||||
|  |     inValue (AccountNested a) = account == a || (a <> pack ":") `isPrefixOf` account | ||||||
|  |     inValue (Amount _) = False | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | ------------------------------------------------------------------------------- | ||||||
|  | -- Journals | ||||||
|  | 
 | ||||||
|  | -- | Apply account aliases and restrict to the date range, return the | ||||||
|  | -- starting balance of every account. | ||||||
|  | fixupJournal :: Opts -> H.Journal -> IO (H.Journal, [(H.AccountName, H.MixedAmount)]) | ||||||
|  | fixupJournal opts j = do | ||||||
|  |     today <- H.getCurrentDay | ||||||
|  |     let j' = (if cleared   opts then H.filterJournalTransactions (H.Status H.Cleared)   else id) | ||||||
|  |            . (if pending   opts then H.filterJournalTransactions (H.Status H.Pending)   else id) | ||||||
|  |            . (if uncleared opts then H.filterJournalTransactions (H.Status H.Uncleared) else id) | ||||||
|  |            . (if real      opts then H.filterJournalTransactions (H.Real   True)        else id) | ||||||
|  |            $ H.journalApplyAliases (aliases opts) j | ||||||
|  |     let starting = case begin opts of | ||||||
|  |           Just _  -> | ||||||
|  |               let dateSpan = H.DateSpan Nothing (fixDay today begin) | ||||||
|  |               in closingBalances (H.filterJournalPostings (H.Date dateSpan) j') | ||||||
|  |           Nothing -> [] | ||||||
|  |     let dateSpan = H.DateSpan (fixDay today begin) (fixDay today end) | ||||||
|  |     pure (H.filterJournalTransactions (H.Date dateSpan) j', starting) | ||||||
|  | 
 | ||||||
|  |   where | ||||||
|  |     fixDay today dayf = H.fixSmartDate today <$> dayf opts | ||||||
|  | 
 | ||||||
|  | -- | Get the closing balances of every account in the journal. | ||||||
|  | closingBalances :: H.Journal -> [(H.AccountName, H.MixedAmount)] | ||||||
|  | closingBalances = closingBalances' . H.journalPostings | ||||||
|  | 
 | ||||||
|  | -- | Get the closing balances of every account referenced by a group | ||||||
|  | -- of postings. | ||||||
|  | closingBalances' :: [H.Posting] -> [(H.AccountName, H.MixedAmount)] | ||||||
|  | closingBalances' postings = | ||||||
|  |   let postingsByAccount = | ||||||
|  |         groupBy ((==) `on` H.paccount) . sortOn H.paccount $ postings | ||||||
|  |   in map (\ps@(p:_) -> (H.paccount p, H.sumPostings ps)) postingsByAccount | ||||||
|  | 
 | ||||||
|  | -- | Add balances in matching accounts. | ||||||
|  | addAccounts :: [(H.AccountName, H.MixedAmount)] -> [(H.AccountName, H.MixedAmount)] -> [(H.AccountName, H.MixedAmount)] | ||||||
|  | addAccounts as1 as2 = [ (a, a1 + a2) | ||||||
|  |                       | a <- nub (map fst as1 ++ map fst as2) | ||||||
|  |                       , let a1 = fromMaybe H.nullmixedamt $ lookup a as1 | ||||||
|  |                       , let a2 = fromMaybe H.nullmixedamt $ lookup a as2 | ||||||
|  |                       ] | ||||||
|  | 
 | ||||||
|  | ------------------------------------------------------------------------------- | ||||||
|  | -- Dates | ||||||
|  | 
 | ||||||
|  | -- | Check if two postings are in the same day. | ||||||
|  | sameDay :: H.Posting -> H.Posting -> Bool | ||||||
|  | sameDay = sameish id | ||||||
|  | 
 | ||||||
|  | -- | Check if two postings are in the same week. | ||||||
|  | sameWeek :: Bool -> H.Posting -> H.Posting -> Bool | ||||||
|  | sameWeek startSunday p1 p2 = | ||||||
|  |   let startWeek = if startSunday then sundayStartWeek else mondayStartWeek | ||||||
|  |       d1 = H.postingDate p1 | ||||||
|  |       d2 = H.postingDate p2 | ||||||
|  |       y1 = fst (toOrdinalDate d1) | ||||||
|  |       y2 = fst (toOrdinalDate d2) | ||||||
|  |       w1 = fst (startWeek d1) | ||||||
|  |       w2 = fst (startWeek d2) | ||||||
|  |       sameYearSameWeek =   y1 == y2   && w1 == w2 | ||||||
|  |       week0Week52      =   y1 == y2+1 && w1 == 0  && w2 == 52 | ||||||
|  |       week52Week0      = 1+y1 == y2   && w1 == 52 && w2 == 0 | ||||||
|  |   in sameYearSameWeek || week0Week52 || week52Week0 | ||||||
|  | 
 | ||||||
|  | -- | Check if two postings are in the same month. | ||||||
|  | sameMonth :: H.Posting -> H.Posting -> Bool | ||||||
|  | sameMonth = sameish (\(y,m,_) -> (y,m)) | ||||||
|  | 
 | ||||||
|  | -- | Check if two postings are in the same quarter. | ||||||
|  | sameQuarter :: H.Posting -> H.Posting -> Bool | ||||||
|  | sameQuarter = sameish (\(y,m,_) -> (y, m `div` 4)) | ||||||
|  | 
 | ||||||
|  | -- | Check if two postings are in the same year. | ||||||
|  | sameYear :: H.Posting -> H.Posting -> Bool | ||||||
|  | sameYear = sameish (\(y,_,_) -> y) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | ------------------------------------------------------------------------------- | ||||||
|  | -- Command-line Arguments | ||||||
|  | 
 | ||||||
|  | -- | Parsed command-line arguments. | ||||||
|  | data Opts = Opts | ||||||
|  |     { file :: Maybe FilePath | ||||||
|  |     -- ^ Path to journal file. | ||||||
|  |     , aliases :: [H.AccountAlias] | ||||||
|  |     -- ^ Account name aliases: (OLD, NEW). | ||||||
|  |     , ignoreAssertions :: Bool | ||||||
|  |     -- ^ Ignore balance assertions while reading the journal file (but | ||||||
|  |     -- still apply any given to this tool. | ||||||
|  |     , begin :: Maybe H.SmartDate | ||||||
|  |     -- ^ Exclude postings/txns before this date. | ||||||
|  |     , end :: Maybe H.SmartDate | ||||||
|  |     -- ^ Exclude postings/txns on or after this date. | ||||||
|  |     , cleared :: Bool | ||||||
|  |     -- ^ Include only cleared postings/txns. | ||||||
|  |     , pending :: Bool | ||||||
|  |     -- ^ Include only pending postings/txns. | ||||||
|  |     , uncleared :: Bool | ||||||
|  |     -- ^ Include only uncleared (and pending) postings/txns. | ||||||
|  |     , real :: Bool | ||||||
|  |     -- ^ Include only non-virtual postings. | ||||||
|  |     , sunday :: Bool | ||||||
|  |     -- ^ Week starts on Sunday. | ||||||
|  |     , assertionsDaily :: [(String, Predicate)] | ||||||
|  |     -- ^ Account assertions that must hold at the end of each day. | ||||||
|  |     , assertionsWeekly :: [(String, Predicate)] | ||||||
|  |     -- ^ Account assertions that must hold at the end of each week. | ||||||
|  |     , assertionsMonthly :: [(String, Predicate)] | ||||||
|  |     -- ^ Account assertions that must hold at the end of each month. | ||||||
|  |     , assertionsQuarterly :: [(String, Predicate)] | ||||||
|  |     -- ^ Account assertions that must hold at the end of each quarter. | ||||||
|  |     , assertionsYearly :: [(String, Predicate)] | ||||||
|  |     -- ^ Account assertions that must hold at the end of each year. | ||||||
|  |     , assertionsAlways :: [(String, Predicate)] | ||||||
|  |     -- ^ Account assertions that must hold after each txn. | ||||||
|  |     } | ||||||
|  |   deriving (Eq, Ord, Show) | ||||||
|  | 
 | ||||||
|  | -- | Command-line arguments. | ||||||
|  | args :: ParserInfo Opts | ||||||
|  | args = info (helper <*> parser) $ mconcat | ||||||
|  |     [ fullDesc | ||||||
|  |     , progDesc "Complex account balance assertions for hledger journals." | ||||||
|  |     ] | ||||||
|  |   where | ||||||
|  |     parser = Opts <$> (optional . strOption) | ||||||
|  |                         (arg 'f' "file" "use a different input file. For stdin, use -"             <> metavar "FILE") | ||||||
|  |                   <*> (many . fmap snd . popt (lift H.accountaliasp)) | ||||||
|  |                         (arg' "alias" "display accounts named OLD as NEW"                          <> metavar "OLD=NEW") | ||||||
|  |                   <*> switch | ||||||
|  |                         (arg' "ignore-assertions" "ignore any balance assertions in the journal") | ||||||
|  |                   <*> (optional . fmap snd . popt' H.smartdate) | ||||||
|  |                         (arg 'b' "begin" "include postings/txns on or after this date"             <> metavar "DATE") | ||||||
|  |                   <*> (optional . fmap snd . popt' H.smartdate) | ||||||
|  |                         (arg 'e' "end" "include postings/txns before this date"                    <> metavar "DATE") | ||||||
|  |                   <*> switch | ||||||
|  |                         (arg 'C' "cleared" "include only cleared postings/txns") | ||||||
|  |                   <*> switch | ||||||
|  |                         (arg' "pending" "include only pending postings/txns") | ||||||
|  |                   <*> switch | ||||||
|  |                         (arg 'U' "uncleared" "include only uncleared (and pending) postings/txns") | ||||||
|  |                   <*> switch | ||||||
|  |                         (arg 'R' "real" "include only non-virtual postings") | ||||||
|  |                   <*> switch | ||||||
|  |                         (arg' "sunday" "weeks start on Sunday") | ||||||
|  |                   <*> (many . popt predicatep) | ||||||
|  |                         (arg 'D' "daily" "assertions that must hold at the end of the day"         <> metavar "ASSERT") | ||||||
|  |                   <*> (many . popt predicatep) | ||||||
|  |                         (arg 'W' "weekly" "assertions that must hold at the end of the week"       <> metavar "ASSERT") | ||||||
|  |                   <*> (many . popt predicatep) | ||||||
|  |                         (arg 'M' "monthly" "assertions that must hold at the end of the month"     <> metavar "ASSERT") | ||||||
|  |                   <*> (many . popt predicatep) | ||||||
|  |                         (arg 'Q' "quarterly" "assertions that must hold at the end of the quarter" <> metavar "ASSERT") | ||||||
|  |                   <*> (many . popt predicatep) | ||||||
|  |                         (arg 'Y' "yearly" "assertions that must hold at the end of the year"       <> metavar "ASSERT") | ||||||
|  |                   <*> (many . parg predicatep) | ||||||
|  |                         (help "assertions that must hold after every transaction"                  <> metavar "ASSERT") | ||||||
|  | 
 | ||||||
|  |     -- Shorthand for options | ||||||
|  |     arg s l h = arg' l h <> short s | ||||||
|  |     arg'  l h = long l <> help h | ||||||
|  | 
 | ||||||
|  |     -- Arguments and options from a Megaparsec parser. | ||||||
|  |     parg = argument . readParsec | ||||||
|  |     popt = option . readParsec | ||||||
|  |     popt' = option . readParsec' | ||||||
|  | 
 | ||||||
|  |     -- Turn a Parsec parser into a ReadM parser that also returns the | ||||||
|  |     -- input. | ||||||
|  |     readParsec :: H.JournalStateParser ReadM a -> ReadM (String, a) | ||||||
|  |     readParsec p = do | ||||||
|  |       s <- str | ||||||
|  |       parsed <- P.runParserT (runStateT p H.nulljournal) "" (pack s) | ||||||
|  |       case parsed of | ||||||
|  |         Right (a, _) -> pure (s, a) | ||||||
|  |         Left err -> fail ("failed to parse input '" ++ s ++ "': " ++ show err) | ||||||
|  | 
 | ||||||
|  |     readParsec' :: P.Parser a -> ReadM (String, a) | ||||||
|  |     readParsec' p = do | ||||||
|  |       s <- str | ||||||
|  |       let parsed = runIdentity $ P.runParserT p "" (pack s) | ||||||
|  |       case parsed of | ||||||
|  |         Right a -> pure (s, a) | ||||||
|  |         Left err -> fail ("failed to parse input '" ++ s ++ "': " ++ show err) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | ------------------------------------------------------------------------------- | ||||||
|  | -- Predicates & Parsers | ||||||
|  | 
 | ||||||
|  | data Predicate | ||||||
|  |     = Compare Value Compare Value | ||||||
|  |     | Connect Predicate Connect Predicate | ||||||
|  |     | Not Predicate | ||||||
|  |   deriving (Eq, Ord, Show) | ||||||
|  | 
 | ||||||
|  | -- | Parse a 'Predicate'. | ||||||
|  | predicatep :: Monad m => H.JournalStateParser m Predicate | ||||||
|  | predicatep = wrap predparensp <|> wrap predcomparep <|> wrap prednotp where | ||||||
|  |     predparensp  = P.char '(' *> spaces *> predicatep <* spaces <* P.char ')' | ||||||
|  |     predcomparep = Compare <$> valuep <*> (spaces *> lift comparep <* spaces) <*> valuep | ||||||
|  |     prednotp     = void (P.char '!') *> (Not <$> predicatep) | ||||||
|  |     spaces = void . many $ P.char ' ' | ||||||
|  | 
 | ||||||
|  |     wrap p = do | ||||||
|  |         a <- P.try p | ||||||
|  |         spaces | ||||||
|  |         P.try (wrap $ do c <- lift connectp; spaces; a2 <- p; pure $ Connect a c a2) <|> pure a | ||||||
|  | 
 | ||||||
|  | data Value = Account H.AccountName | AccountNested H.AccountName | Amount H.Amount | ||||||
|  |   deriving (Eq, Ord, Show) | ||||||
|  | 
 | ||||||
|  | -- | Parse a 'Value'. | ||||||
|  | valuep :: Monad m => H.JournalStateParser m Value | ||||||
|  | -- Account name parser has to come last because they eat everything. | ||||||
|  | valuep = valueamountp <|> valueaccountnestedp <|> valueaccountp where | ||||||
|  |     valueamountp  = Amount  <$> H.amountp | ||||||
|  |     valueaccountp = Account <$> lift H.accountnamep | ||||||
|  |     valueaccountnestedp = AccountNested <$> (P.char '*' *> spaces *> lift H.accountnamep) | ||||||
|  |     spaces = void . many $ P.char ' ' | ||||||
|  | 
 | ||||||
|  | data Compare = LLT | EEQ | GGT | LTE | NEQ | GTE | ||||||
|  |   deriving (Eq, Ord, Read, Show, Bounded, Enum) | ||||||
|  | 
 | ||||||
|  | -- | Parse a 'Compare'. | ||||||
|  | comparep :: Monad m => H.TextParser m Compare | ||||||
|  | comparep = gostringsp [("<=", LTE), ("<", LLT), ("==", EEQ), (">=", GTE), (">", GGT), ("!=", NEQ)] | ||||||
|  | 
 | ||||||
|  | data Connect = AND | OR | IMPLIES | IFF | ||||||
|  |   deriving (Eq, Ord, Read, Show, Bounded, Enum) | ||||||
|  | 
 | ||||||
|  | -- | Parse a 'Connect'. | ||||||
|  | connectp :: Monad m => H.TextParser m Connect | ||||||
|  | connectp = gostringsp [("&&", AND), ("||", OR), ("==>", IMPLIES), ("<==>", IFF)] | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | ------------------------------------------------------------------------------- | ||||||
|  | -- Utilities | ||||||
|  | 
 | ||||||
|  | -- | Group values in a list into nonempty subsequences. | ||||||
|  | groupByNE :: (a -> a -> Bool) -> [a] -> [NonEmpty a] | ||||||
|  | groupByNE f = mapMaybe nonEmpty . groupBy f | ||||||
|  | 
 | ||||||
|  | -- | Check if two postings are on the sameish date, given a function | ||||||
|  | -- to convert the posting date (in (Y,M,D) format) to some comparable | ||||||
|  | -- value. | ||||||
|  | sameish :: Eq a => ((Integer, Int, Int) -> a) -> H.Posting -> H.Posting -> Bool | ||||||
|  | sameish f = (==) `on` f . toGregorian . H.postingDate | ||||||
|  | 
 | ||||||
|  | -- | Helper for 'Compare' and 'Connect' parsers. | ||||||
|  | gostringsp :: Monad m => [(String, a)] -> H.TextParser m a | ||||||
|  | gostringsp ((s,a):rest) = P.try (P.string s *> pure a) `mplus` gostringsp rest | ||||||
|  | gostringsp [] = mzero | ||||||
		Loading…
	
		Reference in New Issue
	
	Block a user