-- | Liberty - Penny command line parsing utilities -- -- Both Cabin and Zinc share various functions that aid in parsing -- command lines. For instance both the Postings report and the Zinc -- postings filter use common command-line options. However, Zinc -- already depends on Cabin. To avoid a cyclic dependency whereby -- Cabin would also depend on Zinc, functions formerly in Zinc that -- Cabin will also find useful are relocated here, to Liberty. module Penny.Liberty ( Error(..), MatcherFactory, X.evaluate, X.Token, FilteredNum(FilteredNum, unFilteredNum), SortedNum(SortedNum, unSortedNum), LibertyMeta(filteredNum, sortedNum), xactionsToFiltered, ListLength(ListLength, unListLength), ItemIndex(ItemIndex, unItemIndex), PostFilterFn, parseComparer, processPostFilters, parseTokenList, parsePredicate, -- * Parsers Operand, parseOperand, parsePostFilter, parseMatcherSelect, parseCaseSelect, parseOperator, Orderer, parseSort ) where import Control.Applicative ((<|>)) import qualified Control.Monad.Exception.Synchronous as Ex import Control.Monad.Exception.Synchronous ( Exceptional(Exception)) import Data.Char (toUpper) import Data.List (isPrefixOf, sortBy) import Data.Text (Text, pack) import qualified Data.Text as Text import qualified System.Console.MultiArg.Combinator as C import System.Console.MultiArg.Prim (Parser) import Text.Parsec (parse) import Penny.Copper.DateTime (DefaultTimeZone, dateTime) import Penny.Copper.Qty (RadGroup, qty) import Penny.Lincoln.Family.Child (child, parent) import qualified Penny.Lincoln.Predicates as P import qualified Penny.Lincoln as L import qualified Penny.Lincoln.Queries as Q import qualified Penny.Liberty.Expressions as X import Text.Matchers.CaseSensitive ( CaseSensitive(Sensitive, Insensitive)) import qualified Text.Matchers.Text as TM -- | A serial indicating how a post relates to all other postings that -- made it through the filtering phase. newtype FilteredNum = FilteredNum { unFilteredNum :: L.Serial } deriving Show -- | A serial indicating how a posting relates to all other postings -- that have been sorted. newtype SortedNum = SortedNum { unSortedNum :: L.Serial } deriving Show -- | All metadata from Liberty. data LibertyMeta = LibertyMeta { filteredNum :: FilteredNum , sortedNum :: SortedNum } deriving Show -- | Parses a list of tokens. Returns Nothing if the token sequence is -- invalid (e.g. if two operators are next to each other) or if the -- resulting RPN expression is bad (there are tokens left on the stack -- after parsing). Otherwise, returns the expression. -- -- An empty list will fail to parse. The function calling this one -- must deal with empty lists if those are a possibility. parseTokenList :: [X.Token a] -> Maybe a parseTokenList = X.evaluate -- | Parses a list of tokens to obtain a predicate. Deals with an -- empty list of tokens by returning a predicate that is always -- True. Fails if the list of tokens is not empty and the parse fails. parsePredicate :: [X.Token (a -> Bool)] -> Maybe (a -> Bool) parsePredicate ls = case ls of [] -> return (const True) ts -> parseTokenList ts -- | Takes a list of transactions, splits them into PostingChild -- instances, filters them, post-filters them, sorts them, and places -- them in Box instances with Filtered serials. xactionsToFiltered :: (L.PostFam -> Bool) -- ^ The predicate to filter the transactions -> [PostFilterFn] -- ^ Post filter specs -> (L.PostFam -> L.PostFam -> Ordering) -- ^ The sorter -> [L.Transaction] -- ^ The transactions to work on (probably parsed in from Copper) -> [L.Box LibertyMeta] -- ^ Sorted, filtered postings xactionsToFiltered pdct pfs s = addSortedNum . processPostFilters pfs . sortBy (sorter s) . addFilteredNum . map toBox . filter pdct . concatMap L.postFam -- | Transforms a PostingChild into a Box. toBox :: L.PostFam -> L.Box () toBox = L.Box () -- | Takes a list of filtered boxes and adds the Filtered serials. addFilteredNum :: [L.Box a] -> [L.Box FilteredNum] addFilteredNum = L.serialItems f where f ser = fmap (const (FilteredNum ser)) -- | Wraps a PostingChild sorter to change it to a Box sorter. sorter :: (L.PostFam -> L.PostFam -> Ordering) -> L.Box a -> L.Box b -> Ordering sorter f b1 b2 = f (L.boxPostFam b1) (L.boxPostFam b2) -- | Takes a list of Boxes with metadata and adds a Serial for the -- Sorted. addSortedNum :: [L.Box FilteredNum] -> [L.Box LibertyMeta] addSortedNum = L.serialItems f where f ser = fmap g where g filtNum = LibertyMeta filtNum (SortedNum ser) type MatcherFactory = CaseSensitive -> Text -> Ex.Exceptional Text (Text -> Bool) type Operand = X.Operand (L.PostFam -> Bool) newtype ListLength = ListLength { unListLength :: Int } deriving (Eq, Ord, Show) newtype ItemIndex = ItemIndex { unItemIndex :: Int } deriving (Eq, Ord, Show) -- | Specifies options for the post-filter stage. type PostFilterFn = ListLength -> ItemIndex -> Bool processPostFilters :: [PostFilterFn] -> [a] -> [a] processPostFilters pfs ls = foldl processPostFilter ls pfs processPostFilter :: [a] -> PostFilterFn -> [a] processPostFilter as fn = map fst . filter fn' $ zipped where len = ListLength $ length as fn' (_, idx) = fn len (ItemIndex idx) zipped = zip as [0..] data Error = MakeMatcherFactoryError Text | DateParseError | BadPatternError Text | BadNumberError Text | BadQtyError Text | BadSortKeyError Text | BadComparator Text | BadExpression | BadColorName Text | BadFieldName Text | BadBackgroundArg Text | UnexpectedWord Text Text | BadCommodityError Text deriving Show ------------------------------------------------------------ -- Operands ------------------------------------------------------------ -- | Given a String from the command line which represents a pattern, -- and a MatcherFactor, return a Matcher. Fails if the pattern is bad -- (e.g. it is not a valid regular expression). getMatcher :: String -> CaseSensitive -> MatcherFactory -> Ex.Exceptional Error (Text -> Bool) getMatcher s cs f = case f cs (pack s) of Ex.Exception e -> Ex.Exception $ BadPatternError e Ex.Success m -> return m -- | Parses comparers given on command line to a function. parseComparer :: (Eq a, Ord a) => String -> Maybe (a -> a -> Bool) parseComparer t | t == "<" = Just (<) | t == "<=" = Just (<=) | t == "==" = Just (==) | t == "=" = Just (==) | t == ">" = Just (>) | t == ">=" = Just (>=) | t == "/=" = Just (/=) | t == "!=" = Just (/=) | otherwise = Nothing parseDate :: DefaultTimeZone -> Text -> Exceptional Error L.DateTime parseDate dtz t = case parse (dateTime dtz) "" t of Left _ -> Exception DateParseError Right d -> return d date :: Parser (DefaultTimeZone -> Ex.Exceptional Error Operand) date = let os = C.OptSpec ["date"] ['d'] (C.TwoArg f) f a1 a2 dtz = do cmp <- Ex.fromMaybe (BadComparator (pack a1)) (parseComparer a1) dt <- parseDate dtz (pack a2) return $ X.Operand (P.date (`cmp` dt)) in C.parseOption [os] current :: Parser (L.DateTime -> Operand) current = let os = C.OptSpec ["current"] [] (C.NoArg f) f dt = X.Operand (P.date (<= dt)) in C.parseOption [os] -- | Creates options that match against fields that are -- colon-separated. The operand added will return True if the -- posting's colon-separated option matches the pattern given, or -- False if it does not. sepOption :: String -- ^ Long option name -> Maybe Char -- ^ Short option name, if there is one -> (Text -> (Text -> Bool) -> L.PostFam -> Bool) -- ^ When applied to a text that separates the different segments of -- the field, a matcher, and a posting, this function returns True -- if the posting matches the matcher or False if it does not. -> Parser (CaseSensitive -> MatcherFactory -> Ex.Exceptional Error Operand) sepOption str mc f = let so = case mc of Nothing -> [] Just c -> [c] os = C.OptSpec [str] so (C.OneArg g) g a cs fty = do m <- getMatcher a cs fty return $ X.Operand (f sep m) in C.parseOption [os] sep :: Text sep = Text.singleton ':' -- | Parses exactly one integer; fails if it cannot read exactly one. parseInt :: String -> Exceptional Error Int parseInt t = let ps = reads t in case ps of [] -> Exception $ BadNumberError (pack t) ((i, s):[]) -> if length s /= 0 then Exception $ BadNumberError (pack t) else return i _ -> Exception $ BadNumberError (pack t) -- | Creates options that take two arguments, with the first argument -- being the level to match, and the second being the pattern that -- should match against that level. Adds an operand that returns True -- if the pattern matches. levelOption :: String -- ^ Long option name -> (Int -> (Text -> Bool) -> L.PostFam -> Bool) -- ^ Applied to an integer, a matcher, and a PostingBox, this -- function returns True if a particular field in the posting -- matches the matcher given at the given level, or False otherwise. -> Parser (CaseSensitive -> MatcherFactory -> Exceptional Error Operand) levelOption str f = let os = C.OptSpec [str] [] (C.TwoArg g) g a1 a2 cs fty = do n <- parseInt a1 m <- getMatcher a2 cs fty return $ X.Operand (f n m) in C.parseOption [os] -- | Creates options that add an operand that matches the posting if a -- particluar field matches the pattern given. patternOption :: String -- ^ Long option -> Maybe Char -- ^ Short option, if included -> ((Text -> Bool) -> L.PostFam -> Bool) -- ^ When applied to a matcher and a PostingBox, this function -- returns True if the posting matches, or False if it does not. -> Parser (CaseSensitive -> MatcherFactory -> Exceptional Error Operand) patternOption str mc f = let so = maybe [] (\c -> [c]) mc os = C.OptSpec [str] so (C.OneArg g) g a1 cs fty = do m <- getMatcher a1 cs fty return $ X.Operand (f m) in C.parseOption [os] -- | The account option; matches if the pattern given matches the -- colon-separated account name. account :: Parser (CaseSensitive -> MatcherFactory -> Exceptional Error Operand) account = sepOption "account" (Just 'a') P.account -- | The account-level option; matches if the account at the given -- level matches. accountLevel :: Parser (CaseSensitive -> MatcherFactory -> Exceptional Error Operand) accountLevel = levelOption "account-level" P.accountLevel -- | The accountAny option; returns True if the matcher given matches -- a single sub-account name at any level. accountAny :: Parser (CaseSensitive -> MatcherFactory -> Exceptional Error Operand) accountAny = patternOption "account-any" Nothing P.accountAny -- | The payee option; returns True if the matcher matches the payee -- name. payee :: Parser (CaseSensitive -> MatcherFactory -> Exceptional Error Operand) payee = patternOption "payee" (Just 'p') P.payee tag :: Parser (CaseSensitive -> MatcherFactory -> Exceptional Error Operand) tag = patternOption "tag" (Just 't') P.tag number :: Parser (CaseSensitive -> MatcherFactory -> Exceptional Error Operand) number = patternOption "number" Nothing P.number flag :: Parser (CaseSensitive -> MatcherFactory -> Exceptional Error Operand) flag = patternOption "flag" Nothing P.flag commodity :: Parser (CaseSensitive -> MatcherFactory -> Exceptional Error Operand) commodity = sepOption "commodity" Nothing P.commodity commodityLevel :: Parser (CaseSensitive -> MatcherFactory -> Exceptional Error Operand) commodityLevel = levelOption "commodity-level" P.commodityLevel commodityAny :: Parser (CaseSensitive -> MatcherFactory -> Exceptional Error Operand) commodityAny = patternOption "commodity" Nothing P.commodityAny postingMemo :: Parser (CaseSensitive -> MatcherFactory -> Exceptional Error Operand) postingMemo = patternOption "posting-memo" Nothing P.postingMemo transactionMemo :: Parser (CaseSensitive -> MatcherFactory -> Exceptional Error Operand) transactionMemo = patternOption "transaction-memo" Nothing P.transactionMemo debit :: Parser Operand debit = let os = C.OptSpec ["debit"] [] (C.NoArg (X.Operand P.debit)) in C.parseOption [os] credit :: Parser Operand credit = let os = C.OptSpec ["credit"] [] (C.NoArg (X.Operand P.credit)) in C.parseOption [os] qtyOption :: Parser (RadGroup -> Exceptional Error Operand) qtyOption = let os = C.OptSpec ["qty"] [] (C.TwoArg f) f a1 a2 rg = do q <- case parse (qty rg) "" (pack a2) of Left _ -> Ex.throw $ BadQtyError (pack a2) Right g -> return g cmp <- Ex.fromMaybe (BadComparator (pack a1)) (parseComparer a1) return $ X.Operand (P.qty (`cmp` q)) in C.parseOption [os] -- | Creates two options suitable for comparison of serial numbers, -- one for ascending, one for descending. serialOption :: (L.PostFam -> Maybe L.Serial) -- ^ Function that, when applied to a PostingChild, returns the serial -- you are interested in. -> String -- ^ Name of the command line option, such as @global-transaction@ -> Parser (Exceptional Error Operand) -- ^ Parses both descending and ascending serial options. serialOption getSerial n = let osA = C.OptSpec [n] [] (C.TwoArg (f L.forward)) osD = C.OptSpec [addPrefix "rev" n] [] (C.TwoArg (f L.backward)) f getInt a1 a2 = do cmp <- Ex.fromMaybe (BadComparator (pack a1)) (parseComparer a1) i <- parseInt a2 let op pf = case getSerial pf of Nothing -> False Just ser -> getInt ser `cmp` i return $ X.Operand op in C.parseOption [osA, osD] -- | Takes a string, adds a prefix and capitalizes the first letter of -- the old string. e.g. applied to "rev" and "globalTransaction", -- returns "revGlobalTransaction". addPrefix :: String -> String -> String addPrefix pre suf = pre ++ suf' where suf' = case suf of "" -> "" x:xs -> toUpper x : xs globalTransaction :: Parser (Exceptional Error Operand) globalTransaction = let f = fmap L.unGlobalTransaction . L.globalTransaction . L.tMeta . parent . L.unPostFam in serialOption f "globalTransaction" globalPosting :: Parser (Exceptional Error Operand) globalPosting = let f = fmap L.unGlobalPosting . L.globalPosting . L.pMeta . child . L.unPostFam in serialOption f "globalPosting" filePosting :: Parser (Exceptional Error Operand) filePosting = let f = fmap L.unFilePosting . L.filePosting . L.pMeta . child . L.unPostFam in serialOption f "filePosting" fileTransaction :: Parser (Exceptional Error Operand) fileTransaction = let f = fmap L.unFileTransaction . L.fileTransaction . L.tMeta . parent . L.unPostFam in serialOption f "fileTransaction" -- | Parses operands. parseOperand :: Parser (L.DateTime -> DefaultTimeZone -> RadGroup -> CaseSensitive -> MatcherFactory -> Ex.Exceptional Error Operand) parseOperand = (do { f <- date; return (\_ dtz _ _ _ -> f dtz)}) <|> (do { f <- current; return (\dt _ _ _ _ -> return (f dt)) }) <|> wrapFactArg account <|> wrapFactArg accountLevel <|> wrapFactArg accountAny <|> wrapFactArg payee <|> wrapFactArg tag <|> wrapFactArg number <|> wrapFactArg flag <|> wrapFactArg commodity <|> wrapFactArg commodityLevel <|> wrapFactArg commodityAny <|> wrapFactArg postingMemo <|> wrapFactArg transactionMemo <|> (do { o <- debit; return (\_ _ _ _ _ -> return o) }) <|> (do { o <- credit; return (\_ _ _ _ _ -> return o) }) <|> (do { f <- qtyOption; return (\ _ _ rg _ _ -> f rg)}) <|> wrapNoArg globalTransaction <|> wrapNoArg globalPosting <|> wrapNoArg filePosting <|> wrapNoArg fileTransaction wrapNoArg :: Parser (Ex.Exceptional Error Operand) -> Parser (L.DateTime -> DefaultTimeZone -> RadGroup -> CaseSensitive -> MatcherFactory -> Ex.Exceptional Error Operand) wrapNoArg p = do o <- p return (\_ _ _ _ _ -> o) wrapFactArg :: Parser (CaseSensitive -> MatcherFactory -> Exceptional Error Operand) -> Parser (L.DateTime -> DefaultTimeZone -> RadGroup -> CaseSensitive -> MatcherFactory -> Ex.Exceptional Error Operand) wrapFactArg p = do f <- p return (\_ _ _ cs fact -> f cs fact) ------------------------------------------------------------ -- Post filters ------------------------------------------------------------ optHead :: Parser (Exceptional Error PostFilterFn) optHead = let os = C.OptSpec ["head"] [] (C.OneArg f) f a = do i <- fmap ItemIndex (parseInt a) return (\_ idx -> idx < i) in C.parseOption [os] optTail :: Parser (Exceptional Error PostFilterFn) optTail = let os = C.OptSpec ["tail"] [] (C.OneArg f) f a = do i <- parseInt a let g (ListLength len) (ItemIndex idx) = idx >= len - i return g in C.parseOption [os] parsePostFilter :: Parser (Exceptional Error PostFilterFn) parsePostFilter = optHead <|> optTail ------------------------------------------------------------ -- Matcher control ------------------------------------------------------------ noArg :: a -> String -> Parser a noArg a s = let os = C.OptSpec [s] "" (C.NoArg a) in C.parseOption [os] parseInsensitive :: Parser CaseSensitive parseInsensitive = let os = C.OptSpec ["case-insensitive"] ['i'] (C.NoArg Insensitive) in C.parseOption [os] parseSensitive :: Parser CaseSensitive parseSensitive = let os = C.OptSpec ["case-sensitive"] ['I'] (C.NoArg Sensitive) in C.parseOption [os] within :: Parser MatcherFactory within = noArg (\c t -> return (TM.within c t)) "within" pcre :: Parser MatcherFactory pcre = noArg TM.pcre "pcre" posix :: Parser MatcherFactory posix = noArg TM.tdfa "posix" exact :: Parser MatcherFactory exact = noArg (\c t -> return (TM.exact c t)) "exact" parseMatcherSelect :: Parser MatcherFactory parseMatcherSelect = within <|> pcre <|> posix <|> exact parseCaseSelect :: Parser CaseSensitive parseCaseSelect = parseInsensitive <|> parseSensitive ------------------------------------------------------------ -- Operators ------------------------------------------------------------ -- | Open parentheses open :: Parser (X.Token a) open = noArg X.TokOpenParen "open" -- | Close parentheses close :: Parser (X.Token a) close = noArg X.TokCloseParen "close" -- | and operator parseAnd :: Parser (X.Token (a -> Bool)) parseAnd = noArg X.tokAnd "and" -- | or operator parseOr :: Parser (X.Token (a -> Bool)) parseOr = noArg X.tokOr "or" -- | not operator parseNot :: Parser (X.Token (a -> Bool)) parseNot = noArg X.tokNot "not" where parseOperator :: Parser (X.Token (a -> Bool)) parseOperator = open <|> close <|> parseAnd <|> parseOr <|> parseNot ------------------------------------------------------------ -- Sorting ------------------------------------------------------------ type Orderer = L.PostFam -> L.PostFam -> Ordering ordering :: (Ord b) => (a -> b) -> (a -> a -> Ordering) ordering q = f where f p1 p2 = compare (q p1) (q p2) flipOrder :: (a -> a -> Ordering) -> (a -> a -> Ordering) flipOrder f = f' where f' p1 p2 = case f p1 p2 of LT -> GT GT -> LT EQ -> EQ capitalizeFirstLetter :: String -> String capitalizeFirstLetter s = case s of [] -> [] (x:xs) -> toUpper x : xs ordPairs :: [(String, Orderer)] ordPairs = [ ("payee", ordering Q.payee) , ("date", ordering Q.dateTime) , ("flag", ordering Q.flag) , ("number", ordering Q.number) , ("account", ordering Q.account) , ("drCr", ordering Q.drCr) , ("qty", ordering Q.qty) , ("commodity", ordering Q.commodity) , ("postingMemo", ordering Q.postingMemo) , ("transactionMemo", ordering Q.transactionMemo) ] ords :: [(String, Orderer)] ords = ordPairs ++ uppers where uppers = map toReversed ordPairs toReversed (s, f) = (capitalizeFirstLetter s, flipOrder f) parseSort :: Parser (Exceptional Error Orderer) parseSort = let os = C.OptSpec ["sort"] ['s'] (C.OneArg f) f a = let matches = filter (\p -> a `isPrefixOf` (fst p)) ords in case matches of [] -> Ex.throw $ BadSortKeyError (pack a) x:[] -> return $ snd x _ -> Ex.throw $ BadSortKeyError (pack a) in C.parseOption [os]