diff --git a/src/main/frontend/extensions/calc.cljc b/src/main/frontend/extensions/calc.cljc index d20f0d301..552c53d13 100644 --- a/src/main/frontend/extensions/calc.cljc +++ b/src/main/frontend/extensions/calc.cljc @@ -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)))) ;; ====================================================================== diff --git a/src/main/grammar/calc.bnf b/src/main/grammar/calc.bnf index 12466ef6b..a6f1addc2 100644 --- a/src/main/grammar/calc.bnf +++ b/src/main/grammar/calc.bnf @@ -1,14 +1,16 @@ - = assignment | expr | comment -expr = add-sub comment + = assignment | expr | comment | mode +expr = add-sub [comment] comment = <#'\s*(#.*$)?'> = pow-term | mul-div | add | sub | variable add = add-sub <'+'> mul-div sub = add-sub <'-'> mul-div - = pow-term | mul | div + = pow-term | mul | div | mod mul = mul-div <'*'> pow-term div = mul-div <'/'> pow-term - = pow | term +mod = mul-div <'mod'> pow-term + = pow | factorial | term pow = posterm <'^'> pow-term +factorial = posterm <'!'> <#'\s*'> = 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*'> = function | percent | scientific | number | variable | <#'\s*'> <'('> expr <')'> <#'\s*'> -negterm = <#'\s*'> <'-'> posterm | <#'\s*'> <'-'> pow +negterm = <#'\s*'> <'-'> posterm | <#'\s*'> <'-'> pow | <#'\s*'> <'-'> factorial = 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 + = #'\s*(\d+(,\d+)*(\.\d*)?|\d*\.\d+)\s*' + = #'\s*0x([0-9a-fA-F]+(,[0-9a-fA-F]+)*(\.[0-9a-fA-F]*)?|[0-9a-fA-F]*\.[0-9a-fA-F]+)\s*' + = #'\s*0o([0-7]+(,[0-7]+)*(\.[0-7]*)?|[0-7]*\.[0-7]+)\s*' + = #'\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 \ No newline at end of file +assignment = toassign <#'\s*'> <'='> <#'\s*'> expr + = <#'\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 + = #'(?i)hex' <#'(?i)(adecimal)?'> + = #'(?i)dec' <#'(?i)(imal)?'> + = #'(?i)oct' <#'(?i)(al)?'> + = #'(?i)bin' <#'(?i)(ary)?'> +digits = #'\d+' \ No newline at end of file diff --git a/src/test/frontend/extensions/calc_test.cljc b/src/test/frontend/extensions/calc_test.cljc index 79ee24e4d..c94b3e212 100644 --- a/src/test/frontend/extensions/calc_test.cljc +++ b/src/test/frontend/extensions/calc_test.cljc @@ -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 = _")))