@@ -136,7 +136,7 @@ import Control.Monad.State.Strict (MonadState, evalStateT, modify', get, put)
136136import Control.Monad.Trans.Class (lift )
137137import Data.Bifunctor (bimap , first , second )
138138import Data.Char (digitToInt , isDigit , isSpace )
139- import Data.Decimal (DecimalRaw (Decimal ), Decimal )
139+ import Data.Decimal (Decimal , DecimalRaw (Decimal ), decimalPlaces , normalizeDecimal , realFracToDecimal , roundTo )
140140import Data.Either (rights )
141141import Data.Function ((&) )
142142import Data.Functor ((<&>) , ($>) , void )
@@ -915,14 +915,69 @@ amountnobasisp =
915915-- An amount with no cost or cost basis.
916916-- A flag indicates whether we are parsing a multiplier amount;
917917-- if not, a commodity-less amount will have the default commodity applied to it.
918+ --
919+ -- This is also an arithmetic expression of amounts, like
920+ -- @$21.60 + $27.68@, @2 * $200@ or @(1 + 0.05) * $47.97@;
921+ -- see 'applyArithOp' for how such expressions are evaluated.
918922simpleamountp :: Bool -> JournalParser m Amount
919- simpleamountp mult =
923+ simpleamountp mult =
920924 -- dbg "simpleamountp" $
921- do
922- sign <- lift signp
923- leftsymbolamountp sign <|> rightornosymbolamountp sign
924-
925+ additiveexprp
925926 where
927+ -- An expression of amounts combined with + and -, with the usual precedence:
928+ -- * and / bind tighter, and parentheses group (see 'primaryexprp').
929+ additiveexprp :: JournalParser m Amount
930+ additiveexprp = do
931+ t <- multiplicativeexprp
932+ more t
933+ where
934+ more acc = option acc $ try $ do
935+ lift skipNonNewlineSpaces
936+ offBefore <- getOffset
937+ op <- char ' +' <|> char ' -'
938+ lift skipNonNewlineSpaces
939+ rhs <- multiplicativeexprp
940+ offAfter <- getOffset
941+ case applyArithOp op acc rhs of
942+ Left err -> customFailure $ uncurry parseErrorAtRegion (offBefore, offAfter) err
943+ Right amt' -> more amt'
944+
945+ -- An expression of amounts combined with * and /.
946+ multiplicativeexprp :: JournalParser m Amount
947+ multiplicativeexprp = do
948+ t <- primaryexprp
949+ more t
950+ where
951+ more acc = option acc $ try $ do
952+ lift skipNonNewlineSpaces
953+ offBefore <- getOffset
954+ op <- char ' *' <|> char ' /'
955+ lift skipNonNewlineSpaces
956+ rhs <- primaryexprp
957+ offAfter <- getOffset
958+ case applyArithOp op acc rhs of
959+ Left err -> customFailure $ uncurry parseErrorAtRegion (offBefore, offAfter) err
960+ Right amt' -> more amt'
961+
962+ -- A parenthesised expression, or a single signed amount.
963+ -- Note: a parenthesised expression is only recognised where an amount's first
964+ -- term would appear; after an amount, ( still begins a ledger-style cost or
965+ -- a lot note, as before.
966+ primaryexprp :: JournalParser m Amount
967+ primaryexprp = parenthesisedexprp <|> signedsingleamountp
968+ where
969+ parenthesisedexprp = do
970+ char ' ('
971+ lift skipNonNewlineSpaces
972+ e <- additiveexprp
973+ lift skipNonNewlineSpaces
974+ char ' )'
975+ pure e
976+
977+ signedsingleamountp = do
978+ sign <- lift signp
979+ leftsymbolamountp sign <|> rightornosymbolamountp sign
980+
926981 -- An amount with commodity symbol on the left.
927982 leftsymbolamountp :: (Decimal -> Decimal ) -> JournalParser m Amount
928983 leftsymbolamountp sign = label " amount" $ do
@@ -996,6 +1051,78 @@ simpleamountp mult =
9961051 uncurry parseErrorAtRegion posRegion errMsg
9971052 Right (q,p,d,g) -> pure (q, Precision p, d, g)
9981053
1054+ -- | Apply an arithmetic operator ('+', '-', '*' or '/') to two amounts parsed
1055+ -- in an amount expression like @$21.60 + $27.68@. Rules:
1056+ --
1057+ -- * @+@ and @-@ require both operands to be in the same commodity;
1058+ -- an operand written without a commodity symbol adopts the other operand's commodity.
1059+ -- So @$5 + 3@ is $8, and @2 + €3@ is €5.
1060+ -- * @*@ and @/@ require the right operand to be commodity-less,
1061+ -- ie a plain number multiplier or divisor. So @$4.20 * 2@ is valid, @$4.20 * 2 AAPL@ is not.
1062+ -- As an exception, @*@ also allows a commodity-less left operand, so @(1 + 0.05) * $47.97@ is valid.
1063+ --
1064+ -- The left operand's display style is kept, with the display precision widened as needed:
1065+ -- addition, subtraction and multiplication are computed exactly; division is computed
1066+ -- with six extra decimal places of precision (then trailing zeroes are trimmed).
1067+ applyArithOp :: Char -> Amount -> Amount -> Either String Amount
1068+ applyArithOp c x y = case c of
1069+ ' +' -> combine (+)
1070+ ' -' -> combine (-)
1071+ ' *' -> scale (*)
1072+ ' /' -> divide
1073+ _ -> Left (" unsupported arithmetic operator in amount expression: " ++ [c])
1074+ where
1075+ xq = aquantity x
1076+ yq = aquantity y
1077+ xc = acommodity x
1078+ yc = acommodity y
1079+ xdps = qtyDps xq
1080+ ydps = qtyDps yq
1081+
1082+ -- Update an operand's quantity, keeping its style but setting the display
1083+ -- precision to match the result's decimal places.
1084+ keepStyle a p q' = a{aquantity= q', astyle= (astyle a){asprecision= Precision p}}
1085+
1086+ qtyDps :: Quantity -> Word8
1087+ qtyDps = fromIntegral . min 255 . decimalPlaces . normalizeDecimal
1088+
1089+ -- Display precision follows the result's actual decimal places for + and -
1090+ -- (Data.Decimal preserves exponents there). For *, Data.Decimal strips
1091+ -- trailing zeroes (4.20*2 is 8.4), so precision is the sum of the
1092+ -- operands' own decimal places instead (giving the expected $8.40).
1093+ resultDps :: Quantity -> Word8
1094+ resultDps = fromIntegral . min 255 . decimalPlaces
1095+
1096+ rawDps :: Quantity -> Integer
1097+ rawDps = toInteger . decimalPlaces
1098+
1099+ combine op
1100+ | xc == yc = Right (keepStyle x p q')
1101+ | yc == " " && xc /= " " = Right (keepStyle x p q')
1102+ | xc == " " && yc /= " " = Right (keepStyle y p q')
1103+ | otherwise =
1104+ Left (printf " arithmetic on amounts with different commodities (%s and %s) is not supported"
1105+ (T. unpack xc) (T. unpack yc))
1106+ where
1107+ q' = xq `op` yq
1108+ p = resultDps q'
1109+
1110+ scale op
1111+ | c == ' *' && xc == " " = Right (keepStyle y p q')
1112+ | yc /= " " = Left (" the right operand of " ++ [c] ++ " in an amount expression must be a plain number" )
1113+ | otherwise = Right (keepStyle x p q')
1114+ where
1115+ q' = xq `op` yq
1116+ p = fromIntegral (min 255 (rawDps xq + rawDps yq))
1117+
1118+ divide
1119+ | yc /= " " = Left " the divisor in an amount expression must be a plain number"
1120+ | yq == 0 = Left " division by zero in amount expression"
1121+ | otherwise =
1122+ let p = fromInteger $ min 255 $ max (toInteger xdps) (toInteger ydps) + 6
1123+ q' = normalizeDecimal (realFracToDecimal p (toRational xq / toRational yq))
1124+ in Right (keepStyle x (qtyDps q') q')
1125+
9991126-- | Try to parse a single-commodity amount from a string
10001127parseamount :: String -> Either HledgerParseErrors Amount
10011128parseamount s = runParser (evalStateT (amountp <* eof) nulljournal) " " (T. pack s)
@@ -1833,7 +1960,26 @@ tests_Common = testGroup "Common" [
18331960 }
18341961 ,testCase " unit price, parenthesised" $ assertParse amountp " $10 (@) €0.5"
18351962 ,testCase " total price, parenthesised" $ assertParse amountp " $10 (@@) €0.5"
1836- ]
1963+ ,testCase " addition expression" $ assertParseEq amountp " $21.60 + $27.68 + $5.03 + $17.80"
1964+ (usd 72.11 )
1965+ ,testCase " subtraction expression" $ assertParseEq amountp " $5 - $3.25"
1966+ -- keeps the left operand's style ($5 records no decimal mark)
1967+ nullamt{acommodity= " $" , aquantity= 1.75 , astyle= amountstyle{asprecision= Precision 2 , asdecimalmark= Nothing }}
1968+ ,testCase " bare number adopts previous commodity" $
1969+ assertParseEq amountp " $5 + 3"
1970+ nullamt{acommodity= " $" , aquantity= 8 , astyle= amountstyle{asprecision= Precision 0 , asdecimalmark= Nothing }}
1971+ ,testCase " multiplication by plain number keeps cent precision" $
1972+ assertParseEq amountp " $4.20 * 2" (usd 8.40 )
1973+ ,testCase " division by plain number trims trailing zeroes" $
1974+ assertParseEq amountp " $10 / 4"
1975+ nullamt{acommodity= " $" , aquantity= 2.5 , astyle= amountstyle{asprecision= Precision 1 , asdecimalmark= Nothing }}
1976+ ,testCase " parenthesised scalar multiplies amount, with precedence" $
1977+ assertParseEq amountp " (1 + 0.05) * $47.97"
1978+ (nullamt{acommodity= " $" , aquantity= roundTo 4 50.3685 , astyle= amountstyle{asprecision= Precision 4 }})
1979+ ,testCase " arithmetic on different commodities is rejected" $
1980+ assertParseError amountp " $5 + €3" " "
1981+ ,testCase " division by zero is rejected" $ assertParseError amountp " $10 / 0" " "
1982+ ]
18371983
18381984 ,let p = lift (numberp Nothing ) :: JournalParser IO (Quantity , Word8 , Maybe Char , Maybe DigitGroupStyle ) in
18391985 testCase " numberp" $ do
0 commit comments