Add operations, display formats, constants and bases

New operations:
- factorial operator `!`;
- modulo operator `mod`.

Constants added:
- `PI`
- `E`

New display format controls:
- `:fix <places>` display format;
- `:sci <places>` display format;
- `:norm <display-precision>` display format;
- `:hex`, `:oct`, `:bin` and`:dec` display formats.

New number formats:
- Enter hexadecimal, octal and binary numbers using `0x`, `0o` and `0b` prefixes respectively.

Bugs fixed:
- Loss of precision when supplying numbers in scientific notation.
pull/6056/head
Martin Scott 2022-07-15 19:43:34 +01:00
parent 903e5c8a3b
commit 70c04e34bf
3 changed files with 179 additions and 15 deletions

View File

@ -1,7 +1,6 @@
(ns frontend.extensions.calc
(:refer-clojure :exclude [eval])
(:require [clojure.edn :as edn]
[clojure.string :as str]
(:require [clojure.string :as str]
[frontend.util :as util]
[bignumber.js :as bn]
@ -18,6 +17,10 @@
#?(:clj (def parse (insta/parser (io/resource "grammar/calc.bnf")))
:cljs (defparser parse (rc/inline "grammar/calc.bnf")))
(def constants {
"PI" (bn/BigNumber "3.14159265358979323846")
"E" (bn/BigNumber "2.71828182845904523536")})
(defn exception? [e]
#?(:clj (instance? Exception e)
:cljs (instance? js/Error e)))
@ -29,28 +32,38 @@
;; TODO: Set DECIMAL_PLACES https://mikemcl.github.io/bignumber.js/#decimal-places
(defn factorial [n]
(reduce
(fn [a b] (.multipliedBy a b))
(bn/BigNumber 1)
(range 2 (inc n))))
(defn eval* [env ast]
(insta/transform
{:number (comp bn/BigNumber #(str/replace % "," ""))
:percent (fn percent [a] (-> a (.dividedBy 100.00)))
:scientific (comp bn/BigNumber edn/read-string)
:scientific bn/BigNumber
:negterm (fn neg [a] (-> a (.negated)))
:expr identity
:add (fn add [a b] (-> a (.plus b)))
:sub (fn sub [a b] (-> a (.minus b)))
:mul (fn mul [a b] (-> a (.multipliedBy b)))
:div (fn div [a b] (-> a (.dividedBy b)))
:mod (fn mod [a b] (-> a (.modulo b)))
:pow (fn pow [a b] (if (.isInteger b)
(.exponentiatedBy a b)
#?(:clj (java.lang.Math/pow a b)
:cljs (bn/BigNumber (js/Math.pow a b)))))
:factorial (fn fact [a] (if (and (.isInteger a) (.isPositive a) (.isLessThan a 254))
(factorial (.toNumber a))
(bn/BigNumber 'NaN')))
:abs (fn abs [a] (.abs a))
:sqrt (fn abs [a] (.sqrt a))
:sqrt (fn sqrt [a] (.sqrt a))
:log (fn log [a]
#?(:clj (java.lang.Math/log10 a) :cljs (bn/BigNumber (js/Math.log10 a))))
:ln (fn ln [a]
#?(:clj (java.lang.Math/log a) :cljs (bn/BigNumber (js/Math.log a))))
:exp (fn ln [a]
:exp (fn exp [a]
#?(:clj (java.lang.Math/exp a) :cljs (bn/BigNumber (js/Math.exp a))))
:sin (fn sin [a]
#?(:clj (java.lang.Math/sin a) :cljs (bn/BigNumber(js/Math.sin a))))
@ -65,13 +78,31 @@
:acos (fn acos [a]
#?(:clj (java.lang.Math/acos a) :cljs (bn/BigNumber(js/Math.acos a))))
:assignment (fn assign! [var val]
(swap! env assoc var val)
(if (contains? constants var)
(throw
(ex-info (util/format "Can't redefine constant %s" var) {:var var}))
(swap! env assoc var val))
val)
:toassign str/trim
:comment (constantly nil)
:digits int
:mode-fix (fn format [places]
(swap! env assoc :mode "fix" :places places)
(get @env "last"))
:mode-sci (fn format [places]
(swap! env assoc :mode "sci" :places places)
(get @env "last"))
:mode-norm (fn format [precision]
(swap! env dissoc :mode :places)
(swap! env assoc :precision precision)
(get @env "last"))
:mode-base (fn base [b]
(swap! env assoc :base (str/lower-case b))
(get @env "last"))
:variable (fn resolve [var]
(let [var (str/trim var)]
(or (get @env var)
(or (get constants var)
(get @env var)
(throw
(ex-info (util/format "Can't find variable %s" var)
{:var var})))))}
@ -92,12 +123,58 @@
(swap! env assoc "last" val))
val)
(defn can-fix?
"Check that number can render without loss of all significant digits,
and that the absolute value is less than 1e21."
[num places]
(or (.isZero num )
(let [mag (.abs num)
lower-bound (-> (bn/BigNumber 0.5) (.shiftedBy (- places)))
upper-bound (bn/BigNumber 1e21)]
(and (-> mag (.isGreaterThanOrEqualTo lower-bound))
(-> mag (.isLessThan upper-bound))))))
(defn can-fit?
"Check that number can render normally within the given number of digits.
Tolerance allows for leading zeros in a decimal fraction."
[num digits tolerance]
(and (< (.-e num) digits)
(.isInteger (.shiftedBy num (+ tolerance digits)))))
(defn format-val [env val]
(if (instance? bn/BigNumber val)
(let [mode (get @env :mode)
base (get @env :base)
places (get @env :places)]
(cond
(= base "hex")
(.toString val 16)
(= base "oct")
(.toString val 8)
(= base "bin")
(.toString val 2)
(= mode "fix")
(if (can-fix? val places)
(.toFixed val places)
(.toExponential val places))
(= mode "sci")
(.toExponential val places)
:else
(let [precision (or (get @env :precision) 21)
display_val (.precision val precision)]
(if (can-fit? display_val precision 1)
(.toFixed display_val)
(.toExponential display_val)))))
val))
(defn eval-lines [s]
{:pre [(string? s)]}
(let [env (new-env)]
(mapv (fn [line]
(when-not (str/blank? line)
(assign-last-value env (eval env (parse line)))))
(format-val env (assign-last-value env (eval env (parse line))))))
(str/split-lines s))))
;; ======================================================================

View File

@ -1,14 +1,16 @@
<start> = assignment | expr | comment
expr = add-sub comment
<start> = assignment | expr | comment | mode
expr = add-sub [comment]
comment = <#'\s*(#.*$)?'>
<add-sub> = pow-term | mul-div | add | sub | variable
add = add-sub <'+'> mul-div
sub = add-sub <'-'> mul-div
<mul-div> = pow-term | mul | div
<mul-div> = pow-term | mul | div | mod
mul = mul-div <'*'> pow-term
div = mul-div <'/'> pow-term
<pow-term> = pow | term
mod = mul-div <'mod'> pow-term
<pow-term> = pow | factorial | term
pow = posterm <'^'> pow-term
factorial = posterm <'!'> <#'\s*'>
<function> = log | ln | exp | sqrt | abs | sin | cos | tan | acos | asin | atan
log = <#'\s*'> <'log('> expr <')'> <#'\s*'>
ln = <#'\s*'> <'ln('> expr <')'> <#'\s*'>
@ -22,11 +24,25 @@ atan = <#'\s*'> <'atan('> expr <')'> <#'\s*'>
acos = <#'\s*'> <'acos('> expr <')'> <#'\s*'>
asin = <#'\s*'> <'asin('> expr <')'> <#'\s*'>
<posterm> = function | percent | scientific | number | variable | <#'\s*'> <'('> expr <')'> <#'\s*'>
negterm = <#'\s*'> <'-'> posterm | <#'\s*'> <'-'> pow
negterm = <#'\s*'> <'-'> posterm | <#'\s*'> <'-'> pow | <#'\s*'> <'-'> factorial
<term> = negterm | posterm
scientific = #'\s*[0-9]*\.?[0-9]+(e|E)[\-\+]?[0-9]+()\s*'
number = #'\s*(\d+(,\d+)*(\.\d*)?|\d*\.\d+)\s*'
number = decimal-number | hexadecimal-number | octal-number | binary-number
<decimal-number> = #'\s*(\d+(,\d+)*(\.\d*)?|\d*\.\d+)\s*'
<hexadecimal-number> = #'\s*0x([0-9a-fA-F]+(,[0-9a-fA-F]+)*(\.[0-9a-fA-F]*)?|[0-9a-fA-F]*\.[0-9a-fA-F]+)\s*'
<octal-number> = #'\s*0o([0-7]+(,[0-7]+)*(\.[0-7]*)?|[0-7]*\.[0-7]+)\s*'
<binary-number> = #'\s*0b([01]+(,[01]+)*(\.[01]*)?|[01]*\.[01]+)\s*'
percent = number <'%'> <#'\s*'>
variable = #'\s*_*[a-zA-Z]+[_a-zA-Z0-9]*\s*'
toassign = #'\s*_*[a-zA-Z]+[_a-zA-Z0-9]*\s*'
assignment = toassign <#'\s*'> <'='> <#'\s*'> expr
assignment = toassign <#'\s*'> <'='> <#'\s*'> expr
<mode> = <#'\s*\:'> ( mode-fix | mode-sci | mode-norm | mode-base ) <#'\s*'> [comment]
mode-fix = <#'(?i)fix(ed)?\s*'> digits
mode-sci = <#'(?i)sci(entific)?\s*'> [digits]
mode-norm = <#'(?i)norm(al)?\s*'> [digits]
mode-base = mode-hex | mode-dec | mode-oct | mode-bin
<mode-hex> = #'(?i)hex' <#'(?i)(adecimal)?'>
<mode-dec> = #'(?i)dec' <#'(?i)(imal)?'>
<mode-oct> = #'(?i)oct' <#'(?i)(al)?'>
<mode-bin> = #'(?i)bin' <#'(?i)(ary)?'>
digits = #'\d+'

View File

@ -130,6 +130,23 @@
1.0 "exp(0)"
2.0 "ln(exp(2))")))
(deftest additional-operators
(testing "mod"
(are [value expr] (= value (run expr))
0.0 "1 mod 1"
1.0 "7 mod 3"
3.0 "7 mod 4"
0.5 "4.5 mod 2"
-3.0 "-7 mod 4"))
(testing "factorial"
(are [value expr] (= value (run expr))
1.0 "0!"
1.0 "1!"
6.0 "3.0!"
-120.0 "-5!"
124.0 "(2+3)!+4"
240.0 "10 * 4!")))
(deftest variables
(testing "variables can be remembered"
(are [final-env expr] (let [env (calc/new-env)]
@ -182,6 +199,59 @@
[25 5] ["3^2+4^2" "sqrt(last)"]
[6 12] ["2*3" "# a comment" "" " " "last*2"])))
(deftest formatting
(testing "display normal"
(are [values exprs] (let [env (calc/new-env)]
(mapv (fn [expr]
(calc/eval env (calc/parse expr)))
exprs))
[1e6 "1000000"] ["1e6" ":norm"]
[1e6 "1000000"] ["1e6" ":norm 7"]
[1e6 "1e+6"] ["1e6" ":norm 6"]
[0 "0" "3.14"] ["0" ":norm 3" "PI"]
[0 "0" "2"] ["0" ":norm 1" "E"]
[0.000123 "0.000123"] ["0.000123" ":norm 5"]
[0.000123 "1.23e-4"] ["0.000123" ":norm 4"]
[123400000 "123400000"] ["1.234e8" ":norm 9"]
[123400000 "1.234e+8"] ["1.234e8" ":norm 8"]))
(testing "display fixed"
(are [values exprs] (let [env (calc/new-env)]
(mapv (fn [expr]
(calc/eval env (calc/parse expr)))
exprs))
[0.12345 "0.123450"] ["0.12345" ":fix 6"]
[0.12345 "0.1235"] ["0.12345" ":fix 4"]
["" "2.7183"] [":fixed 4" "E"]
[0.0005 "0.001"] ["0.0005" ":fix 3"]
[0.0005 "4.000e-4"] ["0.0004" ":fix 3"]
[1e21 "1.00e+21"] ["1e21+0.1" ":fix 2"]))
(testing "display scientific"
(are [values exprs] (let [env (calc/new-env)]
(mapv (fn [expr]
(calc/eval env (calc/parse expr)))
exprs))
[1e6 "1e+6"] ["1e6" ":sci"]
[0 "0.000e0" "3.142e+3"]["0" ":sci 3" "PI"]
["" "3.14e+2"] [":sci" "3.14*10^2"])))
(deftest base-conversion
(testing "mixed base input"
(are [value expr] (= value (run expr))
255.0 "0xff"
511.0 "0x0A + 0xF5 + 0x100"
83.0 "0o123"
324.0 "0x100 + 0o100 + 0b100"
32.0 "0b100 * 0b1000"))
(testing "mixed base output"
(are [values exprs] (let [env (calc/new-env)]
(mapv (fn [expr]
(calc/eval env (calc/parse expr)))
exprs))
["12345" "3039"] ["12345" ":hex"]
["12345" "30071"] ["12345" ":oct"]
["12345" "11000000111001"]["12345" ":bin"]
["" "100000000"] [":bin" "0b10000 * 0b10000"])))
(deftest comments
(testing "comments are ignored"
(are [value expr] (= value (run expr))
@ -201,4 +271,5 @@
" . "
"_ = 2"
"__ = 4"
"PI = 3.14"
"foo_3 = _")))