Skip to content

Commit 55161d5

Browse files
committed
feat: support arithmetic expressions in posting amounts (closes #2648)
1 parent 46b588c commit 55161d5

3 files changed

Lines changed: 182 additions & 8 deletions

File tree

hledger-lib/Hledger/Data/Amount.hs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,10 +233,12 @@ showCommoditySymbol :: T.Text -> T.Text
233233
showCommoditySymbol = textQuoteIfNeeded
234234

235235
-- characters that may not be used in a non-quoted commodity symbol
236+
-- (')' is excluded so parenthesised amount expressions like (1 + 0.05) * $47.97
237+
-- keep working; use a quoted symbol like ")" if you really need one)
236238
isNonsimpleCommodityChar :: Char -> Bool
237239
isNonsimpleCommodityChar = liftA2 (||) isDigit isOther
238240
where
239-
otherChars = "-+.@*;\t\n \"{}=" :: T.Text
241+
otherChars = "-+.@*;\t\n \"{}=)" :: T.Text
240242
isOther c = T.any (==c) otherChars
241243

242244
quoteCommoditySymbolIfNeeded :: T.Text -> T.Text

hledger-lib/Hledger/Read/Common.hs

Lines changed: 153 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ import Control.Monad.State.Strict (MonadState, evalStateT, modify', get, put)
136136
import Control.Monad.Trans.Class (lift)
137137
import Data.Bifunctor (bimap, first, second)
138138
import Data.Char (digitToInt, isDigit, isSpace)
139-
import Data.Decimal (DecimalRaw (Decimal), Decimal)
139+
import Data.Decimal (Decimal, DecimalRaw (Decimal), decimalPlaces, normalizeDecimal, realFracToDecimal, roundTo)
140140
import Data.Either (rights)
141141
import Data.Function ((&))
142142
import 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.
918922
simpleamountp :: 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
10001127
parseamount :: String -> Either HledgerParseErrors Amount
10011128
parseamount 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

hledger/hledger.m4.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1630,6 +1630,32 @@ Scientific E notation is allowed:
16301630
1E-6
16311631
EUR 1E3
16321632

1633+
### Amount arithmetic
1634+
1635+
Amounts can also contain simple arithmetic expressions: `+`, `-`, `*` and `/`,
1636+
with the usual precedence, and parentheses for grouping.
1637+
The expression is evaluated and shown as a single amount.
1638+
This is useful eg when a payment has several parts,
1639+
or when you want to keep component amounts visible:
1640+
1641+
2026-10-14 some outing
1642+
expenses:meals $21.60 + $27.68 + $5.03 + $17.80
1643+
assets:checking
1644+
1645+
2026-10-15 widgets
1646+
expenses:business (1 + 0.05) * $47.97 ; price plus 5% tax
1647+
assets:checking
1648+
1649+
Rules:
1650+
1651+
- With `+` and `-`, all terms must be in the same commodity.
1652+
A term written without a commodity symbol adopts the previous one's commodity
1653+
(`$5 + 3` means `$8`).
1654+
- With `*` and `/`, the right hand operand must be a plain number without a commodity symbol
1655+
(`$4.20 * 2`, `$10 / 4`). A plain number left operand is also allowed with `*`
1656+
(`(1 + 0.05) * $47.97`).
1657+
- Arithmetic on different commodities, such as `$5 + €3`, and division by zero, are errors.
1658+
16331659
<a name="decimal-marks-digit-group-marks"></a>
16341660

16351661
### Decimal marks

0 commit comments

Comments
 (0)